host_identity/wrap.rs
1//! Strategies for wrapping a raw identifier into a [`uuid::Uuid`].
2//!
3//! Name-based UUID generation follows
4//! [RFC 9562 § 5.3 (`UUIDv3`, MD5)](https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-3)
5//! and [§ 5.5 (`UUIDv5`, SHA-1)](https://datatracker.ietf.org/doc/html/rfc9562#name-uuid-version-5),
6//! which obsoleted [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122).
7//! RFC 9562 recommends `UUIDv5` over `UUIDv3` for new work; this crate exposes
8//! both and defaults to `UUIDv5`. The hashing is performed by the
9//! [`uuid`](https://docs.rs/uuid) crate's `new_v5` / `new_v3` constructors.
10
11use uuid::Uuid;
12
13/// Namespace used for the default UUID v5 wrap strategy.
14///
15/// Fixed for the life of the crate so a given raw identifier always maps to
16/// the same UUID. Chosen randomly; not shared with any other tool, which is
17/// the point — two tools wrapping the same machine-id under different
18/// namespaces produce different UUIDs and will not collide.
19pub const DEFAULT_NAMESPACE: Uuid = Uuid::from_bytes([
20 0x6f, 0x63, 0x1b, 0x9a, 0x2d, 0x4c, 0x5e, 0x11, 0x9b, 0x21, 0x3f, 0x8a, 0xc0, 0x7e, 0x44, 0x21,
21]);
22
23/// How the raw identifier produced by a [`crate::Source`] is turned into a
24/// [`uuid::Uuid`].
25///
26/// Pick one with [`crate::Resolver::with_wrap`]. The default
27/// ([`Wrap::UuidV5Namespaced`]) is the right choice for new code; the other
28/// variants exist for specific interop scenarios.
29///
30/// | Variant | When to use |
31/// | ----------------------- | ----------------------------------------------------------------------------------------------------------------- |
32/// | [`UuidV5Namespaced`] | Default. Strongest collision resistance; rehashes under a private namespace so two tools sharing a raw source cannot collide. |
33/// | [`UuidV5With`] | You want v5 hashing but need the wrapped UUID to live in a namespace already used by another system. |
34/// | [`UuidV3Nil`] | Wire-compatible with the legacy Go derivation `uuid.NewMD5(uuid.Nil, raw)`. Interop only; prefer v5 otherwise. |
35/// | [`Passthrough`] | The source already yields a UUID and you want *that exact UUID* to survive unchanged (e.g. match another agent). |
36///
37/// All deterministic: the same raw input always produces the same UUID.
38///
39/// [`UuidV5Namespaced`]: Wrap::UuidV5Namespaced
40/// [`UuidV5With`]: Wrap::UuidV5With
41/// [`UuidV3Nil`]: Wrap::UuidV3Nil
42/// [`Passthrough`]: Wrap::Passthrough
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44#[non_exhaustive]
45pub enum Wrap {
46 /// UUID v5 (SHA-1) under the crate's [`DEFAULT_NAMESPACE`]. Default;
47 /// strongest collision resistance of the deterministic options and the
48 /// right choice unless you have a concrete interop requirement.
49 ///
50 /// Rehashes the raw value even when the source already yields a UUID
51 /// (DMI `product_uuid`, macOS `IOPlatformUUID`, Windows `MachineGuid`,
52 /// SMBIOS). That is intentional: it prevents two tools that share a
53 /// raw source (e.g. two agents both reading `/etc/machine-id`) from
54 /// emitting colliding IDs. Use [`Wrap::Passthrough`] when you
55 /// explicitly want the source's own UUID to survive unchanged, or
56 /// [`Wrap::UuidV5With`] when you need a different namespace.
57 #[default]
58 UuidV5Namespaced,
59
60 /// UUID v5 (SHA-1) under a caller-supplied namespace. Same algorithm
61 /// as [`Wrap::UuidV5Namespaced`] with a different namespace constant.
62 ///
63 /// Use when another system in your stack already hashes identifiers
64 /// under a well-known namespace (e.g. a product-wide DNS namespace)
65 /// and you want this crate's output to sit in that same space so IDs
66 /// cross-correlate. If you don't have such a namespace, stick with
67 /// the default.
68 UuidV5With(Uuid),
69
70 /// UUID v3 (MD5) under the nil namespace — wire-compatible with the
71 /// legacy Go derivation `uuid.NewMD5(uuid.Nil, raw)`.
72 ///
73 /// Use only for interop with existing pipelines that already produced
74 /// IDs this way; MD5 has no security relevance here, but RFC 9562
75 /// recommends v5 over v3 for new work and so does this crate.
76 UuidV3Nil,
77
78 /// Parse the raw value directly as a UUID, with no hashing.
79 ///
80 /// Use when the source already yields a UUID string (DMI
81 /// `product_uuid`, macOS `IOPlatformUUID`, Windows `MachineGuid`,
82 /// `kenv smbios.system.uuid`, container IDs, Kubernetes pod UIDs)
83 /// and you want *that exact UUID* to survive unchanged — for example,
84 /// to match the ID another agent on the same host already reports.
85 ///
86 /// Returns `None` (surfaced as [`crate::Error::Malformed`] from the
87 /// resolver) when the raw value is not a parseable UUID, so this
88 /// strategy is unsafe to pair with sources that emit arbitrary
89 /// strings (e.g. `HOST_IDENTITY=my-server`).
90 ///
91 /// Accepts every form [`uuid::Uuid::parse_str`] accepts — hyphenated
92 /// (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`), simple (no hyphens),
93 /// braced (`{…}`), and the RFC-9562 `urn:uuid:…` form. The parsed
94 /// UUID is returned in canonical form regardless of the input shape.
95 Passthrough,
96}
97
98impl Wrap {
99 /// Apply this strategy to a raw identifier.
100 ///
101 /// Returns `None` for [`Wrap::Passthrough`] when the raw value cannot be
102 /// parsed as a UUID. All other strategies always succeed.
103 #[must_use]
104 pub fn apply(self, raw: &str) -> Option<Uuid> {
105 match self {
106 Self::UuidV5Namespaced => Some(Uuid::new_v5(&DEFAULT_NAMESPACE, raw.as_bytes())),
107 Self::UuidV5With(ns) => Some(Uuid::new_v5(&ns, raw.as_bytes())),
108 Self::UuidV3Nil => Some(Uuid::new_v3(&Uuid::nil(), raw.as_bytes())),
109 Self::Passthrough => Uuid::parse_str(raw).ok(),
110 }
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn v5_default_is_deterministic() {
120 let a = Wrap::UuidV5Namespaced.apply("host-x").unwrap();
121 let b = Wrap::UuidV5Namespaced.apply("host-x").unwrap();
122 assert_eq!(a, b);
123 }
124
125 #[test]
126 fn v5_distinct_namespaces_produce_distinct_uuids() {
127 let ns = Uuid::from_bytes([1; 16]);
128 let a = Wrap::UuidV5Namespaced.apply("host-x").unwrap();
129 let b = Wrap::UuidV5With(ns).apply("host-x").unwrap();
130 assert_ne!(a, b);
131 }
132
133 #[test]
134 fn passthrough_roundtrips_valid_uuid() {
135 let uuid = "12345678-1234-1234-1234-123456789abc";
136 assert_eq!(Wrap::Passthrough.apply(uuid), Uuid::parse_str(uuid).ok());
137 }
138
139 #[test]
140 fn passthrough_rejects_non_uuid() {
141 assert_eq!(Wrap::Passthrough.apply("not-a-uuid"), None);
142 }
143
144 #[test]
145 fn v3_nil_matches_go_legacy_derivation() {
146 // Wire-compat contract with agent-go's `uuid.NewMD5(uuid.Nil, raw)`.
147 // Must equal the stdlib Uuid::new_v3 under the nil namespace.
148 let expected = Uuid::new_v3(&Uuid::nil(), b"host-x");
149 assert_eq!(Wrap::UuidV3Nil.apply("host-x"), Some(expected));
150 }
151
152 #[test]
153 fn v3_nil_is_deterministic() {
154 let a = Wrap::UuidV3Nil.apply("host-x").unwrap();
155 let b = Wrap::UuidV3Nil.apply("host-x").unwrap();
156 assert_eq!(a, b);
157 }
158
159 #[test]
160 fn non_passthrough_strategies_always_return_some() {
161 // Locks the "All other strategies always succeed" contract
162 // documented on `Wrap::apply`. Empty, whitespace-only, and
163 // long pathological inputs must never produce `None`.
164 let ns = Uuid::from_bytes([1; 16]);
165 let inputs = ["", " \n", &"a".repeat(10_000)];
166 for input in inputs {
167 assert!(Wrap::UuidV5Namespaced.apply(input).is_some());
168 assert!(Wrap::UuidV5With(ns).apply(input).is_some());
169 assert!(Wrap::UuidV3Nil.apply(input).is_some());
170 }
171 }
172}