Skip to main content

running_process/broker/lifecycle/
sid.rs

1//! Per-user identity hash used by every broker pipe name.
2//!
3//! Returns a 16-character lowercase hex string (the first 8 bytes of a
4//! blake3 digest, hex-encoded). Stable across runs for the same user
5//! on the same machine; collision resistant in practice.
6//!
7//! ## Platform inputs
8//!
9//! | Platform | Hash input |
10//! |----------|------------|
11//! | Windows  | The current process token user SID, in `S-1-...` text form, obtained via `OpenProcessToken(GetCurrentProcess())` → `GetTokenInformation(TokenUser)` → `ConvertSidToStringSidW`. |
12//! | Linux    | `format!("{uid}:{machine_id}")` where `machine_id` is the contents of `/etc/machine-id`, falling back to `/var/lib/dbus/machine-id`. |
13//! | macOS    | `format!("{uid}:{machine_uuid}")` where `machine_uuid` comes from `ioreg -d2 -c IOPlatformExpertDevice` (the `IOPlatformUUID` field). |
14//!
15//! ## Why a hash?
16//!
17//! Pipe-name length limits are tight: Windows MAX_PATH (260) and the
18//! macOS `sun_path` field (104 bytes). A blake3 16-char hex is short,
19//! collision-resistant for the namespace size we care about
20//! (per-machine per-user), and avoids leaking the literal SID or
21//! machine UUID into world-readable filesystem paths.
22
23/// Errors that can prevent computing the user SID hash.
24#[derive(Debug, thiserror::Error)]
25pub enum SidError {
26    /// Could not read the platform user identity (e.g. machine-id
27    /// missing, ioreg unavailable, OpenProcessToken failed).
28    #[error("failed to read platform user identity: {0}")]
29    PlatformLookup(String),
30}
31
32/// Return the 16-character lowercase hex blake3 hash of the current
33/// user's platform identity. Stable across runs.
34pub fn user_sid_hash() -> Result<String, SidError> {
35    let input = platform_identity_string()?;
36    Ok(hash_to_16_hex(input.as_bytes()))
37}
38
39/// Hash arbitrary bytes to 16 lowercase hex characters using blake3.
40///
41/// Exposed for testing and for the rare caller that wants to hash a
42/// non-default identity string (e.g. a CI runner ID).
43pub fn hash_to_16_hex(input: &[u8]) -> String {
44    let digest = blake3::hash(input);
45    let bytes = digest.as_bytes();
46    // 8 bytes → 16 hex chars.
47    let mut out = String::with_capacity(16);
48    for b in &bytes[..8] {
49        // Lowercase hex, fixed width.
50        out.push(nibble_to_hex(b >> 4));
51        out.push(nibble_to_hex(b & 0x0F));
52    }
53    out
54}
55
56#[inline]
57fn nibble_to_hex(n: u8) -> char {
58    match n {
59        0..=9 => (b'0' + n) as char,
60        10..=15 => (b'a' + (n - 10)) as char,
61        _ => unreachable!("nibble out of range"),
62    }
63}
64
65fn platform_identity_string() -> Result<String, SidError> {
66    #[cfg(windows)]
67    {
68        windows_current_user_sid()
69    }
70    #[cfg(target_os = "macos")]
71    {
72        let uid = unsafe { libc::getuid() };
73        let uuid = macos_platform_uuid()?;
74        Ok(format!("{uid}:{uuid}"))
75    }
76    #[cfg(all(unix, not(target_os = "macos")))]
77    {
78        let uid = unsafe { libc::getuid() };
79        let machine_id = linux_machine_id()?;
80        Ok(format!("{uid}:{machine_id}"))
81    }
82}
83
84#[cfg(windows)]
85fn windows_current_user_sid() -> Result<String, SidError> {
86    use std::ptr;
87    use winapi::shared::winerror::ERROR_INSUFFICIENT_BUFFER;
88    use winapi::um::errhandlingapi::GetLastError;
89    use winapi::um::processthreadsapi::{GetCurrentProcess, OpenProcessToken};
90    use winapi::um::securitybaseapi::GetTokenInformation;
91    use winapi::um::winnt::{TokenUser, HANDLE, TOKEN_QUERY, TOKEN_USER};
92
93    // SAFETY: the chain of Windows API calls below follows the
94    // documented pattern for retrieving the current process's user
95    // SID. Every allocated buffer is freed before returning, and we
96    // never expose raw pointers to safe Rust.
97    unsafe {
98        let mut token: HANDLE = ptr::null_mut();
99        if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
100            return Err(SidError::PlatformLookup(format!(
101                "OpenProcessToken failed (GetLastError={})",
102                GetLastError()
103            )));
104        }
105        let close_token = TokenHandle(token);
106
107        // First call: query required buffer size.
108        let mut required_size: u32 = 0;
109        let ok = GetTokenInformation(
110            close_token.0,
111            TokenUser,
112            ptr::null_mut(),
113            0,
114            &mut required_size,
115        );
116        // We expect this to fail with ERROR_INSUFFICIENT_BUFFER.
117        let last = GetLastError();
118        if ok != 0 || last != ERROR_INSUFFICIENT_BUFFER {
119            return Err(SidError::PlatformLookup(format!(
120                "GetTokenInformation size query failed (ok={ok}, GetLastError={last})"
121            )));
122        }
123        if required_size == 0 {
124            return Err(SidError::PlatformLookup(
125                "GetTokenInformation reported 0 required bytes".into(),
126            ));
127        }
128
129        let mut buf: Vec<u8> = vec![0u8; required_size as usize];
130        if GetTokenInformation(
131            close_token.0,
132            TokenUser,
133            buf.as_mut_ptr().cast(),
134            required_size,
135            &mut required_size,
136        ) == 0
137        {
138            return Err(SidError::PlatformLookup(format!(
139                "GetTokenInformation real query failed (GetLastError={})",
140                GetLastError()
141            )));
142        }
143
144        // The buffer starts with a TOKEN_USER struct whose `User.Sid`
145        // points into the same allocation.
146        let token_user: *const TOKEN_USER = buf.as_ptr().cast();
147        let sid_ptr = (*token_user).User.Sid;
148        if sid_ptr.is_null() {
149            return Err(SidError::PlatformLookup(
150                "TOKEN_USER returned a null SID pointer".into(),
151            ));
152        }
153        sid_to_string(sid_ptr)
154    }
155}
156
157#[cfg(windows)]
158struct TokenHandle(winapi::um::winnt::HANDLE);
159
160#[cfg(windows)]
161impl Drop for TokenHandle {
162    fn drop(&mut self) {
163        // SAFETY: handle came from OpenProcessToken and is only closed
164        // once via this Drop.
165        unsafe {
166            winapi::um::handleapi::CloseHandle(self.0);
167        }
168    }
169}
170
171#[cfg(windows)]
172unsafe fn sid_to_string(sid: winapi::um::winnt::PSID) -> Result<String, SidError> {
173    // ConvertSidToStringSidW lives in advapi32. winapi 0.3 exposes it
174    // through `winapi::shared::sddl::ConvertSidToStringSidW`, but the
175    // `sddl` module requires the `sddl` feature. Rather than expand
176    // the winapi feature list, we round-trip through the byte
177    // representation of the SID, which is exactly what
178    // ConvertSidToStringSidW formats. The hash input only needs to be
179    // stable per user on a given machine — the textual S-1-... form
180    // and the raw bytes both meet that bar.
181    use winapi::um::securitybaseapi::{GetLengthSid, IsValidSid};
182
183    if IsValidSid(sid) == 0 {
184        return Err(SidError::PlatformLookup("IsValidSid returned false".into()));
185    }
186    let len = GetLengthSid(sid) as usize;
187    if len == 0 || len > 1024 {
188        return Err(SidError::PlatformLookup(format!(
189            "GetLengthSid returned implausible length {len}"
190        )));
191    }
192    let slice = std::slice::from_raw_parts(sid as *const u8, len);
193    // Format as `windows-sid:<hex>` so the hash input is
194    // distinguishable from the Linux/macOS schemes (defence in depth
195    // against accidental cross-platform collisions).
196    let mut hex = String::with_capacity(len * 2);
197    for b in slice {
198        hex.push(nibble_to_hex(b >> 4));
199        hex.push(nibble_to_hex(b & 0x0F));
200    }
201    Ok(format!("windows-sid:{hex}"))
202}
203
204#[cfg(all(unix, not(target_os = "macos")))]
205fn linux_machine_id() -> Result<String, SidError> {
206    const PATHS: &[&str] = &["/etc/machine-id", "/var/lib/dbus/machine-id"];
207    for path in PATHS {
208        match std::fs::read_to_string(path) {
209            Ok(s) => {
210                let trimmed = s.trim();
211                if !trimmed.is_empty() {
212                    return Ok(trimmed.to_string());
213                }
214            }
215            Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
216            Err(err) => {
217                return Err(SidError::PlatformLookup(format!("read {path}: {err}")));
218            }
219        }
220    }
221    Err(SidError::PlatformLookup(
222        "no /etc/machine-id or /var/lib/dbus/machine-id found".into(),
223    ))
224}
225
226#[cfg(target_os = "macos")]
227fn macos_platform_uuid() -> Result<String, SidError> {
228    use std::process::Command;
229    // `ioreg -d2 -c IOPlatformExpertDevice` prints a block that
230    // contains a line like `"IOPlatformUUID" = "ABCDEF..."`. Parse
231    // that line out — we don't need a full plist parser.
232    let output = Command::new("ioreg")
233        .args(["-d2", "-c", "IOPlatformExpertDevice"])
234        .output()
235        .map_err(|e| SidError::PlatformLookup(format!("spawn ioreg: {e}")))?;
236    if !output.status.success() {
237        return Err(SidError::PlatformLookup(format!(
238            "ioreg failed (status={:?})",
239            output.status.code()
240        )));
241    }
242    let stdout = String::from_utf8_lossy(&output.stdout);
243    for line in stdout.lines() {
244        let line = line.trim();
245        if let Some(rest) = line.strip_prefix("\"IOPlatformUUID\"") {
246            // rest looks like ` = "ABCDEF-..."`
247            if let Some(eq_idx) = rest.find('=') {
248                let value = rest[eq_idx + 1..].trim();
249                let unquoted = value.trim_matches('"');
250                if !unquoted.is_empty() {
251                    return Ok(unquoted.to_string());
252                }
253            }
254        }
255    }
256    Err(SidError::PlatformLookup(
257        "ioreg output did not contain IOPlatformUUID".into(),
258    ))
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn hash_is_16_lowercase_hex() {
267        let h = hash_to_16_hex(b"sample-input");
268        assert_eq!(h.len(), 16, "hash must be 16 chars");
269        for c in h.chars() {
270            assert!(
271                c.is_ascii_digit() || ('a'..='f').contains(&c),
272                "non-lowercase-hex char in {h:?}"
273            );
274        }
275    }
276
277    #[test]
278    fn different_inputs_yield_different_hashes() {
279        let a = hash_to_16_hex(b"alice:machine-1");
280        let b = hash_to_16_hex(b"bob:machine-1");
281        assert_ne!(a, b);
282    }
283
284    #[test]
285    fn same_input_is_stable() {
286        let a = hash_to_16_hex(b"alice:machine-1");
287        let b = hash_to_16_hex(b"alice:machine-1");
288        assert_eq!(a, b);
289    }
290
291    #[test]
292    fn current_user_hash_resolves() {
293        // On a healthy dev machine this should succeed on all three
294        // platforms. CI containers without /etc/machine-id will skip
295        // (we don't want to make this test platform-fragile).
296        match user_sid_hash() {
297            Ok(h) => {
298                assert_eq!(h.len(), 16);
299            }
300            Err(e) => {
301                eprintln!("user_sid_hash unavailable on this host: {e}");
302            }
303        }
304    }
305}