1use sha2::{Digest, Sha256};
18
19pub 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
29pub 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
40pub fn self_source_key() -> String {
47 format!("remote:{}", hostname_or_unknown())
48}
49
50pub 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 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 let mut parts = trimmed.split_whitespace();
131 let _ = parts.next(); let _ = parts.next(); 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}