From 18c4830de75e58fef0661caf6399c9b99ab4047a Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 11 Aug 2026 16:38:35 +0300 Subject: [PATCH] knot2/knot-migrate: rehearse host key & master key Lewis: May this revision serve well! --- knot2/crates/knot-migrate/src/emit.rs | 231 ++++++++- knot2/crates/knot-migrate/src/main.rs | 115 +++-- knot2/crates/knot-migrate/src/mapping.rs | 16 + knot2/crates/knot-migrate/src/rehearse.rs | 36 +- knot2/crates/knot-migrate/src/report.rs | 223 +++++---- knot2/crates/knot-migrate/tests/migrate.rs | 552 ++++++++++++++++++++- 6 files changed, 1022 insertions(+), 151 deletions(-) diff --git a/knot2/crates/knot-migrate/src/emit.rs b/knot2/crates/knot-migrate/src/emit.rs index eb22b5b8..2bc78a8e 100644 --- a/knot2/crates/knot-migrate/src/emit.rs +++ b/knot2/crates/knot-migrate/src/emit.rs @@ -7,11 +7,13 @@ use knot_cobs::{ }; use knot_git::{GitError, Layout}; use knot_runtime::Signer; +use knot_secrets::{MasterKey, SecretsError}; use knot_types::{AccountDid, ActorId, KnotHostname, KnotId, ObjectFormat, RepoDid, RepoName}; use serde::Serialize; use serde::de::DeserializeOwned; use url::Url; +use crate::adopt; use crate::mapping::{AdoptRepo, MappedGrant, Mapping}; #[derive(Debug, thiserror::Error)] @@ -37,6 +39,15 @@ pub enum EmitError { path: PathBuf, source: std::io::Error, }, + #[error("read host key {path}: {source}")] + ReadHostKey { + path: PathBuf, + source: std::io::Error, + }, + #[error( + "host key {path} is the public half of a key pair. Point --host-key at the private key, usually the same path without the .pub." + )] + PublicHostKey { path: PathBuf }, #[error("host key {path} doesn't parse as an OpenSSH private key: {source}")] HostKey { path: PathBuf, @@ -335,6 +346,7 @@ pub fn write_key_archive(path: &Path, repos: &[AdoptRepo]) -> Result<(), EmitErr pub struct HostKey { bytes: zeroize::Zeroizing>, pub algorithm: ssh_key::Algorithm, + pub fingerprint: ssh_key::Fingerprint, } impl HostKey { @@ -343,15 +355,180 @@ impl HostKey { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKeyPolicy { + Keep, + Replace, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKeyPlacement { + Fresh, + Unchanged, + Replacing, +} + +#[derive(Debug)] +pub struct Fingerprints { + pub found: ssh_key::Fingerprint, + pub importing: ssh_key::Fingerprint, +} + +#[derive(Debug, thiserror::Error)] +pub enum HostKeyConflict { + #[error( + "the migration will replace the different host key at {path}, whose fingerprint {} your users already trust, with {}", + .fingerprints.found, + .fingerprints.importing + )] + Different { + path: PathBuf, + fingerprints: Box, + }, + #[error("the migration won't write over the key already at the target: {source}")] + Unparsable { source: EmitError }, + #[error( + "read {path}, which this process can't examine and the old knot might still be serving: {source}" + )] + Unreadable { + path: PathBuf, + source: std::io::Error, + }, + #[error("{path} isn't a regular file, so the migration won't write the host key over it")] + NotAFile { path: PathBuf }, + #[error("examine {path}: {source}")] + Unexaminable { + path: PathBuf, + source: std::io::Error, + }, + #[error( + "the migration will write the host key over {path}, which this process can't write: {source}" + )] + Unwritable { + path: PathBuf, + source: rustix::io::Errno, + }, + #[error( + "the migration will write the host key to {path} under {blocked}, which this process can't write: {source}" + )] + Uncreatable { + path: PathBuf, + blocked: PathBuf, + source: rustix::io::Errno, + }, +} + +enum Occupant { + Absent, + NotAFile, + Unexaminable(std::io::Error), + Unreadable(std::io::Error), + Unparsable(EmitError), + SameKey, + DifferentKey(ssh_key::Fingerprint), +} + +fn occupant(destination: &Path, key: &HostKey) -> Occupant { + match std::fs::symlink_metadata(destination) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Occupant::Absent, + Err(error) => Occupant::Unexaminable(error), + Ok(metadata) if !metadata.is_file() => Occupant::NotAFile, + Ok(_) => match load_host_key(destination) { + Err(EmitError::ReadHostKey { source, .. }) => Occupant::Unreadable(source), + Err(error) => Occupant::Unparsable(error), + Ok(found) => match found.fingerprint == key.fingerprint { + true => Occupant::SameKey, + false => Occupant::DifferentKey(found.fingerprint), + }, + }, + } +} + +pub fn plan_host_key( + destination: &Path, + key: &HostKey, + policy: HostKeyPolicy, +) -> Result { + let path = destination.to_path_buf(); + let placement = match (occupant(destination, key), policy) { + (Occupant::Absent, _) => Ok(HostKeyPlacement::Fresh), + (Occupant::SameKey, _) => Ok(HostKeyPlacement::Unchanged), + ( + Occupant::DifferentKey(_) | Occupant::Unparsable(_) | Occupant::Unreadable(_), + HostKeyPolicy::Replace, + ) => Ok(HostKeyPlacement::Replacing), + (Occupant::DifferentKey(found), HostKeyPolicy::Keep) => Err(HostKeyConflict::Different { + path, + fingerprints: Box::new(Fingerprints { + found, + importing: key.fingerprint, + }), + }), + (Occupant::Unparsable(source), HostKeyPolicy::Keep) => { + Err(HostKeyConflict::Unparsable { source }) + } + (Occupant::Unreadable(source), HostKeyPolicy::Keep) => { + Err(HostKeyConflict::Unreadable { path, source }) + } + (Occupant::NotAFile, _) => Err(HostKeyConflict::NotAFile { path }), + (Occupant::Unexaminable(source), _) => Err(HostKeyConflict::Unexaminable { path, source }), + }?; + writable_target(destination, placement).map(|()| placement) +} + +fn writable_target( + destination: &Path, + placement: HostKeyPlacement, +) -> Result<(), HostKeyConflict> { + match placement { + HostKeyPlacement::Fresh => { + let blocked = match destination.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + match adopt::writable(blocked) { + Ok(()) => Ok(()), + Err(source) if source == rustix::io::Errno::NOENT => Ok(()), + Err(source) => Err(HostKeyConflict::Uncreatable { + path: destination.to_path_buf(), + blocked: blocked.to_path_buf(), + source, + }), + } + } + HostKeyPlacement::Unchanged | HostKeyPlacement::Replacing => rustix::fs::accessat( + rustix::fs::CWD, + destination, + rustix::fs::Access::WRITE_OK, + rustix::fs::AtFlags::EACCESS, + ) + .map_err(|source| HostKeyConflict::Unwritable { + path: destination.to_path_buf(), + source, + }), + } +} + pub fn load_host_key(source: &Path) -> Result { - let bytes = zeroize::Zeroizing::new(std::fs::read(source).map_err(|error| EmitError::Io { - path: source.to_path_buf(), - source: error, - })?); + let bytes = + zeroize::Zeroizing::new( + std::fs::read(source).map_err(|error| EmitError::ReadHostKey { + path: source.to_path_buf(), + source: error, + })?, + ); let key = ssh_key::PrivateKey::from_openssh(bytes.as_slice()).map_err(|error| { - EmitError::HostKey { - path: source.to_path_buf(), - source: error, + match std::str::from_utf8(bytes.as_slice()) + .ok() + .is_some_and(|text| ssh_key::PublicKey::from_openssh(text).is_ok()) + { + true => EmitError::PublicHostKey { + path: source.to_path_buf(), + }, + false => EmitError::HostKey { + path: source.to_path_buf(), + source: error, + }, } })?; if key.is_encrypted() { @@ -361,6 +538,7 @@ pub fn load_host_key(source: &Path) -> Result { } Ok(HostKey { algorithm: key.algorithm(), + fingerprint: key.fingerprint(ssh_key::HashAlg::Sha256), bytes, }) } @@ -377,6 +555,7 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<(), EmitError> { { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); + options.custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32); } let mut file = options.open(path).map_err(io)?; #[cfg(unix)] @@ -413,6 +592,44 @@ impl MasterKeyEnv { pub fn as_str(&self) -> &str { &self.0 } + + pub fn read(&self) -> Result { + let value = zeroize::Zeroizing::new( + std::env::var_os(&self.0) + .ok_or_else(|| MasterKeyError::Unset(self.clone()))? + .into_string() + .map_err(|_| MasterKeyError::NotText(self.clone()))?, + ); + self.decode(&value) + } + + pub fn decode(&self, value: &str) -> Result { + use base64::Engine; + let bytes = zeroize::Zeroizing::new( + base64::engine::general_purpose::STANDARD + .decode(value.trim()) + .map_err(|_| MasterKeyError::NotBase64(self.clone()))?, + ); + MasterKey::new(bytes.as_slice()).map_err(|source| MasterKeyError::Weak { + env: self.clone(), + source, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum MasterKeyError { + #[error("master key env var {0} isn't set")] + Unset(MasterKeyEnv), + #[error("master key env var {0} is set to bytes that aren't text")] + NotText(MasterKeyEnv), + #[error("master key env var {0} isn't base64")] + NotBase64(MasterKeyEnv), + #[error("{source}, decoded from master key env var {env}")] + Weak { + env: MasterKeyEnv, + source: SecretsError, + }, } impl std::fmt::Display for MasterKeyEnv { diff --git a/knot2/crates/knot-migrate/src/main.rs b/knot2/crates/knot-migrate/src/main.rs index fc717c6a..da6dc122 100644 --- a/knot2/crates/knot-migrate/src/main.rs +++ b/knot2/crates/knot-migrate/src/main.rs @@ -2,17 +2,19 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::ExitCode; -use base64::Engine; use knot_migrate::adopt::{self, AdoptError, SourcePolicy}; use knot_migrate::casbin::{self, CasbinError}; -use knot_migrate::emit::{self, ConfigValues, EmitError, MasterKeyEnv}; +use knot_migrate::emit::{ + self, ConfigValues, EmitError, HostKeyConflict, HostKeyPlacement, HostKeyPolicy, MasterKeyEnv, + MasterKeyError, +}; use knot_migrate::envfile::{EnvFile, EnvFileError}; use knot_migrate::mapping::{self, Mapping, MappingError, RepoList}; use knot_migrate::rehearse::{self, Rehearsal}; use knot_migrate::report::{Phase, Report}; use knot_migrate::source::{SourceDb, SourceError, SourceRepoDid, SourceRkey, SourceSchema}; use knot_runtime::OsEntropy; -use knot_secrets::{MasterKey, SealedStore, SecretsError}; +use knot_secrets::{SealedStore, SecretsError}; use knot_types::{AccountDid, KnotHostname, ObjectFormat, RepoDid}; use url::Url; @@ -38,6 +40,8 @@ options: which empties the source tree and needs one filesystem --skip-unreadable migrate the rest when knot-migrate can't read a source path, and leave those repos on the old knot + --force-host-key replace the host key already at the target, + whose fingerprint your users already trust --dry-run print the mapping and reconciliation report, leave the target alone, and exit non-zero while an input is still missing "; @@ -55,6 +59,8 @@ enum MigrateError { #[error(transparent)] Emit(#[from] EmitError), #[error(transparent)] + HostKeyConflict(#[from] HostKeyConflict), + #[error(transparent)] EnvFile(#[from] EnvFileError), #[error(transparent)] Git(#[from] knot_git::GitError), @@ -66,13 +72,11 @@ enum MigrateError { OwnerMismatch { env: String, acl: String }, #[error("--hostname {flag} doesn't match the env file's KNOT_SERVER_HOSTNAME {env}")] HostnameMismatch { flag: String, env: String }, - #[error("master key env var {name} isn't set")] - MissingMasterKey { name: MasterKeyEnv }, - #[error("master key env var {name} isn't base64")] - MalformedMasterKey { name: MasterKeyEnv }, + #[error(transparent)] + MasterKey(#[from] MasterKeyError), #[error("{0}")] Refused(Refusals), - #[error("the rehearsal lists what the real run still needs")] + #[error("the rehearsal lists what the migration still needs")] RehearsalIncomplete, #[error("{context}: {source}")] Io { @@ -141,6 +145,7 @@ struct Args { object_format: ObjectFormat, master_key_env: MasterKeyEnv, source_policy: SourcePolicy, + host_key_policy: HostKeyPolicy, skip_unreadable: bool, dry_run: bool, } @@ -150,6 +155,7 @@ struct Switches { dry_run: bool, consume_source: bool, skip_unreadable: bool, + force_host_key: bool, } const KNOWN_FLAGS: [&str; 9] = [ @@ -203,6 +209,14 @@ fn parse_args(args: &[String]) -> Result { }, None, )), + (None, "--force-host-key") => Ok(( + flags, + Switches { + force_host_key: true, + ..switches + }, + None, + )), (None, flag) => match flag.strip_prefix("--").map(|rest| { rest.split_once('=') .map_or((rest, None), |(key, value)| (key, Some(value))) @@ -254,6 +268,10 @@ fn parse_args(args: &[String]) -> Result { true => SourcePolicy::Consume, false => SourcePolicy::Preserve, }, + host_key_policy: match switches.force_host_key { + true => HostKeyPolicy::Replace, + false => HostKeyPolicy::Keep, + }, skip_unreadable: switches.skip_unreadable, dry_run: switches.dry_run, }) @@ -387,6 +405,10 @@ fn run(args: &[String]) -> Result<(), MigrateError> { adopted: &mapping.repos, scan_path: &repos_dir(&args.target), policy: args.source_policy, + host_key: args.host_key.as_deref(), + host_key_target: &host_key_file(&args.target), + host_key_policy: args.host_key_policy, + master_key: &args.master_key_env, }) }); report(&mapping, orphan_alias_count, Phase::Rehearsed(&rehearsal)); @@ -418,6 +440,16 @@ fn run(args: &[String]) -> Result<(), MigrateError> { println!(); println!("knot key identity: {}", written.knot_did); println!("host key algorithm: {}", written.host_key_algorithm); + println!("host key fingerprint: {}", written.host_key_fingerprint); + match written.host_key_placement { + HostKeyPlacement::Fresh => (), + HostKeyPlacement::Unchanged => { + println!("host key: this key was already at the target") + } + HostKeyPlacement::Replacing => { + println!("host key: the migration replaced the different key at the target") + } + } println!("config: {}", written.config_file.display()); println!("key archive: {}", written.archive_file.display()); Ok(()) @@ -429,6 +461,10 @@ fn repos_dir(target: &Path) -> PathBuf { target.join("repos") } +fn host_key_file(target: &Path) -> PathBuf { + target.join("ssh_host_key") +} + fn report<'a>(mapping: &'a Mapping, orphan_alias_count: u64, phase: Phase<'a>) { print!( "{}", @@ -452,6 +488,8 @@ struct Written { cobs: emit::CobSummary, knot_did: knot_types::KnotId, host_key_algorithm: ssh_key::Algorithm, + host_key_fingerprint: ssh_key::Fingerprint, + host_key_placement: HostKeyPlacement, config_file: PathBuf, archive_file: PathBuf, } @@ -463,11 +501,15 @@ fn materialize( source_repos: &Path, mapping: &Mapping, ) -> Result { - let host_key_source = args - .host_key - .as_deref() - .ok_or_else(|| MigrateError::Usage("--host-key is required for a real run".to_string()))?; + let host_key_source = args.host_key.as_deref().ok_or_else(|| { + MigrateError::Usage("--host-key is required for the migration".to_string()) + })?; let host_key = emit::load_host_key(host_key_source)?; + let host_key_placement = emit::plan_host_key( + &host_key_file(&args.target), + &host_key, + args.host_key_policy, + )?; std::fs::create_dir_all(&args.target).map_err(|source| MigrateError::Io { context: format!("create {}", args.target.display()), source, @@ -481,25 +523,13 @@ fn materialize( })?; let scan_path = repos_dir(&target); let sealed_key_file = target.join("sealed-keys"); - let host_key_file = target.join("ssh_host_key"); + let host_key_destination = host_key_file(&target); let archive_file = target.join("repo-signing-keys.json"); let config_file = target.join("config.toml"); let knot_did = hostname.knot_did(); - let master_key_value = - zeroize::Zeroizing::new(std::env::var(args.master_key_env.as_str()).map_err(|_| { - MigrateError::MissingMasterKey { - name: args.master_key_env.clone(), - } - })?); - let master_key = MasterKey::new( - base64::engine::general_purpose::STANDARD - .decode(master_key_value.trim()) - .map_err(|_| MigrateError::MalformedMasterKey { - name: args.master_key_env.clone(), - })?, - )?; + let master_key = args.master_key_env.read()?; let secrets = SealedStore::open(sealed_key_file.clone(), &master_key, Box::new(OsEntropy))?; secrets.ensure(&knot_did)?; let signer = secrets.signer(&knot_did)?; @@ -514,13 +544,13 @@ fn materialize( emit::write_cobs(&layout, &knot_did, mapping, &signer) })?; emit::write_key_archive(&archive_file, &mapping.repos)?; - host_key.write_to(&host_key_file)?; + host_key.write_to(&host_key_destination)?; let config = emit::render_config(&ConfigValues { hostname: hostname.clone(), admins: vec![mapping.knot_owner.clone()], scan_path, - ssh_host_key_file: host_key_file, + ssh_host_key_file: host_key_destination, sealed_key_file, master_key_env: args.master_key_env.clone(), object_format: args.object_format, @@ -536,6 +566,8 @@ fn materialize( cobs, knot_did, host_key_algorithm: host_key.algorithm, + host_key_fingerprint: host_key.fingerprint, + host_key_placement, config_file, archive_file, }) @@ -566,6 +598,32 @@ mod tests { assert!(args.dry_run); } + #[test] + fn every_switch_is_off_until_it_is_named() { + let off = parse(&["--source-db=/db", "--target=/t"]).unwrap(); + assert!(!off.dry_run); + assert!(!off.skip_unreadable); + assert_eq!(off.source_policy, SourcePolicy::Preserve); + assert_eq!( + off.host_key_policy, + HostKeyPolicy::Keep, + "a fingerprint your users already trust survives a migration nobody asked to force" + ); + let on = parse(&[ + "--source-db=/db", + "--target=/t", + "--dry-run", + "--consume-source", + "--skip-unreadable", + "--force-host-key", + ]) + .unwrap(); + assert!(on.dry_run); + assert!(on.skip_unreadable); + assert_eq!(on.source_policy, SourcePolicy::Consume); + assert_eq!(on.host_key_policy, HostKeyPolicy::Replace); + } + #[test] fn rejects_duplicates_missing_values_and_unknown_flags() { [ @@ -579,6 +637,7 @@ mod tests { &["--source-db", "--target"], &["--mystery=1", "--source-db=/data/knotserver.db"], &["--object-format=blake3", "--source-db=/db", "--target=/t"], + &["--force-host-key=yes", "--source-db=/db", "--target=/t"], ] .into_iter() .for_each(|args| { diff --git a/knot2/crates/knot-migrate/src/mapping.rs b/knot2/crates/knot-migrate/src/mapping.rs index b06407ad..3816f7ea 100644 --- a/knot2/crates/knot-migrate/src/mapping.rs +++ b/knot2/crates/knot-migrate/src/mapping.rs @@ -110,6 +110,16 @@ pub struct OwnerConflict { pub key_owner: SourceDid, } +impl fmt::Display for OwnerConflict { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} acl {}, repo_keys {}", + self.repo, self.acl_owner, self.key_owner + ) + } +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct Drift { pub acl_only_collaborators: Vec<(SourceRepoDid, SourceDid)>, @@ -127,6 +137,12 @@ pub struct Drift { pub unresolved_slash_forms: Vec, } +impl Drift { + pub fn is_clean(&self) -> bool { + self == &Self::default() + } +} + #[derive(Debug)] pub struct Mapping { pub knot_owner: AccountDid, diff --git a/knot2/crates/knot-migrate/src/rehearse.rs b/knot2/crates/knot-migrate/src/rehearse.rs index 56bac35a..b3b78407 100644 --- a/knot2/crates/knot-migrate/src/rehearse.rs +++ b/knot2/crates/knot-migrate/src/rehearse.rs @@ -3,24 +3,35 @@ use std::path::{Path, PathBuf}; use knot_types::scalar_newtype; use crate::adopt::{self, AdoptError, SourcePolicy, Transfer}; +use crate::emit::{ + self, EmitError, HostKeyConflict, HostKeyPlacement, HostKeyPolicy, MasterKeyEnv, MasterKeyError, +}; use crate::mapping::AdoptRepo; +#[derive(Debug, thiserror::Error)] +pub enum HostKeyError { + #[error("--host-key is required for the migration")] + Unset, + #[error(transparent)] + Unusable(#[from] EmitError), +} + #[derive(Debug, thiserror::Error)] pub enum ScanPathError { #[error( - "the real run will create {path} under {blocked}, which this process can't write: {source}" + "the migration will create {path} under {blocked}, which this process can't write: {source}" )] Uncreatable { path: PathBuf, blocked: PathBuf, source: rustix::io::Errno, }, - #[error("the real run will write repos into {path}, which this process can't write: {source}")] + #[error("the migration will write repos into {path}, which this process can't write: {source}")] Unwritable { path: PathBuf, source: rustix::io::Errno, }, - #[error("the real run can't create {path}, which is a symlink to a missing target")] + #[error("the migration can't create {path}, which is a symlink to a missing target")] Dangling { path: PathBuf }, #[error("read {path}: {source}")] Unreadable { @@ -89,6 +100,10 @@ pub struct Inputs<'a> { pub adopted: &'a [AdoptRepo], pub scan_path: &'a Path, pub policy: SourcePolicy, + pub host_key: Option<&'a Path>, + pub host_key_target: &'a Path, + pub host_key_policy: HostKeyPolicy, + pub master_key: &'a MasterKeyEnv, } pub struct Rehearsal { @@ -96,12 +111,19 @@ pub struct Rehearsal { pub transfer: Result, pub scan_path: Result, pub room: Option>, + pub host_key: Result, + pub host_key_target: Option>, + pub master_key: Result, } impl Rehearsal { pub fn run(inputs: Inputs<'_>) -> Self { let probed = Probed::nearest(inputs.scan_path); let transfer = adopt::transfer_mode(inputs.source_repos, probed.existing, inputs.policy); + let source_key = inputs + .host_key + .ok_or(HostKeyError::Unset) + .and_then(|path| emit::load_host_key(path).map_err(HostKeyError::Unusable)); Self { fallback: probed.fallback().map(Path::to_path_buf), room: match transfer { @@ -110,12 +132,20 @@ impl Rehearsal { }, transfer, scan_path: occupancy(probed), + host_key_target: source_key.as_ref().ok().map(|key| { + emit::plan_host_key(inputs.host_key_target, key, inputs.host_key_policy) + }), + host_key: source_key.map(|key| key.algorithm), + master_key: inputs.master_key.read().map(|_| inputs.master_key.clone()), } } pub fn ready(&self) -> bool { self.transfer.is_ok() && self.scan_path.is_ok() + && self.host_key.is_ok() + && self.host_key_target.as_ref().is_none_or(Result::is_ok) + && self.master_key.is_ok() && self.room.as_ref().is_none_or(|room| { room.as_ref() .is_ok_and(|measured| matches!(measured.fit(), Fit::Clear)) diff --git a/knot2/crates/knot-migrate/src/report.rs b/knot2/crates/knot-migrate/src/report.rs index 06f53188..8895d380 100644 --- a/knot2/crates/knot-migrate/src/report.rs +++ b/knot2/crates/knot-migrate/src/report.rs @@ -2,8 +2,10 @@ use std::fmt::{self, Display, Formatter}; use crate::adopt::AdoptOutcome; use crate::emit::CobSummary; +use crate::emit::HostKeyPlacement; use crate::mapping::{Mapping, SkipReason}; use crate::rehearse::{Fit, Occupancy, Rehearsal}; +use crate::source::{SourceDid, SourceRepoDid}; pub struct Report<'a> { pub mapping: &'a Mapping, @@ -37,107 +39,73 @@ impl Display for Report<'_> { .sum::() )?; writeln!(f)?; - writeln!(f, "casbin cross-check drift:")?; - writeln!( - f, - "acl-only collaborator grants unioned in: {}", - drift.acl_only_collaborators.len() - )?; - drift - .acl_only_collaborators - .iter() - .try_for_each(|(repo, did)| writeln!(f, "{repo} <- {did}"))?; - writeln!( - f, - "table-only collaborator grants missing from acl: {}", - drift.table_only_collaborators.len() - )?; - drift - .table_only_collaborators - .iter() - .try_for_each(|(repo, did)| writeln!(f, "{repo} <- {did}"))?; - writeln!( - f, - "repos with no acl owner marker where the owner regains push: {}", - drift.markerless_owner_repos.len() - )?; - drift - .markerless_owner_repos - .iter() - .try_for_each(|repo| writeln!(f, "{repo}"))?; - writeln!( - f, - "orphan owner markers on unknown repos: {}", - drift.orphan_owner_markers.len() - )?; - writeln!( - f, - "repos recorded with different owners in the acl and repo_keys: {}", - drift.conflicting_owner_markers.len() - )?; - drift - .conflicting_owner_markers - .iter() - .try_for_each(|conflict| { - writeln!( + match drift.is_clean() && self.orphan_alias_count == 0 { + true => writeln!(f, "casbin cross-check: the acl and the tables agree")?, + false => { + writeln!(f, "casbin cross-check drift:")?; + listed( f, - "{} acl {}, repo_keys {}", - conflict.repo, conflict.acl_owner, conflict.key_owner - ) - })?; - writeln!( - f, - "extra acl owner markers dropped: {}", - drift.extra_owner_markers.len() - )?; - drift - .extra_owner_markers - .iter() - .try_for_each(|(repo, did)| writeln!(f, "{repo} <- {did}"))?; - writeln!( - f, - "orphan collaborator pairs on unknown repos: {}", - drift.orphan_collaborator_pairs.len() - )?; - writeln!( - f, - "acl-only members unioned in: {}", - drift.acl_only_members.len() - )?; - drift - .acl_only_members - .iter() - .try_for_each(|did| writeln!(f, "{did}"))?; - writeln!( - f, - "table-only members missing from acl: {}", - drift.table_only_members.len() - )?; - writeln!(f, "slash-form owner markers: {}", drift.slash_owner_markers)?; - writeln!( - f, - "slash-form collaborator rows: {}", - drift.slash_collab_rows - )?; - writeln!( - f, - "slash-resolved collaborator grants left out of the union: {}", - drift.slash_resolved_collaborators.len() - )?; - drift - .slash_resolved_collaborators - .iter() - .try_for_each(|(repo, did)| writeln!(f, "{repo} <- {did}"))?; - writeln!( - f, - "unresolved slash forms: {}", - drift.unresolved_slash_forms.len() - )?; - drift - .unresolved_slash_forms - .iter() - .try_for_each(|form| writeln!(f, "{form}"))?; - writeln!(f, "orphan aliases: {}", self.orphan_alias_count)?; + "acl-only collaborator grants unioned in", + drift.acl_only_collaborators.iter().map(GrantRow::from), + )?; + listed( + f, + "table-only collaborator grants missing from acl", + drift.table_only_collaborators.iter().map(GrantRow::from), + )?; + listed( + f, + "repos with no acl owner marker where the owner regains push", + drift.markerless_owner_repos.iter(), + )?; + counted( + f, + "orphan owner markers on unknown repos", + drift.orphan_owner_markers.len() as u64, + )?; + listed( + f, + "repos recorded with different owners in the acl and repo_keys", + drift.conflicting_owner_markers.iter(), + )?; + listed( + f, + "extra acl owner markers dropped", + drift.extra_owner_markers.iter().map(GrantRow::from), + )?; + counted( + f, + "orphan collaborator pairs on unknown repos", + drift.orphan_collaborator_pairs.len() as u64, + )?; + listed( + f, + "acl-only members unioned in", + drift.acl_only_members.iter(), + )?; + counted( + f, + "table-only members missing from acl", + drift.table_only_members.len() as u64, + )?; + counted(f, "slash-form owner markers", drift.slash_owner_markers)?; + counted(f, "slash-form collaborator rows", drift.slash_collab_rows)?; + listed( + f, + "slash-resolved collaborator grants left out of the union", + drift + .slash_resolved_collaborators + .iter() + .map(GrantRow::from), + )?; + listed( + f, + "unresolved slash forms", + drift.unresolved_slash_forms.iter(), + )?; + counted(f, "orphan aliases", self.orphan_alias_count)?; + } + } writeln!(f)?; writeln!(f, "skipped repos: {}", mapping.skipped.len())?; mapping.skipped.iter().try_for_each(|skip| { @@ -165,7 +133,7 @@ impl Display for Report<'_> { Ok(Occupancy::Fresh) => writeln!(f, "scan path: writable"), Ok(Occupancy::Occupied) => writeln!( f, - "scan path: writable, with repos already in it that the real run will keep" + "scan path: writable, with repos already in it that the migration will keep" ), Err(error) => writeln!(f, "scan path: {error}"), }?; @@ -183,7 +151,28 @@ impl Display for Report<'_> { ), }, Err(error) => writeln!(f, "room to copy: {error}"), - }) + })?; + match &rehearsal.host_key { + Ok(algorithm) => writeln!(f, "host key algorithm: {algorithm}"), + Err(error) => writeln!(f, "host key: {error}"), + }?; + match &rehearsal.host_key_target { + None | Some(Ok(HostKeyPlacement::Fresh)) => Ok(()), + Some(Ok(HostKeyPlacement::Unchanged)) => { + writeln!(f, "host key: the target already has the imported key") + } + Some(Ok(HostKeyPlacement::Replacing)) => { + writeln!( + f, + "host key: the migration will replace the different key at the target" + ) + } + Some(Err(error)) => writeln!(f, "host key: {error}"), + }?; + match &rehearsal.master_key { + Ok(env) => writeln!(f, "master key: {env} decodes to a usable key"), + Err(error) => writeln!(f, "{error}"), + } } Phase::Written { adoption, cobs } => { writeln!(f)?; @@ -217,6 +206,36 @@ impl Display for Report<'_> { } } +struct GrantRow<'a>(&'a SourceRepoDid, &'a SourceDid); + +impl<'a> From<&'a (SourceRepoDid, SourceDid)> for GrantRow<'a> { + fn from((repo, did): &'a (SourceRepoDid, SourceDid)) -> Self { + Self(repo, did) + } +} + +impl Display for GrantRow<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{} <- {}", self.0, self.1) + } +} + +fn counted(f: &mut Formatter<'_>, label: &str, count: u64) -> fmt::Result { + match count { + 0 => Ok(()), + count => writeln!(f, "{label}: {count}"), + } +} + +fn listed( + f: &mut Formatter<'_>, + label: &str, + mut rows: impl ExactSizeIterator, +) -> fmt::Result { + counted(f, label, rows.len() as u64)?; + rows.try_for_each(|row| writeln!(f, "{row}")) +} + fn describe(reason: &SkipReason) -> String { match reason { SkipReason::Name { value } => format!("unrepresentable name {:?}", value.as_str()), diff --git a/knot2/crates/knot-migrate/tests/migrate.rs b/knot2/crates/knot-migrate/tests/migrate.rs index 7c48ec35..70421d6a 100644 --- a/knot2/crates/knot-migrate/tests/migrate.rs +++ b/knot2/crates/knot-migrate/tests/migrate.rs @@ -722,6 +722,36 @@ fn host_key_import_preserves_every_algorithm() { }); } +#[test] +fn the_public_half_of_a_host_key_is_refused_with_its_own_error() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("ssh_host_ed25519_key.pub"); + let public = ssh_key::PrivateKey::from_openssh(HOST_KEY) + .unwrap() + .public_key() + .to_openssh() + .unwrap(); + std::fs::write(&source, format!("{public}\n")).unwrap(); + match emit::load_host_key(&source) { + Err(error @ emit::EmitError::PublicHostKey { .. }) => assert!( + error.to_string().contains("without the .pub"), + "the refusal has to point at the private half: {error}" + ), + other => panic!( + "a public key mustn't read as a corrupt private key: {:?}", + other.err() + ), + } + std::fs::write(&source, "not a key of any kind\n").unwrap(); + match emit::load_host_key(&source) { + Err(emit::EmitError::HostKey { .. }) => {} + other => panic!( + "a file that isn't a key of either kind is still a parse failure: {:?}", + other.err() + ), + } +} + fn honors_permission_bits(dir: &Path) -> bool { use std::os::unix::fs::PermissionsExt; let probe = dir.join("permission-probe"); @@ -908,6 +938,57 @@ fn two_owners_for_one_repo_are_drift_that_the_report_can_render() { ); } +#[test] +fn a_report_names_drift_only_where_there_is_drift() { + let render = |drift, orphan_alias_count| { + let mapping = mapping::Mapping { + knot_owner: AccountDid::new("did:plc:akshay").unwrap(), + members: Vec::new(), + repos: Vec::new(), + skipped: Vec::new(), + drift, + }; + report::Report { + mapping: &mapping, + orphan_alias_count, + phase: report::Phase::Refused, + } + .to_string() + }; + + let agreed = render(mapping::Drift::default(), 0); + assert!( + agreed.contains("casbin cross-check: the acl and the tables agree"), + "{agreed}" + ); + assert!( + !agreed.contains("drift"), + "thirteen zeroes are what an operator has to read past to find the one line that matters: \ + {agreed}" + ); + + let drifted = render( + mapping::Drift { + slash_owner_markers: 2, + ..mapping::Drift::default() + }, + 0, + ); + assert!(drifted.contains("casbin cross-check drift:"), "{drifted}"); + assert!(drifted.contains("slash-form owner markers: 2"), "{drifted}"); + assert!( + !drifted.contains("acl-only members unioned in"), + "{drifted}" + ); + + let orphaned = render(mapping::Drift::default(), 3); + assert!(orphaned.contains("orphan aliases: 3"), "{orphaned}"); + assert!( + !orphaned.contains("the acl and the tables agree"), + "orphan aliases are a reconciliation the operator still owes: {orphaned}" + ); +} + #[test] fn an_owner_marker_beside_the_repo_keys_owner_is_still_an_extra() { let mapping = map(&fixture(true)); @@ -942,6 +1023,10 @@ fn rehearse_adopting( adopted, scan_path, policy, + host_key: None, + host_key_target: &scan_path.with_file_name("ssh_host_key"), + host_key_policy: emit::HostKeyPolicy::Keep, + master_key: &master_key_env(), }) } @@ -954,7 +1039,7 @@ fn a_rehearsal_plans_its_transfer_and_probes_the_path_that_the_real_run_will_cre assert_eq!( missing.fallback.as_deref(), fx.target.parent(), - "the real run will create the scan path, so the filesystem checks use the deepest path \ + "the migration will create the scan path, so the filesystem checks use the deepest path \ that exists now" ); @@ -985,7 +1070,7 @@ fn a_rehearsal_plans_its_transfer_and_probes_the_path_that_the_real_run_will_cre assert_eq!( relative.fallback.as_deref(), Some(Path::new(".")), - "probing / instead would answer for a filesystem that the real run never touches" + "probing / instead would answer for a filesystem that the migration never touches" ); } @@ -1094,7 +1179,7 @@ fn a_scan_path_that_the_real_run_cannot_reach_is_refused_whichever_user_runs_it( looped.scan_path, Err(rehearse::ScanPathError::Unwritable { .. }) ), - "a path that this process can't examine mustn't read as a path that the real run will \ + "a path that this process can't examine mustn't read as a path that the migration will \ create: {:?}", looped.scan_path ); @@ -1109,12 +1194,12 @@ fn a_scan_path_that_the_real_run_cannot_reach_is_refused_whichever_user_runs_it( Err(rehearse::ScanPathError::Dangling { .. }) ), "std::fs::create_dir_all refuses a symlink to a missing target with AlreadyExists, so the \ - rehearsal mustn't read it as a path that the real run will create: {:?}", + rehearsal mustn't read it as a path that the migration will create: {:?}", dangling.scan_path ); assert_eq!( dangling.fallback, None, - "the symlink itself is what the real run fails on, so the checks stay on it and don't step \ + "the symlink itself is what the migration fails on, so the checks stay on it and don't step \ up to its parent" ); [under_a_file, looped, dangling] @@ -1151,7 +1236,7 @@ fn a_scan_path_that_this_process_cannot_write_is_refused() { uncreatable.scan_path, Err(rehearse::ScanPathError::Uncreatable { .. }) ), - "the real run will create the scan path, so an unwritable ancestor stops it: {:?}", + "the migration will create the scan path, so an unwritable ancestor stops it: {:?}", uncreatable.scan_path ); assert!( @@ -1166,6 +1251,14 @@ fn a_scan_path_that_this_process_cannot_write_is_refused() { assert!(!uncreatable.ready() && !unwritable.ready()); } +fn master_key_env() -> MasterKeyEnv { + MasterKeyEnv::new("KNOT_MASTER_KEY").unwrap() +} + +fn unset_master_key_env() -> MasterKeyEnv { + MasterKeyEnv::new("KNOT_MASTER_KEY_THAT_NOBODY_SETS").unwrap() +} + fn ready_rehearsal() -> Rehearsal { Rehearsal { fallback: None, @@ -1175,6 +1268,9 @@ fn ready_rehearsal() -> Rehearsal { source: rehearse::Bytes::new(1), free: rehearse::Bytes::new(2), })), + host_key: Ok(ssh_key::Algorithm::Ed25519), + host_key_target: Some(Ok(emit::HostKeyPlacement::Fresh)), + master_key: Ok(master_key_env()), } } @@ -1199,6 +1295,383 @@ fn a_rehearsal_is_ready_only_once_the_copy_has_room() { ); } +#[test] +fn a_rehearsal_reads_a_usable_host_key_and_reports_an_unusable_file() { + let dir = tempfile::tempdir().unwrap(); + let target_key = dir.path().join("knot/ssh_host_key"); + let rehearse_host_key = |path: PathBuf| { + Rehearsal::run(rehearse::Inputs { + source_repos: dir.path(), + adopted: &[], + scan_path: &dir.path().join("knot/repos"), + policy: adopt::SourcePolicy::Preserve, + host_key: Some(&path), + host_key_target: &target_key, + host_key_policy: emit::HostKeyPolicy::Keep, + master_key: &master_key_env(), + }) + }; + assert!(matches!( + rehearse_host_key(host_key_file(dir.path())).host_key, + Ok(ssh_key::Algorithm::Ed25519) + )); + let missing = rehearse_host_key(dir.path().join("no-such-key")); + assert!(matches!( + missing.host_key, + Err(rehearse::HostKeyError::Unusable(_)) + )); + assert!( + missing.transfer.is_ok() && missing.scan_path.is_ok(), + "one unreadable input mustn't take the other checks down with it" + ); + assert!(!missing.ready()); +} + +#[test] +fn a_host_key_already_at_the_target_is_kept_until_the_switchover_is_forced() { + let dir = tempfile::tempdir().unwrap(); + let source = host_key_file(dir.path()); + let target = dir.path().join("target"); + std::fs::create_dir_all(&target).unwrap(); + let destination = target.join("ssh_host_key"); + let rehearse = |policy| { + Rehearsal::run(rehearse::Inputs { + source_repos: dir.path(), + adopted: &[], + scan_path: &target.join("repos"), + policy: adopt::SourcePolicy::Preserve, + host_key: Some(&source), + host_key_target: &destination, + host_key_policy: policy, + master_key: &master_key_env(), + }) + }; + + assert!(matches!( + rehearse(emit::HostKeyPolicy::Keep).host_key_target, + Some(Ok(emit::HostKeyPlacement::Fresh)) + )); + + emit::load_host_key(&source) + .unwrap() + .write_to(&destination) + .unwrap(); + assert!(matches!( + rehearse(emit::HostKeyPolicy::Keep).host_key_target, + Some(Ok(emit::HostKeyPlacement::Unchanged)), + )); + + std::fs::write(&destination, ECDSA_HOST_KEY).unwrap(); + let clash = rehearse(emit::HostKeyPolicy::Keep); + assert!( + matches!( + clash.host_key_target, + Some(Err(emit::HostKeyConflict::Different { .. })) + ), + "a knot that started before the switchover leaves a key of its own" + ); + assert!( + clash.transfer.is_ok() && clash.scan_path.is_ok(), + "the conflict mustn't take the other checks down with it" + ); + assert!(matches!( + rehearse(emit::HostKeyPolicy::Replace).host_key_target, + Some(Ok(emit::HostKeyPlacement::Replacing)) + )); + + std::fs::write(&destination, b"whatever this file is, it isn't a key").unwrap(); + assert!( + matches!( + rehearse(emit::HostKeyPolicy::Keep).host_key_target, + Some(Err(emit::HostKeyConflict::Unparsable { .. })) + ), + "a file that doesn't parse here might still be the key the old knot is serving" + ); + assert!( + matches!( + rehearse(emit::HostKeyPolicy::Replace).host_key_target, + Some(Ok(emit::HostKeyPlacement::Replacing)) + ), + "the file the migration can't read is the one an operator most wants gone" + ); + + std::fs::remove_file(&destination).unwrap(); + std::os::unix::fs::symlink(&source, &destination).unwrap(); + let dangling = target.join("dangling"); + std::os::unix::fs::symlink(target.join("gone"), &dangling).unwrap(); + [ + (emit::HostKeyPolicy::Keep, &destination), + (emit::HostKeyPolicy::Replace, &destination), + (emit::HostKeyPolicy::Replace, &dangling), + ] + .into_iter() + .for_each(|(policy, path)| { + assert!( + matches!( + Rehearsal::run(rehearse::Inputs { + source_repos: dir.path(), + adopted: &[], + scan_path: &target.join("repos"), + policy: adopt::SourcePolicy::Preserve, + host_key: Some(&source), + host_key_target: path, + host_key_policy: policy, + master_key: &master_key_env(), + }) + .host_key_target, + Some(Err(emit::HostKeyConflict::NotAFile { .. })) + ), + "OpenOptions::open follows a symlink, so the key would go somewhere the operator \ + never named: {policy:?} {path:?}" + ); + }); + + std::fs::remove_file(&destination).unwrap(); + std::fs::create_dir(&destination).unwrap(); + assert!( + matches!( + rehearse(emit::HostKeyPolicy::Replace).host_key_target, + Some(Err(emit::HostKeyConflict::NotAFile { .. })) + ), + "a directory would fail at write time, long after adoption has moved every repo" + ); + std::fs::remove_dir(&destination).unwrap(); + + assert!( + !Rehearsal { + host_key_target: Some(Err(emit::HostKeyConflict::Different { + path: destination, + fingerprints: Box::new(emit::Fingerprints { + found: emit::load_host_key(&source).unwrap().fingerprint, + importing: emit::load_host_key(&source).unwrap().fingerprint, + }), + })), + ..ready_rehearsal() + } + .ready() + ); + assert!( + Rehearsal { + host_key_target: Some(Ok(emit::HostKeyPlacement::Replacing)), + ..ready_rehearsal() + } + .ready() + ); +} + +#[test] +fn a_host_key_target_this_process_cannot_write_is_refused_before_adoption() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + if !honors_permission_bits(dir.path()) { + return; + } + let key = emit::load_host_key(&host_key_file(dir.path())).unwrap(); + let target = dir.path().join("target"); + std::fs::create_dir(&target).unwrap(); + let destination = target.join("ssh_host_key"); + std::fs::write(&destination, ECDSA_HOST_KEY).unwrap(); + let chmod = |path: &Path, mode| { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap() + }; + + chmod(&destination, 0o000); + assert!( + matches!( + emit::plan_host_key(&destination, &key, emit::HostKeyPolicy::Keep), + Err(emit::HostKeyConflict::Unreadable { .. }) + ), + "a key this process can't open is not a key it has read and found unparsable" + ); + assert!( + matches!( + emit::plan_host_key(&destination, &key, emit::HostKeyPolicy::Replace), + Err(emit::HostKeyConflict::Unwritable { .. }) + ), + "forcing this would otherwise fail at write time, long after adoption has moved every repo" + ); + + chmod(&destination, 0o200); + assert!( + matches!( + emit::plan_host_key(&destination, &key, emit::HostKeyPolicy::Replace), + Ok(emit::HostKeyPlacement::Replacing) + ), + "a file this process can write is one that --force-host-key can still land on" + ); + chmod(&destination, 0o600); + + let sealed = dir.path().join("sealed"); + std::fs::create_dir(&sealed).unwrap(); + chmod(&sealed, 0o500); + assert!( + matches!( + emit::plan_host_key(&sealed.join("ssh_host_key"), &key, emit::HostKeyPolicy::Keep), + Err(emit::HostKeyConflict::Uncreatable { .. }) + ), + "an absent key under a directory nobody can write is a write that fails after adoption" + ); + chmod(&sealed, 0o700); + + assert!( + matches!( + emit::plan_host_key( + &dir.path().join("nothing/here/ssh_host_key"), + &key, + emit::HostKeyPolicy::Keep + ), + Ok(emit::HostKeyPlacement::Fresh) + ), + "a target directory that doesn't exist yet belongs to create_dir_all, which runs seconds \ + later and long before adoption" + ); +} + +#[test] +fn a_symlink_where_a_private_file_goes_is_never_followed() { + let dir = tempfile::tempdir().unwrap(); + let key = emit::load_host_key(&host_key_file(dir.path())).unwrap(); + let decoy = dir.path().join("decoy"); + let archive = dir.path().join("repo-signing-keys.json"); + let planted = dir.path().join("ssh_host_key"); + std::fs::write(&decoy, "untouched").unwrap(); + std::os::unix::fs::symlink(&decoy, &archive).unwrap(); + std::os::unix::fs::symlink(&decoy, &planted).unwrap(); + + assert!( + emit::write_key_archive(&archive, &[]).is_err(), + "every repo's signing key would land wherever the symlink points, and chmod 0600 would \ + dress up the wrong file" + ); + assert!( + key.write_to(&planted).is_err(), + "plan_host_key refuses a symlink long before the write, which is check-then-use unless the \ + open refuses it too" + ); + assert_eq!(std::fs::read_to_string(&decoy).unwrap(), "untouched"); +} + +#[test] +fn a_rehearsal_report_names_what_the_key_at_the_target_costs() { + let mapping = mapping::Mapping { + knot_owner: AccountDid::new("did:plc:akshay").unwrap(), + members: Vec::new(), + repos: Vec::new(), + skipped: Vec::new(), + drift: mapping::Drift::default(), + }; + let render = |host_key_target: Option>| { + let rehearsal = Rehearsal { + host_key_target, + ..ready_rehearsal() + }; + report::Report { + mapping: &mapping, + orphan_alias_count: 0, + phase: report::Phase::Rehearsed(&rehearsal), + } + .to_string() + }; + + [None, Some(Ok(emit::HostKeyPlacement::Fresh))] + .into_iter() + .for_each(|quiet| { + let rendered = render(quiet); + assert!( + rendered.contains("host key algorithm: ") && !rendered.contains("host key: "), + "a target with no key on it costs the operator nothing to read: {rendered}" + ); + }); + + let unchanged = render(Some(Ok(emit::HostKeyPlacement::Unchanged))); + assert!( + unchanged.contains("host key: the target already has the imported key"), + "{unchanged}" + ); + + let replacing = render(Some(Ok(emit::HostKeyPlacement::Replacing))); + assert!( + replacing + .contains("host key: the migration will replace the different key at the target"), + "{replacing}" + ); + + let unwritable = render(Some(Err(emit::HostKeyConflict::Unwritable { + path: PathBuf::from("/srv/knot/ssh_host_key"), + source: rustix::io::Errno::ACCESS, + }))); + assert!( + unwritable.contains("host key: the migration will write the host key over \ + /srv/knot/ssh_host_key, which this process can't write"), + "{unwritable}" + ); + + let not_a_file = render(Some(Err(emit::HostKeyConflict::NotAFile { + path: PathBuf::from("/srv/knot/ssh_host_key"), + }))); + assert!( + not_a_file.contains("host key: /srv/knot/ssh_host_key isn't a regular file"), + "{not_a_file}" + ); +} + +#[test] +fn a_rehearsal_states_the_inputs_that_the_real_run_still_needs() { + let fx = fixture(true); + let mapping = map(&fx); + let rehearsal = Rehearsal::run(rehearse::Inputs { + source_repos: &fx.source_repos, + adopted: &mapping.repos, + scan_path: &fx.target.join("repos"), + policy: adopt::SourcePolicy::Preserve, + host_key: None, + host_key_target: &fx.target.join("ssh_host_key"), + host_key_policy: emit::HostKeyPolicy::Keep, + master_key: &unset_master_key_env(), + }); + assert!(!rehearsal.ready()); + let rendered = report::Report { + mapping: &mapping, + orphan_alias_count: 0, + phase: report::Phase::Rehearsed(&rehearsal), + } + .to_string(); + assert!(rendered.contains("transfer mode: copy"), "{rendered}"); + assert!(rendered.contains("scan path: writable"), "{rendered}"); + assert!(rendered.contains("room to copy: "), "{rendered}"); + assert!( + rendered.contains(", since the scan path doesn't exist yet"), + "{rendered}" + ); + assert!( + rendered.contains("host key: --host-key is required for the migration"), + "{rendered}" + ); + assert!( + rendered.contains("master key env var KNOT_MASTER_KEY_THAT_NOBODY_SETS isn't set"), + "{rendered}" + ); +} + +#[test] +fn a_master_key_reads_from_its_env_var_and_refuses_a_weak_or_malformed_value() { + assert!(matches!( + unset_master_key_env().read(), + Err(emit::MasterKeyError::Unset(_)) + )); + let padded = format!(" {}\n", base64_standard(&[7_u8; 32])); + assert!(master_key_env().decode(&padded).is_ok()); + let short = master_key_env().decode(&base64_standard(&[7_u8; 31])); + assert!( + matches!(short, Err(emit::MasterKeyError::Weak { .. })), + "{short:?}" + ); + assert!(matches!( + master_key_env().decode("not base64 at all!"), + Err(emit::MasterKeyError::NotBase64(_)) + )); +} + fn base64_standard(bytes: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::STANDARD.encode(bytes) @@ -1244,6 +1717,56 @@ fn with_unreadable_repo(fx: &Fixture, work: impl FnOnce() -> T) -> Option Some(outcome) } +#[test] +fn a_real_run_refuses_the_key_at_the_target_before_it_touches_a_repo() { + let fx = fixture(true); + let dir = tempfile::tempdir().unwrap(); + let host_key = host_key_file(dir.path()); + std::fs::create_dir_all(&fx.target).unwrap(); + let destination = fx.target.join("ssh_host_key"); + std::fs::write(&destination, ECDSA_HOST_KEY).unwrap(); + + let importing = emit::load_host_key(&host_key).unwrap().fingerprint; + let found = emit::load_host_key(&destination).unwrap().fingerprint; + + let refused = run_migrate(&fx, &host_key, &[]); + let refusal = String::from_utf8_lossy(&refused.stderr).to_string(); + assert!(!refused.status.success(), "{refusal}"); + assert!(refusal.contains("your users already trust"), "{refusal}"); + assert!( + refusal.contains(&format!("whose fingerprint {found} your users already trust")) + && refusal.contains(&importing.to_string()), + "an operator deciding whether to force needs both fingerprints, not the word: {refusal}" + ); + assert!( + !fx.target.join("repos").exists() && !fx.target.join("sealed-keys").exists(), + "the refusal has to come before adoption, which moves every repo" + ); + assert_eq!( + std::fs::read_to_string(&destination).unwrap(), + ECDSA_HOST_KEY, + "a refused run mustn't touch the key it refused" + ); + + let forced = run_migrate(&fx, &host_key, &["--force-host-key"]); + let stdout = String::from_utf8_lossy(&forced.stdout).to_string(); + let stderr = String::from_utf8_lossy(&forced.stderr).to_string(); + assert!(forced.status.success(), "{stdout}{stderr}"); + assert_eq!( + std::fs::read(&destination).unwrap(), + std::fs::read(&host_key).unwrap(), + "--force-host-key has to leave the imported key at the target" + ); + assert!( + stdout.contains("host key: the migration replaced the different key at the target"), + "an operator who forced the switchover has to read what it cost: {stdout}" + ); + assert!( + stdout.contains(&format!("host key fingerprint: {importing}")), + "the fingerprint everybody has to trust now is the one worth printing: {stdout}" + ); +} + #[test] fn a_real_run_refuses_an_unreadable_source_until_it_is_told_to_skip_it() { let fx = fixture(true); @@ -1354,12 +1877,19 @@ fn consuming_a_source_that_this_process_cannot_write_is_refused() { if !honors_permission_bits(&fx.source_repos) { return; } + let dir = tempfile::tempdir().unwrap(); + let host_key = host_key_file(dir.path()); std::fs::set_permissions(&fx.source_repos, std::fs::Permissions::from_mode(0o555)).unwrap(); - let rehearsal = rehearse_scan_path( - &fx.source_repos, - &fx.target.join("repos"), - adopt::SourcePolicy::Consume, - ); + let rehearsal = Rehearsal::run(rehearse::Inputs { + source_repos: &fx.source_repos, + adopted: &[], + scan_path: &fx.target.join("repos"), + policy: adopt::SourcePolicy::Consume, + host_key: Some(&host_key), + host_key_target: &fx.target.join("ssh_host_key"), + host_key_policy: emit::HostKeyPolicy::Keep, + master_key: &master_key_env(), + }); let preserving = rehearse_scan_path( &fx.source_repos, &fx.target.join("repos"), -- 2.51.2