1use std::fs::File;
12use std::io::Read;
13
14pub const RUN_ID_HEX_LEN: usize = 32;
16
17pub const SHORT_RUN_ID_LEN: usize = 8;
20
21pub const MIN_PREFIX_LEN: usize = 4;
24
25pub trait RunIdSource: Send + Sync {
29 fn mint(&self) -> Result<String, String>;
30}
31
32#[derive(Debug, Default)]
34pub struct RandomRunIds;
35
36impl RunIdSource for RandomRunIds {
37 fn mint(&self) -> Result<String, String> {
38 let mut bytes = [0_u8; RUN_ID_HEX_LEN / 2];
39 random_bytes(&mut bytes)?;
40 Ok(hex(&bytes))
41 }
42}
43
44#[derive(Debug)]
47pub struct FixedRunIds {
48 remaining: std::sync::Mutex<std::collections::VecDeque<String>>,
49}
50
51impl FixedRunIds {
52 pub fn new(ids: impl IntoIterator<Item = String>) -> Self {
53 Self {
54 remaining: std::sync::Mutex::new(ids.into_iter().collect()),
55 }
56 }
57}
58
59impl RunIdSource for FixedRunIds {
60 fn mint(&self) -> Result<String, String> {
61 self.remaining
62 .lock()
63 .expect("the fixed id queue is not poisoned")
64 .pop_front()
65 .ok_or_else(|| "the fixed run id source is exhausted".into())
66 }
67}
68
69fn random_bytes(buffer: &mut [u8]) -> Result<(), String> {
70 #[cfg(target_os = "linux")]
74 let filled = unsafe { libc::getrandom(buffer.as_mut_ptr().cast(), buffer.len(), 0) }
75 == buffer.len() as libc::ssize_t;
76 #[cfg(not(target_os = "linux"))]
77 let filled = unsafe { libc::getentropy(buffer.as_mut_ptr().cast(), buffer.len()) } == 0;
78 if filled {
79 return Ok(());
80 }
81 File::open("/dev/urandom")
82 .and_then(|mut file| file.read_exact(buffer))
83 .map_err(|source| format!("cannot read random bytes for a run id: {source}"))
84}
85
86fn hex(bytes: &[u8]) -> String {
87 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
88}
89
90pub fn short(id: &str) -> &str {
93 id.get(..SHORT_RUN_ID_LEN).unwrap_or(id)
94}
95
96pub fn alias(ticket_id: &str, attempt: i64) -> String {
99 format!("{ticket_id}-r{attempt}")
100}
101
102pub fn parse_alias(reference: &str) -> Option<(&str, i64)> {
105 let (ticket_id, attempt) = reference.rsplit_once("-r")?;
106 if ticket_id.is_empty() || attempt.is_empty() {
107 return None;
108 }
109 if !attempt.bytes().all(|byte| byte.is_ascii_digit()) {
110 return None;
111 }
112 attempt
113 .parse()
114 .ok()
115 .filter(|attempt| *attempt > 0)
116 .map(|attempt| (ticket_id, attempt))
117}
118
119pub fn as_id_prefix(reference: &str) -> Option<String> {
122 let long_enough = (MIN_PREFIX_LEN..=RUN_ID_HEX_LEN).contains(&reference.len());
123 let hexadecimal = reference.bytes().all(|byte| byte.is_ascii_hexdigit());
124 (long_enough && hexadecimal).then(|| reference.to_ascii_lowercase())
125}
126
127pub const ACCEPTED_RUN_REFERENCES: &str = "an alias like `TICK-20-r1`, a ticket id or name for \
130 its latest run, or at least 4 characters of a run id";
131
132#[cfg(test)]
133mod tests {
134 use super::{
135 FixedRunIds, RUN_ID_HEX_LEN, RandomRunIds, RunIdSource, alias, as_id_prefix, parse_alias,
136 short,
137 };
138 use std::collections::HashSet;
139
140 #[test]
141 fn minted_ids_are_full_width_lowercase_hex_and_do_not_repeat() {
142 let source = RandomRunIds;
143 let mut seen = HashSet::new();
144 for _ in 0..512 {
145 let id = source.mint().expect("mint a run id");
146 assert_eq!(id.len(), RUN_ID_HEX_LEN, "{id}");
147 assert!(
148 id.bytes()
149 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
150 "{id}"
151 );
152 assert!(seen.insert(id.clone()), "minted `{id}` twice");
153 }
154 }
155
156 #[test]
157 fn an_injected_source_decides_the_identities_and_reports_its_own_exhaustion() {
158 let source = FixedRunIds::new(["aaaa1111".into(), "bbbb2222".into()]);
159
160 assert_eq!(source.mint().unwrap(), "aaaa1111");
161 assert_eq!(source.mint().unwrap(), "bbbb2222");
162 assert!(source.mint().is_err());
163 }
164
165 #[test]
166 fn display_shortens_minted_ids_and_leaves_legacy_ids_whole() {
167 assert_eq!(short("3f2a9c1b7d4e5061a2b3c4d5e6f70819"), "3f2a9c1b");
168 assert_eq!(short("R14"), "R14");
169 }
170
171 #[test]
172 fn aliases_round_trip_through_parsing() {
173 let alias = alias("TICK-20", 3);
174 assert_eq!(alias, "TICK-20-r3");
175 assert_eq!(parse_alias(&alias), Some(("TICK-20", 3)));
176 }
177
178 #[test]
179 fn alias_parsing_rejects_bare_tickets_and_malformed_attempts() {
180 for reference in [
181 "TICK-20",
182 "TICK-20-r",
183 "TICK-20-r0",
184 "TICK-20-rx",
185 "-r1",
186 "TICK-20-r+1",
187 ] {
188 assert_eq!(parse_alias(reference), None, "{reference}");
189 }
190 }
191
192 #[test]
193 fn prefixes_are_hexadecimal_of_a_useful_length_and_never_ticket_ids() {
194 assert_eq!(as_id_prefix("3F2a"), Some("3f2a".into()));
195 for reference in ["3f2", "TICK-20", "TICK-20-r1", "zzzz"] {
196 assert_eq!(as_id_prefix(reference), None, "{reference}");
197 }
198 }
199}