Skip to main content

client_core/
machine.rs

1//! Stable per-machine identifier used to deduplicate device rows on the relay.
2//!
3//! The relay uses `machine_id` to recognize when the CLI and desktop on the
4//! same Mac are signing in independently and should share a single device row
5//! instead of producing two. The value is opaque (SHA-256 of the OS-provided
6//! UUID, truncated to 16 hex chars) so the underlying hardware identifier is
7//! never sent in cleartext.
8//!
9//! Sources by platform:
10//!   macOS   — `IOPlatformUUID` via `ioreg -rd1 -c IOPlatformExpertDevice`
11//!   Linux   — `/etc/machine-id` (or `/var/lib/dbus/machine-id` fallback)
12//!   Windows — `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid`
13//!
14//! Returns an empty string when the source is unavailable; callers treat that
15//! the same as "no dedup hint" and the relay falls back to today's behavior.
16
17use sha2::{Digest, Sha256};
18
19/// Returns true if the current process appears to be running inside an SSH
20/// session, based on the SSH_* environment variables OpenSSH's sshd sets
21/// when forking a login shell. Suppresses browser auto-open and desktop
22/// handoff during `cinch auth login`.
23pub fn in_ssh_session() -> bool {
24    std::env::var("SSH_CONNECTION").is_ok()
25        || std::env::var("SSH_TTY").is_ok()
26        || std::env::var("SSH_CLIENT").is_ok()
27}
28
29/// Returns the OS-level hostname via `gethostname(3)`, or `"unknown"` if the
30/// syscall fails or yields an empty value. Identical on CLI and desktop so
31/// the relay's source_key dedup matches across both clients on the same Mac.
32pub fn hostname_or_unknown() -> String {
33    hostname::get()
34        .ok()
35        .and_then(|os| os.into_string().ok())
36        .filter(|s| !s.is_empty())
37        .unwrap_or_else(|| "unknown".to_string())
38}
39
40/// Returns the `source` value used when this device pushes a clip.
41///
42/// Format: `remote:<hostname>`. Mirrors the format produced by
43/// `cinch push` (see commands/push.rs). Used by `cinch pull --exclude-self`
44/// and the underlying `--exclude-source` query so the relay can suppress
45/// clips authored by the local device.
46pub fn self_source_key() -> String {
47    format!("remote:{}", hostname_or_unknown())
48}
49
50/// Returns a stable, opaque identifier for this machine, or an empty string
51/// if no source is available. The value is suitable for sending to the relay
52/// (no raw hardware ID) and is consistent across CLI and desktop on the same
53/// machine.
54pub fn stable_machine_id() -> String {
55    match raw_machine_source() {
56        Some(raw) if !raw.is_empty() => hash_short(&raw),
57        _ => String::new(),
58    }
59}
60
61fn hash_short(raw: &str) -> String {
62    let digest = Sha256::digest(raw.as_bytes());
63    let mut s = String::with_capacity(16);
64    for b in &digest[..8] {
65        s.push_str(&format!("{:02x}", b));
66    }
67    s
68}
69
70#[cfg(target_os = "macos")]
71fn raw_machine_source() -> Option<String> {
72    use std::process::Command;
73    let out = Command::new("ioreg")
74        .args(["-rd1", "-c", "IOPlatformExpertDevice"])
75        .output()
76        .ok()?;
77    if !out.status.success() {
78        return None;
79    }
80    let text = String::from_utf8_lossy(&out.stdout);
81    for line in text.lines() {
82        let trimmed = line.trim();
83        if let Some(rest) = trimmed.strip_prefix("\"IOPlatformUUID\"") {
84            // Format: "IOPlatformUUID" = "ABCD-..."
85            if let Some(start) = rest.find('=') {
86                let after = rest[start + 1..].trim();
87                let unq = after.trim_matches('"');
88                if !unq.is_empty() {
89                    return Some(unq.to_string());
90                }
91            }
92        }
93    }
94    None
95}
96
97#[cfg(target_os = "linux")]
98fn raw_machine_source() -> Option<String> {
99    for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] {
100        if let Ok(s) = std::fs::read_to_string(path) {
101            let v = s.trim();
102            if !v.is_empty() {
103                return Some(v.to_string());
104            }
105        }
106    }
107    None
108}
109
110#[cfg(target_os = "windows")]
111fn raw_machine_source() -> Option<String> {
112    use std::process::Command;
113    let out = Command::new("reg")
114        .args([
115            "query",
116            "HKLM\\SOFTWARE\\Microsoft\\Cryptography",
117            "/v",
118            "MachineGuid",
119        ])
120        .output()
121        .ok()?;
122    if !out.status.success() {
123        return None;
124    }
125    let text = String::from_utf8_lossy(&out.stdout);
126    for line in text.lines() {
127        let trimmed = line.trim();
128        if trimmed.starts_with("MachineGuid") {
129            // Format: MachineGuid    REG_SZ    abcd-...
130            let mut parts = trimmed.split_whitespace();
131            let _ = parts.next(); // MachineGuid
132            let _ = parts.next(); // REG_SZ
133            if let Some(v) = parts.next() {
134                if !v.is_empty() {
135                    return Some(v.to_string());
136                }
137            }
138        }
139    }
140    None
141}
142
143#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
144fn raw_machine_source() -> Option<String> {
145    None
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::env;
152
153    #[test]
154    fn hash_is_16_hex_chars() {
155        let h = hash_short("any-input");
156        assert_eq!(h.len(), 16);
157        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
158    }
159
160    #[test]
161    fn hash_is_deterministic() {
162        assert_eq!(hash_short("same"), hash_short("same"));
163        assert_ne!(hash_short("a"), hash_short("b"));
164    }
165
166    fn with_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
167        let prev: Vec<_> = vars
168            .iter()
169            .map(|(k, _)| (k.to_string(), env::var(k).ok()))
170            .collect();
171        for (k, v) in vars {
172            match v {
173                Some(val) => env::set_var(k, val),
174                None => env::remove_var(k),
175            }
176        }
177        f();
178        for (k, v) in prev {
179            match v {
180                Some(val) => env::set_var(&k, val),
181                None => env::remove_var(&k),
182            }
183        }
184    }
185
186    #[test]
187    fn ssh_connection_triggers() {
188        with_env(
189            &[
190                ("SSH_CONNECTION", Some("1.2.3.4 22 5.6.7.8 22")),
191                ("SSH_TTY", None),
192                ("SSH_CLIENT", None),
193            ],
194            || assert!(in_ssh_session()),
195        );
196    }
197
198    #[test]
199    fn ssh_tty_triggers() {
200        with_env(
201            &[
202                ("SSH_CONNECTION", None),
203                ("SSH_TTY", Some("/dev/pts/0")),
204                ("SSH_CLIENT", None),
205            ],
206            || assert!(in_ssh_session()),
207        );
208    }
209
210    #[test]
211    fn ssh_client_triggers() {
212        with_env(
213            &[
214                ("SSH_CONNECTION", None),
215                ("SSH_TTY", None),
216                ("SSH_CLIENT", Some("1.2.3.4 22 22")),
217            ],
218            || assert!(in_ssh_session()),
219        );
220    }
221
222    #[test]
223    fn no_ssh_env_returns_false() {
224        with_env(
225            &[
226                ("SSH_CONNECTION", None),
227                ("SSH_TTY", None),
228                ("SSH_CLIENT", None),
229            ],
230            || assert!(!in_ssh_session()),
231        );
232    }
233
234    #[test]
235    fn self_source_key_matches_push_format() {
236        let key = self_source_key();
237        let host = hostname_or_unknown();
238        assert_eq!(key, format!("remote:{}", host));
239    }
240
241    #[test]
242    fn self_source_key_has_remote_prefix() {
243        assert!(self_source_key().starts_with("remote:"));
244    }
245}