From dee867a45363dd08994fb221a884e1659f630b54 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ <34196588+UnbreakableMJ@users.noreply.github.com> Date: Mon, 4 May 2026 20:31:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(hostkey):=20M19.2=20=E2=80=94=20hashed-emi?= =?UTF-8?q?t=20+=20revoke=20writers=20(FR-84=20write,=20FR-86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the write-side counterparts to M19.1's read-side `HashedHost`, plus the `@revoked` prepend helper FR-86 needs. Also promotes two previously-private helpers to `pub` so the upcoming `gitway hosts` verb family (M19.4) can drive them without re-export shims. src/hostkey.rs: - `pub fn append_known_host(path, host, fp)` — promoted from `pub(crate)`. Plaintext append; creates parent dir; no locking (M19 explicit non-goal). - `pub fn append_known_host_hashed(path, host, fp)` (new) — emits `|1|| ` with a fresh 20-byte OS-RNG salt per call. Round-trippable through `cert_authority::parse_known_hosts` + `HashedHost::matches(host)`, pinned by `tests/test_hostkey_writes.rs::append_known_host_hashed_writes_round_trippable_entry`. - `pub fn prepend_revoked(path, host_pattern, fp)` (new) — atomic prepend via tempfile + `std::fs::rename`. 1 MiB cap on input file (refused with a clear `tips-thinking`-style message pointing at `--known-hosts` for splitting). Trust-merger treats `@revoked` as a hard reject regardless of position; the prepend is purely a readability convention. - `pub fn all_embedded()` (new) — returns `(host, fingerprint, alg)` triples for the `gitway hosts list` embedded section. 3 hosts × 3 algorithms = 9 entries, alg in {"ed25519", "ecdsa", "rsa"}. - `pub enum HashMode { Empty, Plaintext, Hashed }` and `pub fn detect_hash_mode(path) -> Result` (new) — inspect-existing-file decision for `gitway hosts add`'s default hashed/plaintext choice. Short-circuits on the first hashed token seen. - `pub fn default_known_hosts_path()` — promoted from private. `~/.config/gitway/known_hosts` via `dirs::config_dir()`. - New private `ensure_parent_exists` helper extracted from the existing `mkdir -p` block so all four writers share one error message shape. Cargo.toml: - Adds `rand_core = { version = "0.6", features = ["std", "getrandom"] }` is already a runtime dep; M19.2 just uses it directly via `OsRng.fill_bytes`. tests/test_hostkey_writes.rs (new, ~13 tests): - Plaintext append: file creation, append-to-existing. - Hashed append: round-trip parse + match, distinct-salt-per-call (privacy property of `HashKnownHosts yes`). - Revoke prepend: file creation, prepend-position assertion, oversize refusal. - detect_hash_mode: missing file, comments-only, plaintext-only, short-circuit on first hashed token. - all_embedded: count + algorithm-tag completeness. - default_known_hosts_path: ends-with assertion. Stacks on M19.1 (#22, merged). Plan: M19.2 of anvil-gitway-milestone-plan.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hostkey.rs | 331 ++++++++++++++++++++++++++++++++--- tests/test_hostkey_writes.rs | 216 +++++++++++++++++++++++ 2 files changed, 524 insertions(+), 23 deletions(-) create mode 100644 tests/test_hostkey_writes.rs diff --git a/src/hostkey.rs b/src/hostkey.rs index 2cb72e6..ed2d47d 100644 --- a/src/hostkey.rs +++ b/src/hostkey.rs @@ -27,6 +27,11 @@ use std::path::Path; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use hmac::{Hmac, Mac}; +use rand_core::{OsRng, RngCore}; +use sha1::Sha1; + use crate::cert_authority::{parse_known_hosts, CertAuthority, KnownHostsFile, RevokedEntry}; use crate::error::AnvilError; use crate::ssh_config::lexer::wildcard_match; @@ -147,8 +152,18 @@ fn fingerprints_from_known_hosts(path: &Path, hostname: &str) -> Result Option { +/// Returns the default known-hosts path: `~/.config/gitway/known_hosts` +/// (or the platform-equivalent `dirs::config_dir()` location). +/// +/// Returns `None` when `dirs::config_dir()` cannot resolve a config +/// directory (extremely rare — typically only on misconfigured CI +/// runners with no `HOME` / `XDG_CONFIG_HOME` and no fallback). +/// +/// Promoted from crate-private to public in M19 (PRD §5.8.8) so the +/// `gitway hosts` subcommand family can target the same path the +/// rest of Anvil reads from by default. +#[must_use] +pub fn default_known_hosts_path() -> Option { dirs::config_dir().map(|d| d.join("gitway").join("known_hosts")) } @@ -324,38 +339,98 @@ fn embedded_fingerprints(host: &str) -> Vec { } } -/// Appends `host SHA256:` as a new line to the `known_hosts` -/// file at `path`, creating the file (and any missing parent directories) -/// if needed. +/// Appends `host SHA256:` as a new plaintext line to +/// the `known_hosts` file at `path`, creating the file (and any +/// missing parent directories) if needed. +/// +/// Promoted from crate-private to public in M19 (PRD §5.8.8 FR-85) +/// so the `gitway hosts add` verb can drive the write side without a +/// re-export shim. Used internally by +/// [`crate::ssh_config::StrictHostKeyChecking::AcceptNew`] for the +/// first-connection TOFU path. /// -/// Used by [`crate::ssh_config::StrictHostKeyChecking::AcceptNew`] to -/// record the fingerprint of an otherwise-unknown host on first -/// connection. This is the minimum write surface — file locking and -/// duplicate-detection are deferred to the post-M12 TOFU UX. +/// File locking and duplicate-detection are deferred to a post-M19 +/// polish pass — see PRD §5.8.8 risks. /// /// # Errors /// -/// Returns an error if the parent directory cannot be created, or if -/// the file cannot be opened for append, or if the write fails. -pub(crate) fn append_known_host( +/// Returns an error if the parent directory cannot be created, the +/// file cannot be opened for append, or the write fails. +pub fn append_known_host(path: &Path, host: &str, fingerprint: &str) -> Result<(), AnvilError> { + use std::io::Write; + + ensure_parent_exists(path)?; + + let line = format!("{host} {fingerprint}\n"); + let mut file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path) + .map_err(|e| { + AnvilError::invalid_config(format!( + "could not open known_hosts {} for append: {e}", + path.display(), + )) + })?; + file.write_all(line.as_bytes()).map_err(|e| { + AnvilError::invalid_config(format!( + "could not write to known_hosts {}: {e}", + path.display(), + )) + })?; + + Ok(()) +} + +/// Appends `|1|| SHA256:` +/// to the `known_hosts` file at `path`, generating a fresh 20-byte +/// random salt for this entry. +/// +/// This is the M19 (PRD §5.8.8 FR-84) write-side counterpart to +/// [`crate::cert_authority::HashedHost::matches`]. The encoding is +/// bit-for-bit identical to what `ssh-keygen -H` would write — see +/// the `tests/test_hostkey_writes.rs` round-trip test that proves it +/// re-parses through [`crate::cert_authority::parse_known_hosts`] + +/// [`crate::cert_authority::HashedHost::matches(host)`] cleanly. +/// +/// `host` is what gets HMAC-SHA1'd; pass exactly the hostname the +/// caller wants the hash to match (no implicit lower-casing — that +/// policy lives in the caller, mirroring OpenSSH's +/// `hostfile.c::lowercase` flag handling). +/// +/// # Errors +/// +/// Returns an error if the parent directory cannot be created, the +/// file cannot be opened for append, or the write fails. +pub fn append_known_host_hashed( path: &Path, host: &str, fingerprint: &str, ) -> Result<(), AnvilError> { use std::io::Write; - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).map_err(|e| { - AnvilError::invalid_config(format!( - "could not create known_hosts parent {}: {e}", - parent.display(), - )) - })?; - } - } + ensure_parent_exists(path)?; - let line = format!("{host} {fingerprint}\n"); + // Fresh 20-byte salt per entry, sourced from the OS RNG. + let mut salt = [0u8; 20]; + OsRng.fill_bytes(&mut salt); + + let mut mac = >::new_from_slice(&salt).map_err(|_e| { + // `_e` is the InvalidLength variant; HMAC-SHA1 does not + // enforce key-length restrictions in practice, so this + // branch is effectively dead. Discarded by design. + AnvilError::invalid_config( + "HMAC-SHA1 init failed unexpectedly; refusing to write hashed entry".to_owned(), + ) + })?; + mac.update(host.as_bytes()); + let hash = mac.finalize().into_bytes(); + + let line = format!( + "|1|{}|{} {fingerprint}\n", + BASE64.encode(salt), + BASE64.encode(hash.as_slice()), + ); let mut file = std::fs::OpenOptions::new() .append(true) .create(true) @@ -376,6 +451,216 @@ pub(crate) fn append_known_host( Ok(()) } +/// Prepends `@revoked ` to the +/// `known_hosts` file at `path`, atomically via a sibling tempfile + +/// rename. Creates the file (and missing parents) if it does not +/// yet exist. +/// +/// M19 (PRD §5.8.8 FR-86): the `@revoked` line is written **first** +/// in the file so it surfaces ahead of any direct pin during +/// human inspection. The trust-merger ([`host_key_trust`]) +/// already treats `@revoked` as a hard reject regardless of position, +/// so the prepend is purely a readability convention. +/// +/// # Atomicity +/// +/// Reads the existing file into memory (capped at 1 MiB), prepends +/// the new line, writes to `.tmp.`, then +/// [`std::fs::rename`] over the original. POSIX `rename` is atomic +/// within a filesystem; on Windows, `MoveFileEx` with +/// `MOVEFILE_REPLACE_EXISTING` is the closest equivalent and is what +/// `std::fs::rename` uses. A crash mid-rename leaves either the old +/// file or the new one — never a torn write. +/// +/// # Errors +/// +/// Returns an error if the file is larger than 1 MiB, the parent +/// directory cannot be created, the tempfile cannot be opened, or +/// the rename fails. +pub fn prepend_revoked( + path: &Path, + host_pattern: &str, + fingerprint: &str, +) -> Result<(), AnvilError> { + use std::io::Write; + + const MAX_FILE_BYTES: u64 = 1024 * 1024; + + ensure_parent_exists(path)?; + + // Read the existing file (or treat missing as empty). + let existing: Vec = if path.exists() { + let metadata = std::fs::metadata(path).map_err(|e| { + AnvilError::invalid_config(format!( + "could not stat known_hosts {} for revoke: {e}", + path.display(), + )) + })?; + if metadata.len() > MAX_FILE_BYTES { + return Err(AnvilError::invalid_config(format!( + "known_hosts {} is larger than {MAX_FILE_BYTES} bytes; refusing to load \ + entire file into memory for revoke. Split the file or pass --known-hosts \ + to point at a smaller one.", + path.display(), + ))); + } + std::fs::read(path).map_err(|e| { + AnvilError::invalid_config(format!( + "could not read known_hosts {} for revoke: {e}", + path.display(), + )) + })? + } else { + Vec::new() + }; + + // Build the temp path with a random suffix so concurrent revokes + // don't collide on the same temp name. + let mut suffix_bytes = [0u8; 8]; + OsRng.fill_bytes(&mut suffix_bytes); + let suffix = BASE64 + .encode(suffix_bytes) + .replace('/', "_") + .replace('+', "-"); + let tmp_path = path.with_extension(format!("revoke.{suffix}.tmp")); + + let mut tmp = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .map_err(|e| { + AnvilError::invalid_config(format!( + "could not create temp file {} for revoke: {e}", + tmp_path.display(), + )) + })?; + + let new_line = format!("@revoked {host_pattern} {fingerprint}\n"); + tmp.write_all(new_line.as_bytes()) + .map_err(|e| AnvilError::invalid_config(format!("could not write revoke header: {e}")))?; + tmp.write_all(&existing).map_err(|e| { + AnvilError::invalid_config(format!("could not copy existing known_hosts contents: {e}")) + })?; + tmp.sync_all().map_err(|e| { + AnvilError::invalid_config(format!("could not fsync temp file before rename: {e}")) + })?; + drop(tmp); + + std::fs::rename(&tmp_path, path).map_err(|e| { + // Best-effort cleanup of the orphaned tempfile; ignore the + // result because we're already in an error path. + let _ = std::fs::remove_file(&tmp_path); + AnvilError::invalid_config(format!( + "could not rename {} -> {}: {e}", + tmp_path.display(), + path.display(), + )) + })?; + + Ok(()) +} + +/// Returns the embedded fingerprint catalogue as `(host, fingerprint, +/// algorithm)` triples for surfacing in `gitway hosts list`. +/// +/// The algorithm tag is one of `"ed25519"`, `"ecdsa"`, `"rsa"` — +/// matches the per-index ordering inside [`GITHUB_FINGERPRINTS`], +/// [`GITLAB_FINGERPRINTS`], and [`CODEBERG_FINGERPRINTS`]. +#[must_use] +pub fn all_embedded() -> Vec<(String, String, &'static str)> { + const ALGS: [&str; 3] = ["ed25519", "ecdsa", "rsa"]; + let mut out = Vec::with_capacity(9); + for (host, fps) in [ + ("github.com", GITHUB_FINGERPRINTS), + ("gitlab.com", GITLAB_FINGERPRINTS), + ("codeberg.org", CODEBERG_FINGERPRINTS), + ] { + for (idx, fp) in fps.iter().enumerate() { + let alg = ALGS.get(idx).copied().unwrap_or("unknown"); + out.push((host.to_owned(), (*fp).to_owned(), alg)); + } + } + out +} + +/// Per-file format detected by [`detect_hash_mode`]. Drives whether +/// `gitway hosts add` should emit a hashed or plaintext entry by +/// default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashMode { + /// File does not exist, or contains no recognizable host lines. + Empty, + /// At least one direct line uses the plaintext `host SHA256:fp` + /// shape; no hashed entries seen. New entries default to + /// plaintext. + Plaintext, + /// At least one direct line uses the `|1|salt|hash SHA256:fp` + /// shape. New entries default to hashed. + Hashed, +} + +/// Inspects the existing `known_hosts` file at `path` and decides +/// whether new entries should be hashed (matches OpenSSH's +/// `HashKnownHosts yes` behaviour) or plaintext. +/// +/// - Returns [`HashMode::Empty`] if the file does not exist or is +/// empty / contains only comments + `@`-marker lines. +/// - Returns [`HashMode::Hashed`] if **any** non-comment direct line +/// starts with `|1|` (matches OpenSSH's `_ssh_host_hashed_p` check). +/// - Returns [`HashMode::Plaintext`] otherwise. +/// +/// Cheap — reads the file once line-by-line and short-circuits on +/// the first hashed token seen. +/// +/// # Errors +/// +/// Returns an error only if the file exists but cannot be read. +pub fn detect_hash_mode(path: &Path) -> Result { + if !path.exists() { + return Ok(HashMode::Empty); + } + let content = std::fs::read_to_string(path).map_err(|e| { + AnvilError::invalid_config(format!( + "could not read known_hosts {} for hash-mode detect: {e}", + path.display(), + )) + })?; + let mut saw_plaintext = false; + for raw in content.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('@') { + continue; + } + // Direct line. Inspect the first whitespace-delimited token. + let host_token = line.split_whitespace().next().unwrap_or(""); + if host_token.starts_with("|1|") { + return Ok(HashMode::Hashed); + } + saw_plaintext = true; + } + if saw_plaintext { + Ok(HashMode::Plaintext) + } else { + Ok(HashMode::Empty) + } +} + +/// Internal helper — `mkdir -p` for the parent of `path`. Used by +/// every M19 writer so they share the same error-message shape. +fn ensure_parent_exists(path: &Path) -> Result<(), AnvilError> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| { + AnvilError::invalid_config(format!( + "could not create known_hosts parent {}: {e}", + parent.display(), + )) + })?; + } + } + Ok(()) +} + // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/tests/test_hostkey_writes.rs b/tests/test_hostkey_writes.rs new file mode 100644 index 0000000..d875bb3 --- /dev/null +++ b/tests/test_hostkey_writes.rs @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Rust guideline compliant 2026-03-30 +//! End-to-end coverage of the M19.2 write-side `hostkey` API: +//! [`anvil_ssh::hostkey::append_known_host`], +//! [`anvil_ssh::hostkey::append_known_host_hashed`], +//! [`anvil_ssh::hostkey::prepend_revoked`], +//! [`anvil_ssh::hostkey::detect_hash_mode`], and +//! [`anvil_ssh::hostkey::all_embedded`]. +//! +//! Hermetic — every test runs against a fresh `tempfile::TempDir`, +//! no network, no russh. + +use std::io::Read; + +use anvil_ssh::cert_authority::parse_known_hosts; +use anvil_ssh::hostkey::{ + all_embedded, append_known_host, append_known_host_hashed, default_known_hosts_path, + detect_hash_mode, prepend_revoked, HashMode, +}; + +/// Helper: read a file's contents as a string. Panics on read +/// failure — these tests don't try to be robust to disk quirks; if +/// the tempfile can't be read, something is very wrong. +fn read_file(path: &std::path::Path) -> String { + let mut f = std::fs::File::open(path).expect("open tempfile"); + let mut s = String::new(); + f.read_to_string(&mut s).expect("read tempfile"); + s +} + +// ── append_known_host (plaintext) ─────────────────────────────────────────── + +#[test] +fn append_known_host_creates_file_and_writes_plaintext_line() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("nested").join("known_hosts"); + append_known_host(&path, "github.com", "SHA256:abc").expect("append"); + assert!(path.exists()); + let content = read_file(&path); + assert_eq!(content, "github.com SHA256:abc\n"); +} + +#[test] +fn append_known_host_appends_to_existing_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + std::fs::write(&path, "old.example SHA256:old\n").expect("seed"); + append_known_host(&path, "new.example", "SHA256:new").expect("append"); + let content = read_file(&path); + assert_eq!(content, "old.example SHA256:old\nnew.example SHA256:new\n",); +} + +// ── append_known_host_hashed ──────────────────────────────────────────────── + +#[test] +fn append_known_host_hashed_writes_round_trippable_entry() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + append_known_host_hashed(&path, "github.com", "SHA256:abc").expect("append"); + let content = read_file(&path); + // The line must start with `|1|` and end with the fingerprint. + assert!( + content.starts_with("|1|"), + "expected hashed prefix; got: {content:?}", + ); + assert!(content.contains("SHA256:abc")); + // Round-trip: parse + match should recover "github.com". + let parsed = parse_known_hosts(&content).expect("parse"); + assert_eq!(parsed.hashed.len(), 1); + assert_eq!(parsed.hashed[0].fingerprint, "SHA256:abc"); + assert!(parsed.hashed[0].matches("github.com")); + assert!(!parsed.hashed[0].matches("gitlab.com")); +} + +#[test] +fn append_known_host_hashed_uses_distinct_salt_per_call() { + // Two appends for the same host MUST produce two different + // `|1|salt|hash` tokens — anything else means the salt isn't + // freshly drawn from the OS RNG and the privacy property of + // `HashKnownHosts yes` is broken. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + append_known_host_hashed(&path, "github.com", "SHA256:abc").expect("append 1"); + append_known_host_hashed(&path, "github.com", "SHA256:abc").expect("append 2"); + let content = read_file(&path); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2); + assert_ne!( + lines[0], lines[1], + "two appends of the same host MUST use distinct salts (got identical lines: {lines:?})", + ); + // Both lines still match `github.com`. + let parsed = parse_known_hosts(&content).expect("parse"); + assert_eq!(parsed.hashed.len(), 2); + assert!(parsed.hashed[0].matches("github.com")); + assert!(parsed.hashed[1].matches("github.com")); +} + +// ── prepend_revoked ───────────────────────────────────────────────────────── + +#[test] +fn prepend_revoked_creates_file_when_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + prepend_revoked(&path, "*.evil.example", "SHA256:bad").expect("revoke"); + let content = read_file(&path); + assert_eq!(content, "@revoked *.evil.example SHA256:bad\n"); +} + +#[test] +fn prepend_revoked_atomically_prepends_before_existing_lines() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + let original = "# header\ngood.example SHA256:good\n"; + std::fs::write(&path, original).expect("seed"); + prepend_revoked(&path, "bad.example", "SHA256:bad").expect("revoke"); + let content = read_file(&path); + assert_eq!( + content, + format!("@revoked bad.example SHA256:bad\n{original}"), + ); +} + +#[test] +fn prepend_revoked_refuses_oversized_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + // Synthesize a 2 MiB file — over the 1 MiB cap. + let big = "a".repeat(2 * 1024 * 1024); + std::fs::write(&path, &big).expect("seed"); + let err = prepend_revoked(&path, "bad", "SHA256:bad").expect_err("must refuse"); + let msg = format!("{err}"); + assert!( + msg.contains("larger than"), + "expected oversize error message, got: {msg}", + ); +} + +// ── detect_hash_mode ──────────────────────────────────────────────────────── + +#[test] +fn detect_hash_mode_empty_when_file_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("does_not_exist"); + assert_eq!(detect_hash_mode(&path).expect("detect"), HashMode::Empty); +} + +#[test] +fn detect_hash_mode_empty_when_only_comments_and_markers() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + std::fs::write( + &path, + "# comment\n@cert-authority *.example.com ssh-ed25519 AAAA ca\n", + ) + .expect("seed"); + // No direct lines → Empty (the @cert-authority is an `@`-marker line + // and skipped; no plaintext, no hashed direct entries). + assert_eq!(detect_hash_mode(&path).expect("detect"), HashMode::Empty); +} + +#[test] +fn detect_hash_mode_plaintext_when_only_plaintext_direct_lines() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + std::fs::write(&path, "github.com SHA256:abc\n").expect("seed"); + assert_eq!( + detect_hash_mode(&path).expect("detect"), + HashMode::Plaintext, + ); +} + +#[test] +fn detect_hash_mode_hashed_short_circuits_on_first_hashed_token() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("known_hosts"); + std::fs::write(&path, "github.com SHA256:abc\n|1|salt=|hash= SHA256:def\n").expect("seed"); + assert_eq!(detect_hash_mode(&path).expect("detect"), HashMode::Hashed); +} + +// ── all_embedded ──────────────────────────────────────────────────────────── + +#[test] +fn all_embedded_returns_three_per_well_known_host() { + let entries = all_embedded(); + // 3 hosts × 3 algorithms each = 9 entries. + assert_eq!(entries.len(), 9); + let github_count = entries.iter().filter(|(h, _, _)| h == "github.com").count(); + let gitlab_count = entries.iter().filter(|(h, _, _)| h == "gitlab.com").count(); + let codeberg_count = entries + .iter() + .filter(|(h, _, _)| h == "codeberg.org") + .count(); + assert_eq!(github_count, 3); + assert_eq!(gitlab_count, 3); + assert_eq!(codeberg_count, 3); + // Algorithms come back tagged ed25519 / ecdsa / rsa. + let algs: std::collections::BTreeSet<&'static str> = + entries.iter().map(|(_, _, a)| *a).collect(); + assert_eq!( + algs, + ["ecdsa", "ed25519", "rsa"] + .iter() + .copied() + .collect::>(), + ); +} + +// ── default_known_hosts_path ──────────────────────────────────────────────── + +#[test] +fn default_known_hosts_path_ends_with_gitway_known_hosts() { + let p = default_known_hosts_path().expect("default path resolves on this platform"); + let s = p.to_string_lossy(); + assert!(s.ends_with("gitway/known_hosts") || s.ends_with(r"gitway\known_hosts")); +}