1#![forbid(unsafe_code)]
4use crate::errors::{SshCliError, SshCliResult};
17use std::collections::BTreeMap;
18use std::fs::File;
19use std::path::{Path, PathBuf};
20
21#[must_use]
28fn fingerprints_eq(a: &str, b: &str) -> bool {
29 let a = a.as_bytes();
30 let b = b.as_bytes();
31 if a.len() != b.len() {
32 return false;
33 }
34 let mut diff = 0u8;
35 for (x, y) in a.iter().zip(b.iter()) {
36 diff |= x ^ y;
37 }
38 std::hint::black_box(diff) == 0
40}
41
42#[derive(Debug, Default, Clone)]
44pub struct KnownHosts {
45 entries: BTreeMap<String, String>,
46 path: PathBuf,
47}
48
49struct HostsFileLock {
51 file: File,
52}
53
54impl HostsFileLock {
55 fn acquire(kh_path: &Path) -> SshCliResult<Self> {
57 let lock_path = {
58 let mut os = kh_path.as_os_str().to_owned();
59 os.push(".lock");
60 PathBuf::from(os)
61 };
62 if let Some(parent_dir) = lock_path.parent() {
63 std::fs::create_dir_all(parent_dir)?;
64 }
65 let file = std::fs::OpenOptions::new()
66 .create(true)
67 .truncate(false)
68 .read(true)
69 .write(true)
70 .open(&lock_path)
71 .map_err(SshCliError::Io)?;
72 let _ = crate::fs_perm::set_secret_file_mode(&lock_path);
74 fs2::FileExt::lock_exclusive(&file).map_err(SshCliError::Io)?;
75 Ok(Self { file })
76 }
77}
78
79impl Drop for HostsFileLock {
80 fn drop(&mut self) {
81 let _ = fs2::FileExt::unlock(&self.file);
82 }
83}
84
85impl KnownHosts {
86 #[must_use]
88 pub fn key(host: &str, port: u16) -> String {
89 format!("{host}:{port}")
90 }
91
92 pub fn load(path: PathBuf) -> SshCliResult<Self> {
94 let mut entries = BTreeMap::new();
95 if path.exists() {
96 let text = crate::paths::read_text_capped(&path, crate::paths::MAX_KNOWN_HOSTS_BYTES)?;
97 for line in text.lines() {
98 let line = line.trim();
99 if line.is_empty() || line.starts_with('#') {
100 continue;
101 }
102 let mut parts = line.split_whitespace();
103 if let (Some(k), Some(fp)) = (parts.next(), parts.next()) {
104 entries.insert(k.to_string(), fp.to_string());
105 }
106 }
107 }
108 Ok(Self { entries, path })
109 }
110
111 #[must_use]
113 pub fn path_beside_config(config_toml: &Path) -> PathBuf {
114 config_toml
115 .parent()
116 .map(|p| p.join(crate::constants::KNOWN_HOSTS_FILE_NAME))
117 .unwrap_or_else(|| PathBuf::from(crate::constants::KNOWN_HOSTS_FILE_NAME))
118 }
119
120 #[must_use]
122 pub fn get(&self, host: &str, port: u16) -> Option<&str> {
123 self.entries.get(&Self::key(host, port)).map(String::as_str)
124 }
125
126 pub fn store(&mut self, host: &str, port: u16, fingerprint: &str) -> SshCliResult<()> {
131 let _lock = HostsFileLock::acquire(&self.path)?;
132 self.reload_from_disk_unlocked()?;
133 self.entries
134 .insert(Self::key(host, port), fingerprint.to_string());
135 self.persist_unlocked()
136 }
137
138 fn reload_from_disk_unlocked(&mut self) -> SshCliResult<()> {
139 let fresh = Self::load(self.path.clone())?;
140 self.entries = fresh.entries;
141 Ok(())
142 }
143
144 fn persist_unlocked(&self) -> SshCliResult<()> {
145 if let Some(parent_dir) = self.path.parent() {
146 std::fs::create_dir_all(parent_dir)?;
147 }
148 const HEADER: &str = "# ssh-cli known_hosts (TOFU)\n";
153 let body_len = self
154 .entries
155 .iter()
156 .fold(HEADER.len(), |acc, (k, v)| acc + k.len() + v.len() + 2);
157 let mut body = String::with_capacity(body_len);
158 body.push_str(HEADER);
159 for (k, v) in &self.entries {
160 body.push_str(k);
161 body.push(' ');
162 body.push_str(v);
163 body.push('\n');
164 }
165
166 let parent_dir = self
167 .path
168 .parent()
169 .map(Path::to_path_buf)
170 .unwrap_or_else(|| PathBuf::from("."));
171 let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir).map_err(SshCliError::Io)?;
172 use std::io::Write;
173 tmp.write_all(body.as_bytes())?;
174 tmp.as_file().sync_data()?;
175 tmp.persist(&self.path)
176 .map_err(|e| SshCliError::Io(e.error))?;
177
178 crate::fs_perm::set_secret_file_mode(&self.path)?;
179 Ok(())
180 }
181}
182
183pub fn verify_tofu(
195 kh: &mut KnownHosts,
196 host: &str,
197 port: u16,
198 fingerprint: &str,
199 replace: bool,
200) -> SshCliResult<bool> {
201 let _lock = HostsFileLock::acquire(&kh.path)?;
202 kh.reload_from_disk_unlocked()?;
203 match kh.get(host, port).map(str::to_string) {
204 None => {
205 kh.entries
206 .insert(KnownHosts::key(host, port), fingerprint.to_string());
207 kh.persist_unlocked()?;
208 Ok(true)
209 }
210 Some(existing) if fingerprints_eq(&existing, fingerprint) => Ok(true),
211 Some(existing) if replace => {
212 tracing::warn!(
213 host,
214 port,
215 old = %existing,
216 novo = %fingerprint,
217 "replacing host key (--replace-host-key)"
218 );
219 kh.entries
220 .insert(KnownHosts::key(host, port), fingerprint.to_string());
221 kh.persist_unlocked()?;
222 Ok(true)
223 }
224 Some(existing) => Err(SshCliError::HostKeyChanged {
225 host: host.to_string(),
226 port,
227 expected: existing,
228 obtained: fingerprint.to_string(),
229 }),
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::sync::{Arc, Barrier};
237 use std::thread;
238 use tempfile::TempDir;
239
240 #[test]
241 fn fingerprints_eq_matches_and_rejects() {
242 assert!(fingerprints_eq("abc", "abc"));
243 assert!(!fingerprints_eq("abc", "abd"));
244 assert!(!fingerprints_eq("abc", "ab"));
245 assert!(!fingerprints_eq("ab", "abc"));
246 }
247
248 #[test]
249 fn tofu_stores_and_accepts_same() {
250 let tmp = TempDir::new().unwrap();
251 let path = tmp.path().join("known_hosts");
252 let mut kh = KnownHosts::load(path).unwrap();
253 assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
254 assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
255 }
256
257 #[test]
258 fn tofu_rejects_change() {
259 let tmp = TempDir::new().unwrap();
260 let path = tmp.path().join("known_hosts");
261 let mut kh = KnownHosts::load(path).unwrap();
262 verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
263 let err = verify_tofu(&mut kh, "h", 22, "fp2", false).unwrap_err();
264 assert!(matches!(err, SshCliError::HostKeyChanged { .. }));
265 }
266
267 #[test]
268 fn tofu_replaces_with_flag() {
269 let tmp = TempDir::new().unwrap();
270 let path = tmp.path().join("known_hosts");
271 let mut kh = KnownHosts::load(path).unwrap();
272 verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
273 assert!(verify_tofu(&mut kh, "h", 22, "fp2", true).unwrap());
274 assert_eq!(kh.get("h", 22), Some("fp2"));
275 }
276
277 #[test]
279 fn concurrent_store_merges_both_hosts() {
280 let tmp = TempDir::new().unwrap();
281 let path = Arc::new(tmp.path().join("known_hosts"));
282 let barrier = Arc::new(Barrier::new(2));
283 let p1 = Arc::clone(&path);
284 let p2 = Arc::clone(&path);
285 let b1 = Arc::clone(&barrier);
286 let b2 = Arc::clone(&barrier);
287
288 let t1 = thread::spawn(move || {
289 let mut kh = KnownHosts::load((*p1).clone()).unwrap();
290 b1.wait();
291 verify_tofu(&mut kh, "alpha.example", 22, "fp-alpha", false).unwrap();
292 });
293 let t2 = thread::spawn(move || {
294 let mut kh = KnownHosts::load((*p2).clone()).unwrap();
295 b2.wait();
296 verify_tofu(&mut kh, "beta.example", 22, "fp-beta", false).unwrap();
297 });
298 t1.join().unwrap();
299 t2.join().unwrap();
300
301 let final_kh = KnownHosts::load((*path).clone()).unwrap();
302 assert_eq!(final_kh.get("alpha.example", 22), Some("fp-alpha"));
303 assert_eq!(final_kh.get("beta.example", 22), Some("fp-beta"));
304 assert_eq!(final_kh.entries.len(), 2);
305 }
306}