Skip to main content

ssh_cli/ssh/
known_hosts.rs

1//! Persistência TOFU de fingerprints de host key em XDG.
2//!
3//! Formato linha a linha (0o600):
4//! `host:porta <fingerprint_sha256>`
5
6use crate::erros::{ErroSshCli, ResultadoSshCli};
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10/// Mapa host:porta → fingerprint.
11#[derive(Debug, Default, Clone)]
12pub struct KnownHosts {
13    entradas: BTreeMap<String, String>,
14    caminho: PathBuf,
15}
16
17impl KnownHosts {
18    /// Chave canônica `host:porta`.
19    #[must_use]
20    pub fn chave(host: &str, porta: u16) -> String {
21        format!("{host}:{porta}")
22    }
23
24    /// Carrega o arquivo (vazio se inexistente).
25    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    /// Caminho padrão `config_dir/known_hosts` a partir do path do `config.toml`.
44    #[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    /// Consulta fingerprint gravado.
53    #[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    /// Insere ou atualiza e persiste atomicamente.
61    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
103/// Verifica fingerprint TOFU.
104///
105/// - Sem entrada: aceita e grava (TOFU).
106/// - Com entrada igual: aceita.
107/// - Com entrada diferente: recusa, a menos que `substituir` seja true.
108pub 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}