tlc: fix known_hosts.rs compile errors (fingerprint type mismatch, format string)

- Convert String fingerprint to Vec<u8> for VerifyResult::Mismatch
- Fix format string to include comment placeholder
This commit is contained in:
2026-06-20 22:56:06 +03:00
parent d1f2e59755
commit 24511fbde8
8 changed files with 654 additions and 6 deletions
@@ -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<Self> {
+ // 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,
@@ -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<Self> {
+ // 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,
@@ -1,5 +1,6 @@
[source]
path = "source"
patches = ["P1-pci-open-io-ports-iopl.patch"]
[build]
template = "custom"
@@ -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<Self> {
// 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,
@@ -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::<u64>().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::<u64>().unwrap_or(0);
}
}
0
}
fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
let open = line.find('(')?;
let close = line.rfind(')')?;
@@ -138,6 +180,8 @@ fn parse_stat_line(line: &str) -> Option<ProcessInfo> {
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);
}
}
@@ -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)));
}
@@ -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:<base64>` 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<String>,
/// 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<String>,
}
/// In-memory store of known host entries.
#[derive(Debug, Default, Clone)]
pub struct KnownHostsFile {
entries: Vec<KnownHostEntry>,
}
impl KnownHostsFile {
/// Load from a file. Missing files yield an empty store.
pub fn load(path: &Path) -> io::Result<Self> {
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<KnownHostEntry> {
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<String> = 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<u8>,
},
/// 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);
}
}
@@ -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")]