1use std::collections::HashMap;
62use std::fmt;
63use std::sync::{Mutex, OnceLock};
64
65use hmac::{Hmac, Mac};
66use sha2::Sha256;
67use uuid::Uuid;
68use zeroize::Zeroize;
69
70use crate::error::Error;
71use crate::source::{Probe, Source, SourceKind};
72
73type HmacSha256 = Hmac<Sha256>;
74
75pub struct AppSpecific<S: Source> {
92 inner: S,
93 app_id: Vec<u8>,
94 label: &'static str,
95}
96
97impl<S: Source> AppSpecific<S> {
98 #[must_use]
116 pub fn new(inner: S, app_id: impl Into<Vec<u8>>) -> Self {
117 let label = intern_label(inner.kind().as_str());
118 Self {
119 inner,
120 app_id: app_id.into(),
121 label,
122 }
123 }
124}
125
126fn intern_label(inner_id: &'static str) -> &'static str {
127 static INTERNER: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();
128 let mut map = INTERNER
129 .get_or_init(|| Mutex::new(HashMap::new()))
130 .lock()
131 .expect("label interner mutex poisoned");
132 if let Some(&existing) = map.get(inner_id) {
133 return existing;
134 }
135 let leaked: &'static str = Box::leak(format!("app-specific:{inner_id}").into_boxed_str());
136 map.insert(inner_id, leaked);
137 leaked
138}
139
140impl<S: Source> fmt::Debug for AppSpecific<S> {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 f.debug_struct("AppSpecific")
143 .field("inner", &self.inner.kind())
144 .field("app_id_len", &self.app_id.len())
145 .finish_non_exhaustive()
146 }
147}
148
149impl<S: Source> Drop for AppSpecific<S> {
150 fn drop(&mut self) {
151 self.app_id.zeroize();
152 }
153}
154
155impl<S: Source> Source for AppSpecific<S> {
156 fn kind(&self) -> SourceKind {
157 SourceKind::Custom(self.label)
158 }
159
160 fn probe(&self) -> Result<Option<Probe>, Error> {
161 let Some(probe) = self.inner.probe()? else {
162 return Ok(None);
163 };
164 let (_inner_kind, raw) = probe.into_parts();
165 let uuid = derive_app_specific_uuid(raw.as_bytes(), &self.app_id);
166 Ok(Some(Probe::new(self.kind(), uuid.hyphenated().to_string())))
167 }
168}
169
170fn derive_app_specific_uuid(raw: &[u8], app_id: &[u8]) -> Uuid {
173 let mut mac = HmacSha256::new_from_slice(raw).expect("HMAC-SHA256 accepts keys of any length");
174 mac.update(app_id);
175 let digest = mac.finalize().into_bytes();
176 let mut buf = [0u8; 16];
177 buf.copy_from_slice(&digest[..16]);
178 buf[6] = (buf[6] & 0x0F) | 0x40;
179 buf[8] = (buf[8] & 0x3F) | 0x80;
180 let uuid = Uuid::from_bytes(buf);
181 buf.zeroize();
182 uuid
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::source::{Probe, Source, SourceKind};
189 use crate::wrap::Wrap;
190
191 #[derive(Debug)]
193 struct Stub {
194 kind: SourceKind,
195 result: Result<Option<String>, &'static str>,
196 }
197
198 impl Stub {
199 fn ok(kind: SourceKind, v: &str) -> Self {
200 Self {
201 kind,
202 result: Ok(Some(v.to_owned())),
203 }
204 }
205 fn none(kind: SourceKind) -> Self {
206 Self {
207 kind,
208 result: Ok(None),
209 }
210 }
211 fn err(kind: SourceKind, msg: &'static str) -> Self {
212 Self {
213 kind,
214 result: Err(msg),
215 }
216 }
217 }
218
219 impl Source for Stub {
220 fn kind(&self) -> SourceKind {
221 self.kind
222 }
223 fn probe(&self) -> Result<Option<Probe>, Error> {
224 match &self.result {
225 Ok(Some(v)) => Ok(Some(Probe::new(self.kind, v.clone()))),
226 Ok(None) => Ok(None),
227 Err(msg) => Err(Error::Malformed {
228 source_kind: self.kind,
229 reason: (*msg).to_owned(),
230 }),
231 }
232 }
233 }
234
235 fn probe_value(s: &impl Source) -> String {
236 s.probe().unwrap().unwrap().value().to_owned()
237 }
238
239 #[test]
240 fn output_is_a_valid_version4_uuid() {
241 let wrapped = AppSpecific::new(
242 Stub::ok(SourceKind::MachineId, "abcdef0123456789abcdef0123456789"),
243 b"com.example.test".to_vec(),
244 );
245 let v = probe_value(&wrapped);
246 let parsed = Uuid::parse_str(&v).expect("valid UUID");
247 assert_eq!(parsed.get_version_num(), 4);
248 let variant_byte = parsed.as_bytes()[8];
249 assert_eq!(variant_byte & 0xC0, 0x80, "variant must be 10xx");
250 let re_parts: Vec<_> = v.split('-').collect();
252 assert_eq!(
253 re_parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
254 vec![8, 4, 4, 4, 12]
255 );
256 assert!(v.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
257 }
258
259 #[test]
260 fn construction_matches_manual_hmac_sha256() {
261 let raw = b"abcdef0123456789abcdef0123456789";
277 let app_id: [u8; 16] = [
278 0xa2, 0xb1, 0x6c, 0x2f, 0x0f, 0xa0, 0x4d, 0x32, 0xb3, 0xc3, 0x1e, 0xe8, 0xc2, 0x2c,
279 0x0b, 0x7e,
280 ];
281 let got = derive_app_specific_uuid(raw, &app_id);
282 let mut mac = HmacSha256::new_from_slice(raw).unwrap();
283 mac.update(&app_id);
284 let digest = mac.finalize().into_bytes();
285 let mut buf = [0u8; 16];
286 buf.copy_from_slice(&digest[..16]);
287 buf[6] = (buf[6] & 0x0F) | 0x40;
288 buf[8] = (buf[8] & 0x3F) | 0x80;
289 assert_eq!(got, Uuid::from_bytes(buf));
290 assert_eq!(got.get_version_num(), 4);
291 }
292
293 #[test]
294 fn determinism_over_many_iterations() {
295 let wrapped = AppSpecific::new(
296 Stub::ok(SourceKind::MachineId, "raw-value"),
297 b"app".to_vec(),
298 );
299 let first = probe_value(&wrapped);
300 for _ in 0..100 {
301 assert_eq!(probe_value(&wrapped), first);
302 }
303 }
304
305 #[test]
306 fn different_app_ids_produce_different_outputs() {
307 let a = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"app-1".to_vec());
308 let b = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"app-2".to_vec());
309 assert_ne!(probe_value(&a), probe_value(&b));
310 }
311
312 #[test]
313 fn different_inner_values_produce_different_outputs() {
314 let a = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"app".to_vec());
315 let b = AppSpecific::new(Stub::ok(SourceKind::MachineId, "y"), b"app".to_vec());
316 assert_ne!(probe_value(&a), probe_value(&b));
317 }
318
319 #[test]
320 fn passthrough_wrap_round_trips_the_probe() {
321 let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, "raw"), b"app".to_vec());
322 let v = probe_value(&wrapped);
323 let roundtrip = Wrap::Passthrough.apply(&v).expect("UUID-shaped");
324 assert_eq!(roundtrip, Uuid::parse_str(&v).unwrap());
325 }
326
327 #[test]
328 fn default_wrap_is_stable() {
329 let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, "raw"), b"app".to_vec());
330 let v1 = probe_value(&wrapped);
331 let v2 = probe_value(&wrapped);
332 assert_eq!(
333 Wrap::UuidV5Namespaced.apply(&v1),
334 Wrap::UuidV5Namespaced.apply(&v2),
335 );
336 }
337
338 #[test]
339 fn scope_label_is_app_specific_prefixed() {
340 let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, "raw"), b"app".to_vec());
341 assert_eq!(wrapped.kind().as_str(), "app-specific:machine-id");
342 let probe = wrapped.probe().unwrap().unwrap();
343 assert_eq!(probe.kind().as_str(), "app-specific:machine-id");
344 }
345
346 #[test]
347 fn inner_none_is_passed_through() {
348 let wrapped = AppSpecific::new(Stub::none(SourceKind::MachineId), b"app".to_vec());
349 assert!(wrapped.probe().unwrap().is_none());
350 }
351
352 #[test]
353 fn inner_err_is_passed_through() {
354 let wrapped = AppSpecific::new(Stub::err(SourceKind::MachineId, "boom"), b"app".to_vec());
355 let err = wrapped.probe().expect_err("error must propagate");
356 match err {
359 Error::Malformed {
360 source_kind,
361 reason,
362 } => {
363 assert_eq!(source_kind, SourceKind::MachineId);
364 assert_eq!(reason, "boom");
365 }
366 other => panic!("unexpected error variant: {other:?}"),
367 }
368 }
369
370 #[test]
371 fn label_is_interned_across_constructions() {
372 let a = AppSpecific::new(Stub::ok(SourceKind::MachineId, "x"), b"a".to_vec());
376 let b = AppSpecific::new(Stub::ok(SourceKind::MachineId, "y"), b"b".to_vec());
377 let (SourceKind::Custom(la), SourceKind::Custom(lb)) = (a.kind(), b.kind()) else {
378 panic!("AppSpecific must report SourceKind::Custom");
379 };
380 assert!(std::ptr::eq(la, lb), "label must be interned");
381 }
382
383 #[test]
384 fn empty_inputs_do_not_panic_and_produce_valid_uuids() {
385 let wrapped = AppSpecific::new(Stub::ok(SourceKind::MachineId, ""), Vec::<u8>::new());
387 let v = probe_value(&wrapped);
388 let parsed = Uuid::parse_str(&v).expect("valid UUID even with empty inputs");
389 assert_eq!(parsed.get_version_num(), 4);
390 }
391}