openlatch-client 0.5.8

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
//! The stable host identifier this install reports for licensing (I-4 D-01).
//!
//! Licensing counts **hosts**, and nothing already on the wire identifies a
//! machine: `agt_<uuid>` is per state directory, so it changes with every
//! reinstall, differs between two OS users on one laptop, and is cloned
//! verbatim by a baked image. The OS machine identifier is the opposite on
//! every count, which is why it is the input here.
//!
//! # Stable, never unique
//!
//! The same OS install always hashes to the same value — across reinstalls of
//! this client, across OS users, across upgrades. It is deliberately **not**
//! unique: a fleet booted from one baked image ships one machine identifier, so
//! those instances collide and the platform counts them as one host. That is
//! the accepted trade, documented for operators, and it is never "fixed" here
//! by minting an id of our own — a generated id would be per state directory
//! again, which is the problem this module exists to solve.
//!
//! # Sources
//!
//! | Platform | Source | Privileged? |
//! |---|---|---|
//! | Windows | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (64-bit view) | no — read access only |
//! | Linux | `/etc/machine-id`, then `/var/lib/dbus/machine-id` | no |
//! | macOS | `IOPlatformUUID`, via `gethostuuid(2)` | no |
//!
//! DMI `product_uuid` is deliberately not consulted: it is root-only on Linux,
//! so the value a daemon reads would depend on how it was started.
//!
//! # Why the raw identifier never leaves the host
//!
//! `/etc/machine-id`'s own manpage says the value must not be exposed on the
//! network and that an application-specific derivation is the supported way to
//! use it. [`hash_machine_id`] is that derivation: a domain-separated SHA-256,
//! truncated exactly the way systemd's `sd_id128_get_app_specific` truncates.
//! The platform gets a 32-hex opaque token it can group by and can never invert
//! into the machine's own identifier.
//!
//! **[`hash_machine_id`] is the contract.** Changing [`HOST_ID_DOMAIN`] or the
//! truncation length re-keys every host in every customer's fleet — every
//! machine would arrive as a new one, and the count would double.

use std::sync::OnceLock;

/// Domain separator mixed in before the machine identifier.
///
/// Carries a version suffix so a future scheme can exist beside this one rather
/// than silently replacing it. Never change it for an existing scheme.
pub const HOST_ID_DOMAIN: &[u8] = b"openlatch-hostid/1";

/// Test seam, honoured first on every platform. Holds the already-normalised
/// raw identifier, which is hashed exactly as a real one is; the literal
/// `none` forces an absent `hostid`.
const TEST_SEAM_ENV: &str = "OPENLATCH_TEST_HOST_ID";

/// Resolved once per process: a machine identifier cannot change inside a
/// daemon's lifetime, and the Windows branch is a registry round trip.
static HOST_ID: OnceLock<Option<String>> = OnceLock::new();

/// The stable host identifier, or `None` when this host has no machine
/// identifier (the normal case in many containers).
pub fn host_id() -> Option<String> {
    HOST_ID.get_or_init(resolve).clone()
}

/// The key the platform counts hosts by (I-4 D-06).
///
/// `hostid` when there is one, else `agent:<agent_id>` so a container that
/// carries no machine identifier is still counted as *something* rather than
/// merging into one anonymous bucket. With neither there is no key at all and
/// the caller omits the header — never a bare `agent:`, which the platform's
/// host-key pattern rejects.
pub fn host_key(agent_id: &str) -> Option<String> {
    key_from(host_id(), agent_id)
}

/// The three branches of [`host_key`], without the memoised getter, so each is
/// reachable from a test.
fn key_from(host_id: Option<String>, agent_id: &str) -> Option<String> {
    match host_id {
        Some(id) => Some(id),
        None if !agent_id.is_empty() => Some(format!("agent:{agent_id}")),
        None => None,
    }
}

/// The raw OS identifier → the wire value: 32 lower-case hex characters.
///
/// Pure, and pinned by fixed vectors in the tests below — this function *is*
/// the wire contract.
pub fn hash_machine_id(normalised: &str) -> String {
    use sha2::{Digest, Sha256};

    let mut hasher = Sha256::new();
    hasher.update(HOST_ID_DOMAIN);
    hasher.update(normalised.as_bytes());
    // The first 16 bytes, hex-encoded: 32 characters, the same truncation
    // systemd applies to an app-specific machine id.
    hex::encode(&hasher.finalize()[..16])
}

/// Trim, strip the braces a Windows-style GUID may carry, lower-case.
///
/// Returns `None` when nothing is left — an empty identifier is not an
/// identifier, and hashing one would give every such host the same id.
pub fn normalise_machine_id(raw: &str) -> Option<String> {
    let trimmed = raw
        .trim()
        .trim_start_matches('{')
        .trim_end_matches('}')
        .trim();
    if trimmed.is_empty() {
        return None;
    }
    Some(trimmed.to_ascii_lowercase())
}

/// The Linux validity rule (D-02). Input is already normalised; the result is
/// the 32-hex form, which is what gets hashed.
///
/// Rejects far more than "missing file": Debian's rootfs images ship an
/// **empty** `/etc/machine-id` on purpose so first boot provisions one, and
/// `systemd` writes the literal `uninitialized` in the same situation. Hashing
/// either would give every container built from that image one shared host id —
/// and an all-zero value is the same failure wearing a different mask.
pub fn validate_linux_machine_id(normalised: &str) -> Option<String> {
    if normalised == "uninitialized" {
        return None;
    }
    let compact: String = normalised.chars().filter(|c| *c != '-').collect();
    if compact.len() != 32 || !compact.chars().all(|c| c.is_ascii_hexdigit()) {
        return None;
    }
    if compact.chars().all(|c| c == '0') {
        return None;
    }
    Some(compact)
}

/// The uncached ladder — the only thing the tests call. Calling the memoised
/// [`host_id`] once would freeze the first answer for the whole test binary.
fn resolve() -> Option<String> {
    // `.ok().filter(..)` rather than `if let Ok(..)`: an empty value means
    // unset, exactly as `os_user.rs::env_non_empty` treats one, and the filter
    // also keeps clippy's `match_result_ok` quiet under `-D warnings`.
    if let Some(seam) = std::env::var(TEST_SEAM_ENV).ok().filter(|v| !v.is_empty()) {
        return if seam == "none" {
            None
        } else {
            Some(hash_machine_id(&seam))
        };
    }
    resolve_platform()
        .and_then(|raw| normalise_machine_id(&raw))
        // The all-zero rule is stated universally above, so it is enforced
        // universally here rather than only inside `resolve_linux`. A Windows
        // image whose `MachineGuid` was left all-zero by broken sysprep, or a
        // macOS buffer that comes back zeroed, would otherwise yield a
        // valid-looking 32-hex host id SHARED by every affected machine — and
        // licensing would merge that whole fleet into one counted host, while the
        // identical state on Linux falls back to `agent:<id>` and counts each
        // separately. Same state, opposite billing outcome, decided by OS.
        .filter(|normalised| normalised.chars().any(|c| c != '0' && c != '-'))
        .map(|normalised| hash_machine_id(&normalised))
}

/// The two well-known Linux locations, in precedence order.
///
/// `/etc/machine-id` first: `/var/lib/dbus/machine-id` is a compatibility
/// symlink to it on every systemd host, and on the few hosts where it is a real
/// file it is the older, D-Bus-owned value.
#[cfg(target_os = "linux")]
const LINUX_MACHINE_ID_PATHS: [&str; 2] = ["/etc/machine-id", "/var/lib/dbus/machine-id"];

#[cfg(target_os = "linux")]
fn resolve_platform() -> Option<String> {
    let paths: Vec<&std::path::Path> = LINUX_MACHINE_ID_PATHS
        .iter()
        .map(std::path::Path::new)
        .collect();
    resolve_linux(&paths)
}

/// The Linux ladder with its paths injected, so a test can point it at a
/// tmpdir instead of the runner's own `/etc`.
#[cfg(target_os = "linux")]
fn resolve_linux(paths: &[&std::path::Path]) -> Option<String> {
    paths.iter().find_map(|path| {
        std::fs::read_to_string(path)
            .ok()
            .and_then(|raw| normalise_machine_id(&raw))
            .and_then(|normalised| validate_linux_machine_id(&normalised))
    })
}

/// `gethostuuid(2)` — the kernel call that returns `IOPlatformUUID`.
///
/// Chosen over spawning `ioreg`: this runs on the daemon's start path, and a
/// subprocess there costs a fork for a value the kernel hands over directly. It
/// is also why no IOKit crate is needed — `libc` is already linked.
///
/// The timeout is finite and deliberate. A daemon launched by launchd very
/// early in boot can reach this before IOKit has published the property, and
/// the call blocks until it does; `EWOULDBLOCK` after five seconds is then an
/// absent `hostid`, not a hung daemon. Five seconds is osquery's own bound for
/// the same call.
#[cfg(target_os = "macos")]
fn resolve_platform() -> Option<String> {
    let mut raw = [0u8; 16];
    let timeout = libc::timespec {
        tv_sec: 5,
        tv_nsec: 0,
    };
    // SAFETY: `raw` is 16 writable bytes, which is the fixed size
    // `gethostuuid` documents for its out-parameter, and `timeout` is a live
    // `timespec` for the duration of the call.
    let rc = unsafe { libc::gethostuuid(raw.as_mut_ptr(), &timeout) };
    if rc != 0 {
        return None;
    }
    Some(format_uuid(&raw))
}

/// The canonical `8-4-4-4-12` lower-case rendering of 16 raw bytes.
///
/// Hand-rolled rather than pulled from `uuid`: that crate is optional here and
/// the daemon must not grow a feature edge for one formatter. The output is the
/// lower-cased form of what `ioreg` prints, which is what makes the acceptance
/// check in the plan a straight comparison.
#[cfg(target_os = "macos")]
fn format_uuid(bytes: &[u8; 16]) -> String {
    let hex = hex::encode(bytes);
    format!(
        "{}-{}-{}-{}-{}",
        &hex[0..8],
        &hex[8..12],
        &hex[12..16],
        &hex[16..20],
        &hex[20..32]
    )
}

/// `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`, read from the **64-bit**
/// view.
///
/// `KEY_WOW64_64KEY` is the load-bearing flag: a 32-bit build would otherwise
/// be redirected into `Wow6432Node`, where this value does not exist, and every
/// such host would report no `hostid` at all.
///
/// Read through `windows-sys`' `RegGetValueW` for the same reason
/// `git_email.rs` does — it is already in the tree, and one value read does not
/// justify the `winreg` crate. Unlike that call site this one opens the key
/// first, because `RegGetValueW` alone offers no way to ask for the 64-bit
/// view.
#[cfg(windows)]
fn resolve_platform() -> Option<String> {
    use std::ffi::OsStr;
    use std::os::windows::ffi::OsStrExt;
    use windows_sys::Win32::Foundation::ERROR_SUCCESS;
    use windows_sys::Win32::System::Registry::{
        RegCloseKey, RegOpenKeyExW, HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY,
    };

    /// A GUID is 38 characters; anything of registry size here is corrupt, and
    /// refusing to allocate for it keeps a bad value from becoming a large read.
    const MAX_VALUE_BYTES: u32 = 64 * 1024;

    fn wide(s: &str) -> Vec<u16> {
        OsStr::new(s)
            .encode_wide()
            .chain(std::iter::once(0))
            .collect()
    }

    let subkey = wide("SOFTWARE\\Microsoft\\Cryptography");
    let value = wide("MachineGuid");

    let mut hkey: HKEY = std::ptr::null_mut();
    // SAFETY: `subkey` is a NUL-terminated UTF-16 buffer alive for the call and
    // `hkey` is a live out-parameter, which is the documented contract.
    let rc = unsafe {
        RegOpenKeyExW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            0,
            KEY_READ | KEY_WOW64_64KEY,
            &mut hkey,
        )
    };
    if rc != ERROR_SUCCESS {
        return None;
    }

    let guid = read_machine_guid(hkey, &value, MAX_VALUE_BYTES);
    // SAFETY: `hkey` was opened above and is not used after this point.
    unsafe { RegCloseKey(hkey) };
    guid
}

/// The two-call `RegGetValueW` sizing dance, split out so the open key is
/// always closed on every path out of [`resolve_platform`].
#[cfg(windows)]
fn read_machine_guid(
    hkey: windows_sys::Win32::System::Registry::HKEY,
    value: &[u16],
    max_bytes: u32,
) -> Option<String> {
    use std::ffi::OsString;
    use std::os::windows::ffi::OsStringExt;
    use windows_sys::Win32::Foundation::ERROR_SUCCESS;
    use windows_sys::Win32::System::Registry::{RegGetValueW, RRF_RT_REG_SZ};

    // First call sizes the value, in bytes, including the terminating NUL.
    let mut bytes: u32 = 0;
    // SAFETY: the value name is a NUL-terminated UTF-16 buffer alive for the
    // call; a null data pointer with a live size out-parameter is the
    // documented "tell me how big it is" form.
    let rc = unsafe {
        RegGetValueW(
            hkey,
            std::ptr::null(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            &mut bytes,
        )
    };
    if rc != ERROR_SUCCESS || bytes == 0 || bytes > max_bytes {
        return None;
    }

    let mut buf = vec![0u16; bytes as usize / 2 + 1];
    let mut written = bytes;
    // SAFETY: `buf` is at least `written` bytes of writable UTF-16 storage and
    // `written` is a live u32 out-parameter — the contract for the second call.
    let rc = unsafe {
        RegGetValueW(
            hkey,
            std::ptr::null(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut written,
        )
    };
    if rc != ERROR_SUCCESS {
        return None;
    }

    let len = (written as usize / 2).saturating_sub(1).min(buf.len());
    let guid = OsString::from_wide(&buf[..len])
        .to_string_lossy()
        .into_owned();
    if guid.is_empty() {
        None
    } else {
        Some(guid)
    }
}

#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
fn resolve_platform() -> Option<String> {
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::daemon::identity::test_support::EnvGuard;
    use crate::daemon::identity::ENV_LOCK;

    /// A fixed vector, computed once and pasted. This is the wire contract:
    /// if this assertion has to be edited, every host in every fleet has been
    /// re-keyed.
    const SAMPLE_ID: &str = "75f76b18-6d6c-4a7f-9f0e-2b1c3d4e5f60";
    const SAMPLE_HASH: &str = "9262b296baa37a205734509345581251";

    #[test]
    fn hash_is_pinned_to_a_fixed_vector_and_to_its_domain() {
        assert_eq!(hash_machine_id(SAMPLE_ID), SAMPLE_HASH);

        // The domain separator is not decoration: the same identifier hashed
        // without it is a different host id, which is what stops a value
        // derived elsewhere from the same machine id colliding with ours.
        use sha2::{Digest, Sha256};
        let mut bare = Sha256::new();
        bare.update(SAMPLE_ID.as_bytes());
        assert_ne!(hex::encode(&bare.finalize()[..16]), SAMPLE_HASH);
    }

    /// Replaces the `pattern` the schema deliberately does not carry (D-05):
    /// the resolver is what guarantees the shape, so the guarantee is asserted
    /// here instead.
    #[test]
    fn hash_is_always_32_lowercase_hex_characters() {
        for input in [
            "",
            "a",
            SAMPLE_ID,
            "0f0e0d0c0b0a09080706050403020100",
            "ÄÖÜ-non-ascii",
            &"x".repeat(4096),
        ] {
            let hashed = hash_machine_id(input);
            assert_eq!(hashed.len(), 32, "wrong length for {input:?}");
            assert!(
                hashed
                    .chars()
                    .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
                "not lower-case hex for {input:?}: {hashed}"
            );
        }
    }

    #[test]
    fn normalise_strips_whitespace_braces_and_case() {
        assert_eq!(
            normalise_machine_id(" {ABC-def} ").as_deref(),
            Some("abc-def")
        );
        assert_eq!(
            normalise_machine_id("\n75F76B18-6D6C\n").as_deref(),
            Some("75f76b18-6d6c")
        );
        assert_eq!(normalise_machine_id("   "), None);
        assert_eq!(normalise_machine_id("{}"), None);
    }

    #[test]
    fn linux_validity_rejects_every_shape_a_container_ships() {
        // Accepted: the 32-hex form systemd writes, and the hyphenated 36-char
        // form some images carry — both normalise to the same 32 hex digits.
        assert_eq!(
            validate_linux_machine_id("75f76b186d6c4a7f9f0e2b1c3d4e5f60").as_deref(),
            Some("75f76b186d6c4a7f9f0e2b1c3d4e5f60")
        );
        assert_eq!(
            validate_linux_machine_id(SAMPLE_ID).as_deref(),
            Some("75f76b186d6c4a7f9f0e2b1c3d4e5f60")
        );

        // Rejected — each of these is a real machine state, not a corruption.
        assert_eq!(validate_linux_machine_id(""), None);
        assert_eq!(validate_linux_machine_id("uninitialized"), None);
        assert_eq!(validate_linux_machine_id(&"0".repeat(32)), None);
        assert_eq!(validate_linux_machine_id("abc"), None);
        assert_eq!(validate_linux_machine_id(&"z".repeat(32)), None);
    }

    #[test]
    fn host_key_falls_back_to_the_agent_id_and_then_to_nothing() {
        assert_eq!(
            key_from(Some(SAMPLE_HASH.to_string()), "agt_x").as_deref(),
            Some(SAMPLE_HASH)
        );
        assert_eq!(key_from(None, "agt_x").as_deref(), Some("agent:agt_x"));
        // Never a bare `agent:` — the platform's host-key pattern rejects it.
        assert_eq!(key_from(None, ""), None);
    }

    /// The seam, exercised through the uncached [`resolve`] under the shared
    /// environment lock — the idiom every resolver's tests in this tree use.
    #[test]
    fn seam_outranks_the_platform_and_an_empty_value_means_unset() {
        let _lock = ENV_LOCK.blocking_lock();
        let env = EnvGuard::clear();

        env.set(TEST_SEAM_ENV, "none");
        assert_eq!(resolve(), None, "`none` forces an absent hostid");

        env.set(TEST_SEAM_ENV, SAMPLE_ID);
        assert_eq!(
            resolve().as_deref(),
            Some(SAMPLE_HASH),
            "a seam value is hashed exactly as a real identifier is"
        );

        // Empty is unset. Asserted against `resolve()` with the seam actually
        // unset, rather than against a re-transcription of `resolve()`'s own
        // fallback ladder: comparing the function to a copy of itself would stay
        // green if a step were dropped from the ladder, and the old
        // `assert_ne!(.., Some(hash("")))` was vacuous on every host where
        // `resolve_platform()` is `None` — which is every container, precisely
        // where "empty must not be hashed" would bite.
        env.unset(TEST_SEAM_ENV);
        let unset = resolve();
        env.set(TEST_SEAM_ENV, "");
        assert_eq!(
            resolve(),
            unset,
            "an empty seam must behave exactly as an unset one"
        );
    }

    /// The Linux ladder against a tmpdir: `/etc/machine-id` wins, an invalid
    /// first file falls through, and both invalid means absent.
    #[cfg(target_os = "linux")]
    #[test]
    fn linux_reads_etc_first_and_falls_through_an_unusable_file() {
        let dir = tempfile::tempdir().expect("tempdir");
        let etc = dir.path().join("etc-machine-id");
        let dbus = dir.path().join("dbus-machine-id");

        std::fs::write(&etc, "75f76b186d6c4a7f9f0e2b1c3d4e5f60\n").expect("write etc");
        std::fs::write(&dbus, "0123456789abcdef0123456789abcdef\n").expect("write dbus");
        assert_eq!(
            resolve_linux(&[etc.as_path(), dbus.as_path()]).as_deref(),
            Some("75f76b186d6c4a7f9f0e2b1c3d4e5f60")
        );

        // Debian's empty file: fall through to the next source rather than
        // hashing nothing.
        std::fs::write(&etc, "").expect("truncate etc");
        assert_eq!(
            resolve_linux(&[etc.as_path(), dbus.as_path()]).as_deref(),
            Some("0123456789abcdef0123456789abcdef")
        );

        std::fs::write(&dbus, "uninitialized\n").expect("write dbus");
        assert_eq!(resolve_linux(&[etc.as_path(), dbus.as_path()]), None);

        // A path that does not exist is not an error, just the next rung.
        assert_eq!(resolve_linux(&[dir.path().join("absent").as_path()]), None);
    }

    /// The real registry read on a real Windows host (this runs in
    /// `windows-checks.yml` post-merge). `MachineGuid` is a 36-character GUID
    /// on every supported Windows version.
    #[cfg(windows)]
    #[test]
    fn windows_machine_guid_is_a_real_guid() {
        let raw = resolve_platform().expect("HKLM MachineGuid must exist on a Windows host");
        assert_eq!(raw.trim().len(), 36, "MachineGuid is a 36-char GUID: {raw}");
        let normalised = normalise_machine_id(&raw).expect("a GUID normalises");
        assert_eq!(hash_machine_id(&normalised).len(), 32);
    }

    /// macOS: run by hand on a Mac and recorded in the PR (D-13 — there is no
    /// macOS `cargo test` job). `gethostuuid` answers on any booted Mac.
    #[cfg(target_os = "macos")]
    #[test]
    fn macos_platform_uuid_has_the_canonical_shape() {
        let raw = resolve_platform().expect("gethostuuid must answer on a booted Mac");
        assert_eq!(raw.len(), 36, "8-4-4-4-12: {raw}");
        assert_eq!(
            raw.chars().filter(|c| *c == '-').count(),
            4,
            "four separators: {raw}"
        );
        assert_eq!(raw, raw.to_ascii_lowercase(), "lower-case: {raw}");
        // The raw identifier is deliberately NOT printed. This module's own
        // contract is that it never leaves the host, and this output is meant to
        // be recorded in a PR — which would publish it permanently, alongside the
        // hash, handing over the plaintext/digest pair for this machine. The
        // assertions above already prove the shape; the hash is what the
        // acceptance check needs.
        println!("macOS hostid={}", hash_machine_id(&raw));
    }
}