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