Skip to main content

sloop/
run_ref.rs

1//! Run identity: minted internal ids, ticket-derived aliases, and the shapes a
2//! run reference can take.
3//!
4//! A run has two names. The internal id is 128 random bits in lowercase hex;
5//! it is what the store joins on and what names directories on disk. The alias
6//! is `<ticket-id>-r<attempt>`, derived from two columns that are frozen at
7//! claim time, and it is the only name humans are shown. Nothing here reads the
8//! clock or the store: minting is a trait so the effect stays at the boundary,
9//! and every other function is a pure string judgement.
10
11use std::fs::File;
12use std::io::Read;
13
14/// Internal run ids are 128 bits rendered as lowercase hex.
15pub const RUN_ID_HEX_LEN: usize = 32;
16
17/// How much of an internal id is shown wherever one surfaces at all, matching
18/// Git's short-object convention.
19pub const SHORT_RUN_ID_LEN: usize = 8;
20
21/// The shortest prefix accepted as a reference. Below this a prefix says so
22/// little that the ambiguity error would be the only likely outcome.
23pub const MIN_PREFIX_LEN: usize = 4;
24
25/// Mints internal run ids. Implemented over the operating system CSPRNG in
26/// production and over a fixed sequence in tests, so claim-time logic can be
27/// exercised with predictable identities.
28pub trait RunIdSource: Send + Sync {
29    fn mint(&self) -> Result<String, String>;
30}
31
32/// The production source: 128 bits straight from the kernel.
33#[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/// A source that hands out a fixed sequence, then refuses. Exists so tests can
45/// drive claim-time logic with identities they chose in advance.
46#[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    // Both calls reach the kernel CSPRNG. Linux uses `getrandom` because musl
71    // libc does not export `getentropy`; `/dev/urandom` covers kernels that
72    // lack the syscall.
73    #[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
90/// The displayed form of an internal id. Legacy `R<n>` ids are shorter than the
91/// window and pass through whole.
92pub fn short(id: &str) -> &str {
93    id.get(..SHORT_RUN_ID_LEN).unwrap_or(id)
94}
95
96/// The human-facing name of a run. Both components are frozen at claim, so the
97/// alias never changes for the life of the run.
98pub fn alias(ticket_id: &str, attempt: i64) -> String {
99    format!("{ticket_id}-r{attempt}")
100}
101
102/// Splits an alias back into the ticket id and attempt it was built from.
103/// Ticket ids may contain `-`, so the split is taken from the right.
104pub 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
119/// Whether a reference could name internal ids by prefix. Ticket and project
120/// ids always carry a `-`, so they can never be mistaken for one.
121pub 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
127/// The accepted forms, named in every unresolvable-reference error so a dead
128/// end carries its own remedy.
129pub 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}