Skip to main content

objectiveai_sdk/machine/
machine.rs

1//! Machine identity: a stable, privacy-preserving identifier for the
2//! machine a process runs on, plus display metadata (OS + hostname).
3//!
4//! The identity `id` is `hex(sha256("objectiveai-machine-id" || raw))`
5//! where `raw` is the platform's installation identifier — Linux
6//! `/etc/machine-id`, Windows `MachineGuid`, macOS `IOPlatformUUID` —
7//! hashed so the OS-global identifier is never sent raw (the salt also
8//! decorrelates it from other software's use of the same source). Two
9//! processes on one machine compute the SAME id independently, with no
10//! shared state — that is the property the laboratories host protocol
11//! relies on for local-vs-remote classification. When no OS identifier
12//! is readable, a UUID persisted at `<objectiveai_dir>/bin/machine-id`
13//! is hashed instead (stable per install dir).
14//!
15//! `os` is `std::env::consts::OS` verbatim ("windows" / "linux" /
16//! "macos" / …) — a display discriminator (per-OS icons); consumers
17//! must render unknown values generically. `hostname` is a display
18//! label only, never identity.
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23/// The machine a process runs on: stable hashed `id` (the identity),
24/// plus `os` and `hostname` display metadata.
25#[derive(
26    Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema,
27)]
28#[schemars(rename = "machine.MachineIdentity")]
29pub struct MachineIdentity {
30    /// `hex(sha256("objectiveai-machine-id" || <os machine id>))` —
31    /// stable per machine, never the raw OS identifier.
32    pub id: String,
33    /// `std::env::consts::OS` ("windows" / "linux" / "macos" / …).
34    /// Display discriminator; render unknown values generically.
35    pub os: String,
36    /// The machine's hostname — display label only, never identity.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    #[schemars(extend("omitempty" = true))]
39    pub hostname: Option<String>,
40}
41
42/// Resolve this machine's identity. `objectiveai_dir` roots the
43/// persisted-UUID fallback used when no OS identifier is readable.
44#[cfg(feature = "lockfile")]
45pub fn machine_identity(objectiveai_dir: &std::path::Path) -> MachineIdentity {
46    MachineIdentity {
47        id: machine_id(objectiveai_dir),
48        os: std::env::consts::OS.to_string(),
49        hostname: hostname::get()
50            .ok()
51            .and_then(|h| h.into_string().ok())
52            .filter(|h| !h.is_empty()),
53    }
54}
55
56/// The hashed machine id (see module docs for the derivation chain).
57#[cfg(feature = "lockfile")]
58pub fn machine_id(objectiveai_dir: &std::path::Path) -> String {
59    use sha2::{Digest, Sha256};
60    let raw = raw_machine_id()
61        .or_else(|| persisted_machine_id(objectiveai_dir))
62        // Last resort (unreadable OS id AND unwritable dir): a fresh
63        // UUID — unstable across runs, but every layer treats the id
64        // as opaque so nothing breaks; classification just degrades.
65        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
66    let mut hasher = Sha256::new();
67    hasher.update(b"objectiveai-machine-id");
68    hasher.update(raw.trim().as_bytes());
69    hex::encode(hasher.finalize())
70}
71
72/// The platform's raw installation identifier, `None` when unreadable.
73#[cfg(all(feature = "lockfile", target_os = "linux"))]
74fn raw_machine_id() -> Option<String> {
75    ["/etc/machine-id", "/var/lib/dbus/machine-id"]
76        .iter()
77        .find_map(|p| std::fs::read_to_string(p).ok())
78        .map(|s| s.trim().to_string())
79        .filter(|s| !s.is_empty())
80}
81
82#[cfg(all(feature = "lockfile", target_os = "macos"))]
83fn raw_machine_id() -> Option<String> {
84    // `ioreg -rd1 -c IOPlatformExpertDevice` prints a line like:
85    //   "IOPlatformUUID" = "XXXXXXXX-XXXX-…"
86    //
87    // DELIBERATE std (not tokio) subprocess — the repo-wide rule is
88    // tokio-only, but `machine_identity` is a sync API consumed from
89    // sync contexts, and this macOS-only one-shot read can't await.
90    let output = std::process::Command::new("ioreg")
91        .args(["-rd1", "-c", "IOPlatformExpertDevice"])
92        .output()
93        .ok()?;
94    let stdout = String::from_utf8_lossy(&output.stdout);
95    let line = stdout.lines().find(|l| l.contains("IOPlatformUUID"))?;
96    let value = line.split('"').nth(3)?;
97    (!value.is_empty()).then(|| value.to_string())
98}
99
100#[cfg(all(feature = "lockfile", windows))]
101fn raw_machine_id() -> Option<String> {
102    // HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid — the Windows
103    // installation GUID. RegGetValueW with RRF_RT_REG_SZ; wide-string
104    // in/out.
105    use windows_sys::Win32::System::Registry::{
106        HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ, RegGetValueW,
107    };
108    fn wide(s: &str) -> Vec<u16> {
109        s.encode_utf16().chain(std::iter::once(0)).collect()
110    }
111    let subkey = wide("SOFTWARE\\Microsoft\\Cryptography");
112    let value = wide("MachineGuid");
113    let mut buf = [0u16; 128];
114    let mut size = (buf.len() * 2) as u32;
115    let rc = unsafe {
116        RegGetValueW(
117            HKEY_LOCAL_MACHINE,
118            subkey.as_ptr(),
119            value.as_ptr(),
120            RRF_RT_REG_SZ,
121            std::ptr::null_mut(),
122            buf.as_mut_ptr() as *mut _,
123            &mut size,
124        )
125    };
126    if rc != 0 {
127        return None;
128    }
129    let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
130    let guid = String::from_utf16_lossy(&buf[..len]);
131    (!guid.trim().is_empty()).then(|| guid.trim().to_string())
132}
133
134#[cfg(all(
135    feature = "lockfile",
136    not(any(target_os = "linux", target_os = "macos", windows))
137))]
138fn raw_machine_id() -> Option<String> {
139    None
140}
141
142/// Read-or-create the fallback UUID at `<objectiveai_dir>/bin/machine-id`.
143#[cfg(feature = "lockfile")]
144fn persisted_machine_id(objectiveai_dir: &std::path::Path) -> Option<String> {
145    let path = objectiveai_dir.join("bin").join("machine-id");
146    if let Ok(existing) = std::fs::read_to_string(&path) {
147        let existing = existing.trim();
148        if !existing.is_empty() {
149            return Some(existing.to_string());
150        }
151    }
152    let fresh = uuid::Uuid::new_v4().to_string();
153    std::fs::create_dir_all(path.parent()?).ok()?;
154    std::fs::write(&path, &fresh).ok()?;
155    Some(fresh)
156}