Skip to main content

outpost_core/
outpost_id.rs

1use std::fmt;
2use std::path::Path;
3
4use sha2::{Digest, Sha256};
5
6const ID_LEN: usize = 64;
7pub const MIN_PREFIX_LEN: usize = 5;
8const DERIVED_OUTPOST_ID_HASH_NAMESPACE: &[u8] =
9    b"git-outpost derived outpost id from source path and outpost path v1";
10
11/// Deterministic display and selector alias for one registered outpost.
12///
13/// IDs are scoped to a single source registry and are derived from the source
14/// path and outpost path. They are not stored; moving an outpost changes its
15/// derived ID because the path is part of the outpost identity.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub struct OutpostId(String);
18
19/// Validated ID prefix accepted from human selectors.
20///
21/// Prefixes are accepted only when they are at least five hex characters and
22/// uniquely identify one entry within the current source registry.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct OutpostIdPrefix(String);
25
26impl OutpostId {
27    pub fn parse(value: impl Into<String>) -> Result<Self, String> {
28        let value = value.into();
29        if value.len() != ID_LEN {
30            return Err(format!(
31                "outpost id must be {ID_LEN} lowercase hex characters"
32            ));
33        }
34        if !value
35            .bytes()
36            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
37        {
38            return Err("outpost id must contain only lowercase hex characters".to_owned());
39        }
40        Ok(Self(value))
41    }
42
43    pub fn derive(source: &Path, outpost: &Path) -> Self {
44        let mut hasher = Sha256::new();
45        update_field(&mut hasher, DERIVED_OUTPOST_ID_HASH_NAMESPACE);
46        update_field(&mut hasher, path_bytes(source).as_ref());
47        update_field(&mut hasher, path_bytes(outpost).as_ref());
48        Self(hex_lower(hasher.finalize().as_slice()))
49    }
50
51    pub fn as_str(&self) -> &str {
52        &self.0
53    }
54
55    pub fn starts_with(&self, prefix: &OutpostIdPrefix) -> bool {
56        self.0.starts_with(prefix.as_str())
57    }
58}
59
60impl OutpostIdPrefix {
61    pub fn parse(value: impl Into<String>) -> Result<Self, String> {
62        let value = value.into().to_ascii_lowercase();
63        if value.len() < MIN_PREFIX_LEN {
64            return Err(format!(
65                "outpost id prefix must be at least {MIN_PREFIX_LEN} hex characters"
66            ));
67        }
68        if value.len() > ID_LEN {
69            return Err(format!(
70                "outpost id prefix must be at most {ID_LEN} hex characters"
71            ));
72        }
73        if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
74            return Err("outpost id prefix must contain only hex characters".to_owned());
75        }
76        Ok(Self(value))
77    }
78
79    pub fn as_str(&self) -> &str {
80        &self.0
81    }
82}
83
84impl fmt::Display for OutpostId {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(self.as_str())
87    }
88}
89
90impl fmt::Display for OutpostIdPrefix {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        f.write_str(self.as_str())
93    }
94}
95
96pub(crate) fn shortest_unique_prefixes<'a>(
97    ids: impl IntoIterator<Item = &'a OutpostId>,
98) -> Vec<String> {
99    let ids = ids.into_iter().collect::<Vec<_>>();
100    ids.iter()
101        .map(|id| shortest_unique_prefix(id, &ids))
102        .collect()
103}
104
105fn shortest_unique_prefix(id: &OutpostId, ids: &[&OutpostId]) -> String {
106    for len in MIN_PREFIX_LEN..=ID_LEN {
107        let prefix = &id.as_str()[..len];
108        if ids
109            .iter()
110            .filter(|candidate| candidate.as_str().starts_with(prefix))
111            .count()
112            == 1
113        {
114            return prefix.to_owned();
115        }
116    }
117    id.as_str().to_owned()
118}
119
120fn update_field(hasher: &mut Sha256, value: &[u8]) {
121    hasher.update(value.len().to_le_bytes());
122    hasher.update(value);
123}
124
125fn hex_lower(bytes: &[u8]) -> String {
126    const HEX: &[u8; 16] = b"0123456789abcdef";
127    let mut out = String::with_capacity(bytes.len() * 2);
128    for byte in bytes {
129        out.push(HEX[(byte >> 4) as usize] as char);
130        out.push(HEX[(byte & 0x0f) as usize] as char);
131    }
132    out
133}
134
135#[cfg(unix)]
136fn path_bytes(path: &Path) -> std::borrow::Cow<'_, [u8]> {
137    use std::os::unix::ffi::OsStrExt;
138
139    std::borrow::Cow::Borrowed(path.as_os_str().as_bytes())
140}
141
142#[cfg(not(unix))]
143fn path_bytes(path: &Path) -> std::borrow::Cow<'_, [u8]> {
144    path.to_string_lossy().as_bytes().to_vec().into()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn shortest_unique_prefixes_expand_from_minimum_only_when_needed() {
153        let first =
154            OutpostId::parse("abcde00000000000000000000000000000000000000000000000000000000000")
155                .expect("first id");
156        let second =
157            OutpostId::parse("abcdf00000000000000000000000000000000000000000000000000000000000")
158                .expect("second id");
159        let third =
160            OutpostId::parse("1234500000000000000000000000000000000000000000000000000000000000")
161                .expect("third id");
162
163        let prefixes = shortest_unique_prefixes([&first, &second, &third]);
164
165        assert_eq!(prefixes, vec!["abcde", "abcdf", "12345"]);
166    }
167
168    #[test]
169    fn shortest_unique_prefixes_expand_for_collision() {
170        let first =
171            OutpostId::parse("abcde00000000000000000000000000000000000000000000000000000000000")
172                .expect("first id");
173        let second =
174            OutpostId::parse("abcde10000000000000000000000000000000000000000000000000000000000")
175                .expect("second id");
176
177        let prefixes = shortest_unique_prefixes([&first, &second]);
178
179        assert_eq!(prefixes, vec!["abcde0", "abcde1"]);
180    }
181}