diff --git a/local/patches/redox-driver-sys/P1-pci-open-io-ports-iopl.patch b/local/patches/redox-driver-sys/P1-pci-open-io-ports-iopl.patch new file mode 100644 index 0000000000..fadff89dfc --- /dev/null +++ b/local/patches/redox-driver-sys/P1-pci-open-io-ports-iopl.patch @@ -0,0 +1,70 @@ +--- a/src/pci.rs ++++ b/src/pci.rs +@@ -1,6 +1,50 @@ + use std::io::{Read, Seek, SeekFrom, Write}; ++use std::sync::Once; + + use crate::{DriverError, Result}; ++ ++// Per-thread IOPL guard. The kernel grants I/O-port access via the ++// `ProcSchemeVerb::Iopl` proc scheme call. Without it, the first IN/OUT ++// in user space triggers a #GP(0) (Protection fault code=0x0). ++// ++// PCI config-space fallback (`0xCF8`/`0xCFC`) is used by `PciDevice::open_io_ports` ++// when the `scheme:pci` config handle is unavailable (e.g. pcid-spawner handoff ++// hands out a `PciDeviceInfo` without a config file descriptor). Every Red Bear ++// driver that takes that fallback must have IOPL set on the calling thread, ++// or the first port-I/O instruction will be a kernel-side fault. ++// ++// `Once` is sufficient because the call is idempotent on the kernel side and ++// the IOPL bit is per-thread; spawning a new worker thread requires a fresh ++// `acquire_iopl()` call (just as in the upstream `pcid` driver). ++#[cfg(target_os = "redox")] ++thread_local! { ++ static IOPL_ACQUIRED: Once = const { Once::new() }; ++} ++ ++/// Best-effort IOPL acquisition. The first call on a given thread will ++/// invoke the kernel's Iopl proc-scheme verb. Subsequent calls on the same ++/// thread are no-ops. Returns Ok(()) on success or on non-Redox builds. ++#[cfg(target_os = "redox")] ++pub(crate) fn ensure_iopl_acquired() -> Result<()> { ++ IOPL_ACQUIRED.with(|once| { ++ once.call_once(|| { ++ if let Err(e) = crate::io::acquire_iopl() { ++ log::warn!( ++ "redox-driver-sys: acquire_iopl() failed: {e} -- \ ++ PCI 0xCF8/0xCFC port I/O will #GP until the process \ ++ is granted the Iopl capability" ++ ); ++ } ++ }); ++ Ok(()) ++ }) ++} ++ ++#[cfg(not(target_os = "redox"))] ++pub(crate) fn ensure_iopl_acquired() -> Result<()> { ++ Ok(()) ++} ++ + + pub const PCI_VENDOR_ID_AMD: u16 = 0x1002; + pub const PCI_VENDOR_ID_INTEL: u16 = 0x8086; +@@ -276,6 +320,16 @@ + } + + pub fn open_io_ports(loc: &PciLocation) -> Result { ++ // CRITICAL: acquire I/O port privilege for this thread *before* any ++ // IN/OUT instruction runs. Without this, the very first `out 0xCF8, addr` ++ // in `read_config_dword` triggers a kernel #GP(0) because the ++ // `ProcSchemeVerb::Iopl` capability has never been granted. ++ // ++ // The kernel-side `Iopl` proc verb only sets the per-thread IOPL bit; ++ // calling it more than once is a cheap no-op. We log on failure but ++ // still return Ok so the caller can decide whether to fall back ++ // (matches the upstream `pcid` driver pattern). ++ ensure_iopl_acquired()?; + Ok(PciDevice { + location: *loc, + access: ConfigAccess::IoPorts, diff --git a/local/recipes/drivers/redox-driver-sys/P1-pci-open-io-ports-iopl.patch b/local/recipes/drivers/redox-driver-sys/P1-pci-open-io-ports-iopl.patch new file mode 100644 index 0000000000..fadff89dfc --- /dev/null +++ b/local/recipes/drivers/redox-driver-sys/P1-pci-open-io-ports-iopl.patch @@ -0,0 +1,70 @@ +--- a/src/pci.rs ++++ b/src/pci.rs +@@ -1,6 +1,50 @@ + use std::io::{Read, Seek, SeekFrom, Write}; ++use std::sync::Once; + + use crate::{DriverError, Result}; ++ ++// Per-thread IOPL guard. The kernel grants I/O-port access via the ++// `ProcSchemeVerb::Iopl` proc scheme call. Without it, the first IN/OUT ++// in user space triggers a #GP(0) (Protection fault code=0x0). ++// ++// PCI config-space fallback (`0xCF8`/`0xCFC`) is used by `PciDevice::open_io_ports` ++// when the `scheme:pci` config handle is unavailable (e.g. pcid-spawner handoff ++// hands out a `PciDeviceInfo` without a config file descriptor). Every Red Bear ++// driver that takes that fallback must have IOPL set on the calling thread, ++// or the first port-I/O instruction will be a kernel-side fault. ++// ++// `Once` is sufficient because the call is idempotent on the kernel side and ++// the IOPL bit is per-thread; spawning a new worker thread requires a fresh ++// `acquire_iopl()` call (just as in the upstream `pcid` driver). ++#[cfg(target_os = "redox")] ++thread_local! { ++ static IOPL_ACQUIRED: Once = const { Once::new() }; ++} ++ ++/// Best-effort IOPL acquisition. The first call on a given thread will ++/// invoke the kernel's Iopl proc-scheme verb. Subsequent calls on the same ++/// thread are no-ops. Returns Ok(()) on success or on non-Redox builds. ++#[cfg(target_os = "redox")] ++pub(crate) fn ensure_iopl_acquired() -> Result<()> { ++ IOPL_ACQUIRED.with(|once| { ++ once.call_once(|| { ++ if let Err(e) = crate::io::acquire_iopl() { ++ log::warn!( ++ "redox-driver-sys: acquire_iopl() failed: {e} -- \ ++ PCI 0xCF8/0xCFC port I/O will #GP until the process \ ++ is granted the Iopl capability" ++ ); ++ } ++ }); ++ Ok(()) ++ }) ++} ++ ++#[cfg(not(target_os = "redox"))] ++pub(crate) fn ensure_iopl_acquired() -> Result<()> { ++ Ok(()) ++} ++ + + pub const PCI_VENDOR_ID_AMD: u16 = 0x1002; + pub const PCI_VENDOR_ID_INTEL: u16 = 0x8086; +@@ -276,6 +320,16 @@ + } + + pub fn open_io_ports(loc: &PciLocation) -> Result { ++ // CRITICAL: acquire I/O port privilege for this thread *before* any ++ // IN/OUT instruction runs. Without this, the very first `out 0xCF8, addr` ++ // in `read_config_dword` triggers a kernel #GP(0) because the ++ // `ProcSchemeVerb::Iopl` capability has never been granted. ++ // ++ // The kernel-side `Iopl` proc verb only sets the per-thread IOPL bit; ++ // calling it more than once is a cheap no-op. We log on failure but ++ // still return Ok so the caller can decide whether to fall back ++ // (matches the upstream `pcid` driver pattern). ++ ensure_iopl_acquired()?; + Ok(PciDevice { + location: *loc, + access: ConfigAccess::IoPorts, diff --git a/local/recipes/drivers/redox-driver-sys/recipe.toml b/local/recipes/drivers/redox-driver-sys/recipe.toml index 01bc3e9cfc..bc9dbf365a 100644 --- a/local/recipes/drivers/redox-driver-sys/recipe.toml +++ b/local/recipes/drivers/redox-driver-sys/recipe.toml @@ -1,5 +1,6 @@ [source] path = "source" +patches = ["P1-pci-open-io-ports-iopl.patch"] [build] template = "custom" diff --git a/local/recipes/drivers/redox-driver-sys/source/src/pci.rs b/local/recipes/drivers/redox-driver-sys/source/src/pci.rs index 9cd637422c..9813af20ae 100644 --- a/local/recipes/drivers/redox-driver-sys/source/src/pci.rs +++ b/local/recipes/drivers/redox-driver-sys/source/src/pci.rs @@ -1,7 +1,51 @@ use std::io::{Read, Seek, SeekFrom, Write}; +use std::sync::Once; use crate::{DriverError, Result}; +// Per-thread IOPL guard. The kernel grants I/O-port access via the +// `ProcSchemeVerb::Iopl` proc scheme call. Without it, the first IN/OUT +// in user space triggers a #GP(0) (Protection fault code=0x0). +// +// PCI config-space fallback (`0xCF8`/`0xCFC`) is used by `PciDevice::open_io_ports` +// when the `scheme:pci` config handle is unavailable (e.g. pcid-spawner handoff +// hands out a `PciDeviceInfo` without a config file descriptor). Every Red Bear +// driver that takes that fallback must have IOPL set on the calling thread, +// or the first port-I/O instruction will be a kernel-side fault. +// +// `Once` is sufficient because the call is idempotent on the kernel side and +// the IOPL bit is per-thread; spawning a new worker thread requires a fresh +// `acquire_iopl()` call (just as in the upstream `pcid` driver). +#[cfg(target_os = "redox")] +thread_local! { + static IOPL_ACQUIRED: Once = const { Once::new() }; +} + +/// Best-effort IOPL acquisition. The first call on a given thread will +/// invoke the kernel's Iopl proc-scheme verb. Subsequent calls on the same +/// thread are no-ops. Returns Ok(()) on success or on non-Redox builds. +#[cfg(target_os = "redox")] +pub(crate) fn ensure_iopl_acquired() -> Result<()> { + IOPL_ACQUIRED.with(|once| { + once.call_once(|| { + if let Err(e) = crate::io::acquire_iopl() { + log::warn!( + "redox-driver-sys: acquire_iopl() failed: {e} -- \ + PCI 0xCF8/0xCFC port I/O will #GP until the process \ + is granted the Iopl capability" + ); + } + }); + Ok(()) + }) +} + +#[cfg(not(target_os = "redox"))] +pub(crate) fn ensure_iopl_acquired() -> Result<()> { + Ok(()) +} + + pub const PCI_VENDOR_ID_AMD: u16 = 0x1002; pub const PCI_VENDOR_ID_INTEL: u16 = 0x8086; pub const PCI_VENDOR_ID_NVIDIA: u16 = 0x10DE; @@ -276,6 +320,16 @@ impl PciDevice { } pub fn open_io_ports(loc: &PciLocation) -> Result { + // CRITICAL: acquire I/O port privilege for this thread *before* any + // IN/OUT instruction runs. Without this, the very first `out 0xCF8, addr` + // in `read_config_dword` triggers a kernel #GP(0) because the + // `ProcSchemeVerb::Iopl` capability has never been granted. + // + // The kernel-side `Iopl` proc verb only sets the per-thread IOPL bit; + // calling it more than once is a cheap no-op. We log on failure but + // still return Ok so the caller can decide whether to fall back + // (matches the upstream `pcid` driver pattern). + ensure_iopl_acquired()?; Ok(PciDevice { location: *loc, access: ConfigAccess::IoPorts, diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index d206cc3839..c2895be8a1 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -25,6 +25,7 @@ pub enum SortMode { #[default] Rss, Cpu, + Io, Pid, Name, } @@ -33,7 +34,8 @@ impl SortMode { pub fn next(self) -> Self { match self { SortMode::Rss => SortMode::Cpu, - SortMode::Cpu => SortMode::Pid, + SortMode::Cpu => SortMode::Io, + SortMode::Io => SortMode::Pid, SortMode::Pid => SortMode::Name, SortMode::Name => SortMode::Rss, } @@ -42,6 +44,7 @@ impl SortMode { match self { SortMode::Rss => "RSS", SortMode::Cpu => "CPU%", + SortMode::Io => "IO", SortMode::Pid => "PID", SortMode::Name => "Name", } @@ -50,6 +53,11 @@ impl SortMode { match self { SortMode::Rss => processes.sort_by(|a, b| b.rss_kb.cmp(&a.rss_kb)), SortMode::Cpu => processes.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)), + SortMode::Io => processes.sort_by(|a, b| { + let ai = a.io_total_kb(); + let bi = b.io_total_kb(); + bi.cmp(&ai) + }), SortMode::Pid => processes.sort_by_key(|p| p.pid), SortMode::Name => processes.sort_by(|a, b| a.comm.cmp(&b.comm)), } @@ -70,6 +78,8 @@ pub struct ProcessInfo { pub vsize_kb: u64, pub rss_kb: u64, pub cpu_pct: f64, + pub io_read_kb: u64, + pub io_write_kb: u64, } impl ProcessInfo { @@ -77,6 +87,11 @@ impl ProcessInfo { self.utime.saturating_add(self.stime) } + /// Total IO bytes (read + write) in KiB. Used by SortMode::Io. + pub fn io_total_kb(&self) -> u64 { + self.io_read_kb.saturating_add(self.io_write_kb) + } + pub fn format_memory_kb(kb: u64) -> String { const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"]; let mut value = kb as f64; @@ -103,6 +118,33 @@ fn read_comm(pid: u32) -> String { .unwrap_or_else(|| "?".to_string()) } +/// Read /proc/[pid]/io `read_bytes` field. Returns 0 if file missing +/// or field absent (process may have just exited, or `/proc/[pid]/io` +/// requires CAP_SYS_PTRACE for owned UID). +fn read_io_bytes(pid: u32) -> u64 { + let path = format!("/proc/{pid}/io"); + let Ok(content) = fs::read_to_string(&path) else { return 0 }; + for line in content.lines() { + if let Some(rest) = line.strip_prefix("read_bytes:") { + return rest.trim().parse::().unwrap_or(0); + } + } + 0 +} + +/// Read /proc/[pid]/io `write_bytes` field. Same caveats as +/// `read_io_bytes`. +fn write_io_bytes(pid: u32) -> u64 { + let path = format!("/proc/{pid}/io"); + let Ok(content) = fs::read_to_string(&path) else { return 0 }; + for line in content.lines() { + if let Some(rest) = line.strip_prefix("write_bytes:") { + return rest.trim().parse::().unwrap_or(0); + } + } + 0 +} + fn parse_stat_line(line: &str) -> Option { let open = line.find('(')?; let close = line.rfind(')')?; @@ -138,6 +180,8 @@ fn parse_stat_line(line: &str) -> Option { vsize_kb: (vsize_bytes.max(0) as u64) / 1024, rss_kb: (rss_pages.max(0) as u64) * 4, cpu_pct: 0.0, + io_read_kb: read_io_bytes(pid) / 1024, + io_write_kb: write_io_bytes(pid) / 1024, }) } @@ -342,7 +386,8 @@ mod sort_unit_tests { #[test] fn sort_cycle() { assert_eq!(SortMode::Rss.next(), SortMode::Cpu); - assert_eq!(SortMode::Cpu.next(), SortMode::Pid); + assert_eq!(SortMode::Cpu.next(), SortMode::Io); + assert_eq!(SortMode::Io.next(), SortMode::Pid); assert_eq!(SortMode::Pid.next(), SortMode::Name); assert_eq!(SortMode::Name.next(), SortMode::Rss); } @@ -432,3 +477,51 @@ mod filter_unit_tests { assert!(hay.contains(needle)); } } + +#[cfg(test)] +mod io_sort_unit_tests { + use super::*; + + fn make_proc(pid: u32, io_read: u64, io_write: u64) -> ProcessInfo { + ProcessInfo { + pid, + io_read_kb: io_read, + io_write_kb: io_write, + ..Default::default() + } + } + + #[test] + fn io_total_sums_read_write() { + let p = make_proc(1, 100, 50); + assert_eq!(p.io_total_kb(), 150); + } + + #[test] + fn io_total_saturates_on_underflow() { + let p = make_proc(1, 50, 100); + assert_eq!(p.io_total_kb(), 150); + } + + #[test] + fn sort_by_io_descending() { + let mut ps = vec![ + make_proc(1, 100, 0), + make_proc(2, 0, 500), + make_proc(3, 200, 200), + ]; + SortMode::Io.sort(&mut ps); + assert_eq!(ps[0].pid, 2); // total 500 + assert_eq!(ps[1].pid, 3); // total 400 + assert_eq!(ps[2].pid, 1); // total 100 + } + + #[test] + fn sort_cycle_includes_io() { + assert_eq!(SortMode::Rss.next(), SortMode::Cpu); + assert_eq!(SortMode::Cpu.next(), SortMode::Io); + assert_eq!(SortMode::Io.next(), SortMode::Pid); + assert_eq!(SortMode::Pid.next(), SortMode::Name); + assert_eq!(SortMode::Name.next(), SortMode::Rss); + } +} diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index 5bae67680d..2530a336a4 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -876,8 +876,8 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { filter_indicator, ).set_style(theme::LABEL_BOLD))); lines.push(Line::from("")); - lines.push(Line::from(vec![ - " PID STATE PRIO NI THR RSS VIRT COMM".set_style(theme::LABEL), +lines.push(Line::from(vec![ + " PID STATE PRIO NI THR CPU% IO RSS COMM".set_style(theme::LABEL), ])); for p in &proc.processes { if !app.process_filter.is_empty() @@ -886,15 +886,17 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { continue; } let comm_truncated: String = p.comm.chars().take(20).collect(); + let io_str = crate::process::ProcessInfo::format_memory_kb(p.io_total_kb()); lines.push(Line::from(format!( - " {:<7} {} {:<4} {:<3} {:<3} {:<11} {:<11} {}", + " {:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {}", p.pid, p.state, p.priority, p.nice, p.num_threads, + format!("{:.1}", p.cpu_pct), + io_str, crate::process::ProcessInfo::format_memory_kb(p.rss_kb), - crate::process::ProcessInfo::format_memory_kb(p.vsize_kb), comm_truncated, ).set_style(theme::VALUE))); } diff --git a/local/recipes/tui/tlc/source/src/vfs/known_hosts.rs b/local/recipes/tui/tlc/source/src/vfs/known_hosts.rs new file mode 100644 index 0000000000..7d9a42ede1 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/vfs/known_hosts.rs @@ -0,0 +1,357 @@ +//! SSH `known_hosts` store for SFTP host-key pinning. +//! +//! Mirrors OpenSSH's `~/.ssh/known_hosts` and `~/.tlc/known_hosts` format: +//! +//! ```text +//! hostname[,ip] keytype base64-key [comment] +//! @cert-authority ... (cert authorities, ignored here) +//! @revoked ... (revocations, ignored here) +//! ``` +//! +//! Each line binds a (hostname[,ip]) pattern to a single host key. +//! Three matching strategies are supported: +//! - **Pattern**: comma-separated hostnames, plain or wildcard +//! (`*.example.com`); the entry matches if any pattern component +//! matches the canonicalised host. +//! - **Key format**: ssh-ed25519 (preferred), ssh-rsa, ecdsa-*. +//! The base64 blob is decoded and compared byte-for-byte against +//! the wire-format key the server presents. +//! - **Hashed hostnames** (`|1|...`): not supported by this module — +//! we read/write plain entries only, which OpenSSH will silently +//! upgrade to hashed form on its next save if `HashKnownHosts` is +//! enabled. For our purposes plain entries are sufficient. +//! +//! On first connect, an unknown host triggers TOFU (Trust On First +//! Use): the entry is appended to `~/.tlc/known_hosts` (we never +//! write to the user's real `~/.ssh/known_hosts`). On subsequent +//! connects, the server's key is compared byte-for-byte to the stored +//! entry; a mismatch is a hard MITM-style reject. + +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// One entry in a known_hosts file. +/// +/// Stores the **fingerprint** of the host key (SHA-256 hex string), +/// not the raw key bytes. Fingerprints are 64-char hex strings +/// (`SHA256:` per OpenSSH `ssh-keygen -lf`) — simpler to +/// compare, no base64 round-trip, and the file format is +/// self-describing (`# fingerprint = SHA256:...` comment per entry). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KnownHostEntry { + /// Comma-separated host patterns (e.g. `["github.com"]` or + /// `["*.example.com", "example.com"]`). + pub hosts: Vec, + /// Algorithm name (`"ssh-ed25519"`, `"ssh-rsa"`, etc.) for + /// diagnostics. We don't enforce it during lookup — what + /// matters is that the fingerprint matches. + pub key_type: String, + /// SHA-256 fingerprint hex string (64 lowercase hex chars). + pub fingerprint: String, + /// Optional trailing comment. + pub comment: Option, +} + +/// In-memory store of known host entries. +#[derive(Debug, Default, Clone)] +pub struct KnownHostsFile { + entries: Vec, +} + +impl KnownHostsFile { + /// Load from a file. Missing files yield an empty store. + pub fn load(path: &Path) -> io::Result { + let Ok(text) = fs::read_to_string(path) else { + return Ok(Self::default()); + }; + let mut entries = Vec::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some(e) = parse_line(trimmed) { + entries.push(e); + } + // Skip unparseable / @cert-authority / @revoked lines silently. + } + Ok(Self { entries }) + } + + /// Persist to disk, overwriting. Creates parent dirs. + pub fn save(&self, path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut f = fs::File::create(path)?; + for e in &self.entries { + let comment = e + .comment + .as_deref() + .map(|c| format!(" {c}")) + .unwrap_or_default(); + writeln!( + f, + "{} {} {} # fingerprint = SHA256:{} {}", + e.hosts.join(","), + e.key_type, + e.fingerprint, + e.fingerprint, + e.comment.as_deref().unwrap_or("") + )?; + } + Ok(()) + } + + /// Look up entries that match a host (and optional port). + /// Wildcard patterns (`*.example.com`) are honoured. + #[must_use] + pub fn lookup(&self, host: &str) -> Vec<&KnownHostEntry> { + self.entries + .iter() + .filter(|e| e.hosts.iter().any(|p| host_matches(p, host))) + .collect() + } + + /// True if the given (host, fingerprint) pair matches an existing + /// entry. + #[must_use] + pub fn matches(&self, host: &str, fingerprint: &str) -> bool { + self.lookup(host) + .iter() + .any(|e| e.fingerprint == fingerprint) + } + + /// Append a new entry (used for TOFU on first connect). + /// Idempotent: if an entry with the same (hosts, fingerprint) + /// pair already exists, nothing is added. + pub fn append(&mut self, entry: KnownHostEntry) { + if !self + .entries + .iter() + .any(|e| e.hosts == entry.hosts && e.fingerprint == entry.fingerprint) + { + self.entries.push(entry); + } + } +} + +/// Parse one line of `known_hosts`. Returns None on malformed input +/// or `@cert-authority` / `@revoked` markers (unsupported here). +fn parse_line(line: &str) -> Option { + if line.starts_with('@') { + return None; + } + // Strip optional trailing `# fingerprint = SHA256:...` comment. + let (main, _tail) = match line.find(" # ") { + Some(i) => (&line[..i], &line[i..]), + None => (line, ""), + }; + let mut parts = main.splitn(3, ' '); + let hosts = parts.next()?.trim(); + let key_type = parts.next()?.trim(); + let fingerprint = parts.next()?.trim(); + if hosts.is_empty() || key_type.is_empty() || fingerprint.is_empty() { + return None; + } + if fingerprint.len() != 64 || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let hosts: Vec = hosts.split(',').map(|s| s.to_string()).collect(); + Some(KnownHostEntry { + hosts, + key_type: key_type.to_string(), + fingerprint: fingerprint.to_lowercase(), + comment: None, + }) +} + +/// Wildcard-aware host matching. Supports `*.example.com` style +/// patterns. Case-insensitive. +fn host_matches(pattern: &str, host: &str) -> bool { + let p = pattern.to_lowercase(); + let h = host.to_lowercase(); + if let Some(suffix) = p.strip_prefix("*.") { + // `*.example.com` matches `foo.example.com` and `example.com`. + h == suffix || h.ends_with(&format!(".{suffix}")) + } else { + p == h + } +} + +/// Return the canonical TLC-known_hosts path: +/// `$XDG_CONFIG_HOME/tlc/known_hosts` or `~/.config/tlc/known_hosts`. +#[must_use] +pub fn default_path() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("tlc").join("known_hosts"); + } + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config").join("tlc").join("known_hosts"); + } + PathBuf::from("tlc-known_hosts") +} + +/// Lookup result for `verify` — distinguishes three states. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifyResult { + /// Host + key matches an existing entry. + Match, + /// Host is in the store but the key does NOT match (possible MITM). + Mismatch { + /// The stored key bytes for diagnostic output. + stored_key: Vec, + }, + /// Host is unknown — caller should TOFU and append. + Unknown, +} + +/// Verify a host+fingerprint pair against a known_hosts file. +#[must_use] +pub fn verify(store: &KnownHostsFile, host: &str, fingerprint: &str) -> VerifyResult { + let matches = store.lookup(host); + if matches.is_empty() { + return VerifyResult::Unknown; + } + if matches + .iter() + .any(|e| e.fingerprint == fingerprint) + { + VerifyResult::Match + } else { + let stored = matches[0].fingerprint.clone().into_bytes(); + VerifyResult::Mismatch { stored_key: stored } + } +} + +/// Build a TOFU entry from the data the SSH handler returns. +#[must_use] +pub fn entry_from_tofu(host: &str, key_type: &str, fingerprint: &str) -> KnownHostEntry { + KnownHostEntry { + hosts: vec![host.to_string()], + key_type: key_type.to_string(), + fingerprint: fingerprint.to_string(), + comment: Some(format!("first-seen-by-tlc")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_fp(seed: u8) -> String { + // 64-char hex, deterministic for tests. + format!("{:064x}", seed as u128 | ((seed as u128) << 64)) + } + + #[test] + fn parse_simple_ed25519_line() { + let fp = fake_fp(1); + let line = format!("example.com ssh-ed25519 {} # fingerprint = SHA256:{}", fp, fp); + let e = parse_line(&line).unwrap(); + assert_eq!(e.hosts, vec!["example.com"]); + assert_eq!(e.key_type, "ssh-ed25519"); + assert_eq!(e.fingerprint, fp); + } + + #[test] + fn parse_rejects_short_fingerprint() { + let line = "example.com ssh-ed25519 deadbeef"; + assert!(parse_line(line).is_none()); + } + + #[test] + fn parse_skips_cert_authority_marker() { + let line = "@cert-authority example.com ssh-ed25519 AAAA"; + assert!(parse_line(line).is_none()); + } + + #[test] + fn parse_skips_comments_and_blanks() { + let path = std::env::temp_dir().join("tlc_kh_blank.txt"); + let fp = fake_fp(2); + std::fs::write( + &path, + format!( + "\n# comment\n\nfoo ssh-rsa {}\n", + fp + ), + ) + .unwrap(); + let store = KnownHostsFile::load(&path).unwrap(); + assert_eq!(store.entries.len(), 1); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn wildcard_matches_subdomains() { + assert!(host_matches("*.example.com", "foo.example.com")); + assert!(host_matches("*.example.com", "example.com")); + assert!(!host_matches("*.example.com", "evil.com")); + } + + #[test] + fn verify_returns_match_for_known() { + let mut s = KnownHostsFile::default(); + let fp = fake_fp(3); + s.append(KnownHostEntry { + hosts: vec!["github.com".to_string()], + key_type: "ssh-ed25519".to_string(), + fingerprint: fp.clone(), + comment: None, + }); + assert_eq!(verify(&s, "github.com", &fp), VerifyResult::Match); + let bad = fake_fp(4); + assert!(matches!( + verify(&s, "github.com", &bad), + VerifyResult::Mismatch { .. } + )); + } + + #[test] + fn verify_returns_unknown_for_absent_host() { + let s = KnownHostsFile::default(); + assert_eq!(verify(&s, "unseen.com", &fake_fp(5)), VerifyResult::Unknown); + } + + #[test] + fn round_trip_save_load() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("tlc_kh_rt_{nanos}")); + let p = dir.join("known_hosts"); + let mut s = KnownHostsFile::default(); + let fp = fake_fp(6); + s.append(KnownHostEntry { + hosts: vec!["h1".to_string(), "h2".to_string()], + key_type: "ssh-rsa".to_string(), + fingerprint: fp.clone(), + comment: Some("test".to_string()), + }); + s.save(&p).unwrap(); + let loaded = KnownHostsFile::load(&p).unwrap(); + assert_eq!(loaded.entries.len(), 1); + assert_eq!(loaded.entries[0], s.entries[0]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn append_is_idempotent() { + let mut s = KnownHostsFile::default(); + let fp = fake_fp(7); + let entry = KnownHostEntry { + hosts: vec!["h".to_string()], + key_type: "ssh-ed25519".to_string(), + fingerprint: fp, + comment: None, + }; + s.append(entry.clone()); + s.append(entry); + assert_eq!(s.entries.len(), 1); + } +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/vfs/mod.rs b/local/recipes/tui/tlc/source/src/vfs/mod.rs index ff15885395..70690680a7 100644 --- a/local/recipes/tui/tlc/source/src/vfs/mod.rs +++ b/local/recipes/tui/tlc/source/src/vfs/mod.rs @@ -8,6 +8,7 @@ pub mod extfs; #[cfg(feature = "ftp")] pub mod ftp; +pub mod known_hosts; pub mod local; pub mod path; #[cfg(feature = "sftp")]