ssh_cli/ssh/
known_hosts.rs1use crate::erros::{SshCliError, SshCliResult};
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Default, Clone)]
13pub struct KnownHosts {
14 entries: BTreeMap<String, String>,
15 path: PathBuf,
16}
17
18impl KnownHosts {
19 #[must_use]
21 pub fn key(host: &str, port: u16) -> String {
22 format!("{host}:{port}")
23 }
24
25 pub fn load(path: PathBuf) -> SshCliResult<Self> {
27 let mut entries = BTreeMap::new();
28 if path.exists() {
29 let text = std::fs::read_to_string(&path)?;
30 for line in text.lines() {
31 let line = line.trim();
32 if line.is_empty() || line.starts_with('#') {
33 continue;
34 }
35 let mut parts = line.split_whitespace();
36 if let (Some(k), Some(fp)) = (parts.next(), parts.next()) {
37 entries.insert(k.to_string(), fp.to_string());
38 }
39 }
40 }
41 Ok(Self { entries, path })
42 }
43
44 #[must_use]
46 pub fn path_beside_config(config_toml: &Path) -> PathBuf {
47 config_toml
48 .parent()
49 .map(|p| p.join("known_hosts"))
50 .unwrap_or_else(|| PathBuf::from("known_hosts"))
51 }
52
53 #[must_use]
55 pub fn get(&self, host: &str, port: u16) -> Option<&str> {
56 self.entries
57 .get(&Self::key(host, port))
58 .map(String::as_str)
59 }
60
61 pub fn store(&mut self, host: &str, port: u16, fingerprint: &str) -> SshCliResult<()> {
63 self.entries
64 .insert(Self::key(host, port), fingerprint.to_string());
65 self.persist()
66 }
67
68 fn persist(&self) -> SshCliResult<()> {
69 if let Some(parent_dir) = self.path.parent() {
70 std::fs::create_dir_all(parent_dir)?;
71 }
72 let mut body = String::new();
73 body.push_str("# ssh-cli known_hosts (TOFU)\n");
74 for (k, v) in &self.entries {
75 body.push_str(k);
76 body.push(' ');
77 body.push_str(v);
78 body.push('\n');
79 }
80
81 let parent_dir = self
82 .path
83 .parent()
84 .map(Path::to_path_buf)
85 .unwrap_or_else(|| PathBuf::from("."));
86 let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir).map_err(SshCliError::Io)?;
87 use std::io::Write;
88 tmp.write_all(body.as_bytes())?;
89 tmp.as_file().sync_data()?;
90 tmp.persist(&self.path)
91 .map_err(|e| SshCliError::Io(e.error))?;
92
93 #[cfg(unix)]
94 {
95 use std::os::unix::fs::PermissionsExt;
96 let mut perms = std::fs::metadata(&self.path)?.permissions();
97 perms.set_mode(0o600);
98 std::fs::set_permissions(&self.path, perms)?;
99 }
100 Ok(())
101 }
102}
103
104pub fn verify_tofu(
113 kh: &mut KnownHosts,
114 host: &str,
115 port: u16,
116 fingerprint: &str,
117 replace: bool,
118) -> SshCliResult<bool> {
119 match kh.get(host, port) {
120 None => {
121 kh.store(host, port, fingerprint)?;
122 Ok(true)
123 }
124 Some(existing) if existing == fingerprint => Ok(true),
125 Some(existing) if replace => {
126 tracing::warn!(
127 host,
128 port,
129 old = %existing,
130 novo = %fingerprint,
131 "replacing host key (--replace-host-key)"
132 );
133 kh.store(host, port, fingerprint)?;
134 Ok(true)
135 }
136 Some(existing) => Err(SshCliError::HostKeyChanged {
137 host: host.to_string(),
138 port,
139 expected: existing.to_string(),
140 obtained: fingerprint.to_string(),
141 }),
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use tempfile::TempDir;
149
150 #[test]
151 fn tofu_stores_and_accepts_same() {
152 let tmp = TempDir::new().unwrap();
153 let path = tmp.path().join("known_hosts");
154 let mut kh = KnownHosts::load(path).unwrap();
155 assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
156 assert!(verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap());
157 }
158
159 #[test]
160 fn tofu_rejects_change() {
161 let tmp = TempDir::new().unwrap();
162 let path = tmp.path().join("known_hosts");
163 let mut kh = KnownHosts::load(path).unwrap();
164 verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
165 let err = verify_tofu(&mut kh, "h", 22, "fp2", false).unwrap_err();
166 assert!(matches!(err, SshCliError::HostKeyChanged { .. }));
167 }
168
169 #[test]
170 fn tofu_replaces_with_flag() {
171 let tmp = TempDir::new().unwrap();
172 let path = tmp.path().join("known_hosts");
173 let mut kh = KnownHosts::load(path).unwrap();
174 verify_tofu(&mut kh, "h", 22, "fp1", false).unwrap();
175 assert!(verify_tofu(&mut kh, "h", 22, "fp2", true).unwrap());
176 assert_eq!(kh.get("h", 22), Some("fp2"));
177 }
178}