Skip to main content

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}