rport 0.2.49

A p2p port forwarding client using WebRTC datachannels
#![allow(dead_code)]
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub struct KnownHostEntry {
    pub addr: String,
    pub fingerprint: String,
    pub accepted_at: String,
}

#[derive(Debug, Clone)]
pub struct KnownHosts {
    path: PathBuf,
    entries: Vec<KnownHostEntry>,
}

impl KnownHosts {
    pub fn default_path() -> PathBuf {
        home::home_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join(".rport-known-hosts")
    }

    pub fn load() -> Self {
        let path = Self::default_path();
        let entries = if path.exists() {
            std::fs::read_to_string(&path)
                .ok()
                .map(|content| {
                    content
                        .lines()
                        .filter_map(|line| {
                            let line = line.trim();
                            if line.is_empty() || line.starts_with('#') {
                                return None;
                            }
                            let parts: Vec<&str> = line.splitn(3, ' ').collect();
                            if parts.len() >= 2 {
                                Some(KnownHostEntry {
                                    addr: parts[0].to_string(),
                                    fingerprint: parts[1].to_string(),
                                    accepted_at: parts.get(2).unwrap_or(&"").to_string(),
                                })
                            } else {
                                None
                            }
                        })
                        .collect()
                })
                .unwrap_or_default()
        } else {
            Vec::new()
        };
        Self { path, entries }
    }

    pub fn save(&self) -> std::io::Result<()> {
        let content = self
            .entries
            .iter()
            .map(|e| format!("{} {} {}", e.addr, e.fingerprint, e.accepted_at))
            .collect::<Vec<_>>()
            .join("\n");
        std::fs::write(&self.path, content + "\n")
    }

    pub fn check(&self, addr: &str, fingerprint: &str) -> CheckResult {
        for entry in &self.entries {
            if entry.addr == addr {
                if entry.fingerprint == fingerprint {
                    return CheckResult::Match;
                } else {
                    return CheckResult::Mismatch {
                        expected: entry.fingerprint.clone(),
                        actual: fingerprint.to_string(),
                    };
                }
            }
        }
        CheckResult::Unknown
    }

    pub fn add(&mut self, addr: &str, fingerprint: &str) {
        let entry = KnownHostEntry {
            addr: addr.to_string(),
            fingerprint: fingerprint.to_string(),
            accepted_at: chrono::Utc::now()
                .format("(accepted on %Y-%m-%d %H:%M:%S UTC)")
                .to_string(),
        };
        self.entries.retain(|e| e.addr != addr);
        self.entries.push(entry);
    }

    pub fn prompt_and_confirm(addr: &str, fingerprint: &str) -> bool {
        eprintln!();
        eprintln!("The authenticity of host '[{}]' can't be established.", addr);
        eprintln!("Fingerprint: {}", fingerprint);
        eprintln!("Are you sure you want to continue connecting? (yes/no)");
        let mut input = String::new();
        std::io::stdin().read_line(&mut input).ok();
        let input = input.trim().to_lowercase();
        input == "yes" || input == "y"
    }
}

pub enum CheckResult {
    Match,
    Mismatch { expected: String, actual: String },
    Unknown,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_known_hosts_check() {
        let kh = KnownHosts {
            path: PathBuf::from("/tmp/test_kh"),
            entries: vec![KnownHostEntry {
                addr: "192.168.1.5:4443".to_string(),
                fingerprint: "AA:BB:CC:DD".to_string(),
                accepted_at: String::new(),
            }],
        };

        match kh.check("192.168.1.5:4443", "AA:BB:CC:DD") {
            CheckResult::Match => {}
            _ => panic!("Expected Match"),
        }

        match kh.check("192.168.1.5:4443", "XX:YY:ZZ") {
            CheckResult::Mismatch { .. } => {}
            _ => panic!("Expected Mismatch"),
        }

        match kh.check("10.0.0.1:4443", "AA:BB:CC:DD") {
            CheckResult::Unknown => {}
            _ => panic!("Expected Unknown"),
        }
    }

    #[test]
    fn test_add_and_check() {
        let mut kh = KnownHosts {
            path: PathBuf::from("/tmp/test_kh2"),
            entries: vec![],
        };

        assert!(matches!(kh.check("host:4443", "FP:123"), CheckResult::Unknown));

        kh.add("host:4443", "FP:123");
        assert!(matches!(kh.check("host:4443", "FP:123"), CheckResult::Match));

        // Updating should work
        kh.add("host:4443", "FP:456");
        assert!(matches!(kh.check("host:4443", "FP:456"), CheckResult::Match));
    }
}