outpost_core/
outpost_id.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub struct OutpostId(String);
18
19#[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}