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:
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user