From fc4ac1760463518d6578f1149da52cbf71d17b57 Mon Sep 17 00:00:00 2001 From: Lewis Date: Tue, 11 Aug 2026 16:38:24 +0300 Subject: [PATCH] knot2/knot-migrate: rehearse the transfer, scan path & room on --dry-run Lewis: May this revision serve well! --- knot2/crates/knot-migrate/src/adopt.rs | 40 ++- knot2/crates/knot-migrate/src/lib.rs | 1 + knot2/crates/knot-migrate/src/main.rs | 85 ++++-- knot2/crates/knot-migrate/src/rehearse.rs | 204 +++++++++++++ knot2/crates/knot-migrate/src/report.rs | 111 +++++-- knot2/crates/knot-migrate/tests/migrate.rs | 337 ++++++++++++++++++++- 6 files changed, 709 insertions(+), 69 deletions(-) create mode 100644 knot2/crates/knot-migrate/src/rehearse.rs diff --git a/knot2/crates/knot-migrate/src/adopt.rs b/knot2/crates/knot-migrate/src/adopt.rs index bcc571e63..8dd8ae745 100644 --- a/knot2/crates/knot-migrate/src/adopt.rs +++ b/knot2/crates/knot-migrate/src/adopt.rs @@ -29,6 +29,13 @@ pub enum AdoptError { Vanished { repo: RepoDid }, #[error("consuming the source needs {scan_path} and {target} on one filesystem")] CrossDeviceConsume { scan_path: PathBuf, target: PathBuf }, + #[error( + "consuming the source will move the repos out of {scan_path}, which this process can't write: {source}" + )] + UnwritableSource { + scan_path: PathBuf, + source: std::io::Error, + }, } #[derive(Debug, PartialEq, Eq)] @@ -145,7 +152,7 @@ pub fn adopt_all( }) } -fn transfer_mode( +pub fn transfer_mode( source_root: &Path, target_root: &Path, policy: SourcePolicy, @@ -159,17 +166,32 @@ fn transfer_mode( source, }) }; - let one_filesystem = device(source_root)? == device(target_root)?; - match (policy, one_filesystem) { - (SourcePolicy::Consume, true) => Ok(Transfer::Rename), - (SourcePolicy::Consume, false) => Err(AdoptError::CrossDeviceConsume { - scan_path: source_root.to_path_buf(), - target: target_root.to_path_buf(), - }), - (SourcePolicy::Preserve, _) => Ok(Transfer::Copy), + match policy { + SourcePolicy::Preserve => Ok(Transfer::Copy), + SourcePolicy::Consume => match device(source_root)? == device(target_root)? { + true => writable(source_root) + .map(|()| Transfer::Rename) + .map_err(|source| AdoptError::UnwritableSource { + scan_path: source_root.to_path_buf(), + source: source.into(), + }), + false => Err(AdoptError::CrossDeviceConsume { + scan_path: source_root.to_path_buf(), + target: target_root.to_path_buf(), + }), + }, } } +pub fn writable(path: &Path) -> Result<(), rustix::io::Errno> { + rustix::fs::accessat( + rustix::fs::CWD, + path, + rustix::fs::Access::WRITE_OK | rustix::fs::Access::EXEC_OK, + rustix::fs::AtFlags::EACCESS, + ) +} + fn in_lanes<'items, T, R, F>(items: &'items [T], work: F) -> Result, AdoptError> where T: Sync, diff --git a/knot2/crates/knot-migrate/src/lib.rs b/knot2/crates/knot-migrate/src/lib.rs index c3248eec1..c89d3ba79 100644 --- a/knot2/crates/knot-migrate/src/lib.rs +++ b/knot2/crates/knot-migrate/src/lib.rs @@ -3,5 +3,6 @@ pub mod casbin; pub mod emit; pub mod envfile; pub mod mapping; +pub mod rehearse; pub mod report; pub mod source; diff --git a/knot2/crates/knot-migrate/src/main.rs b/knot2/crates/knot-migrate/src/main.rs index 7dc52e926..fc717c6a8 100644 --- a/knot2/crates/knot-migrate/src/main.rs +++ b/knot2/crates/knot-migrate/src/main.rs @@ -8,7 +8,8 @@ use knot_migrate::casbin::{self, CasbinError}; use knot_migrate::emit::{self, ConfigValues, EmitError, MasterKeyEnv}; use knot_migrate::envfile::{EnvFile, EnvFileError}; use knot_migrate::mapping::{self, Mapping, MappingError, RepoList}; -use knot_migrate::report::Report; +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}; @@ -37,7 +38,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 - --dry-run print the mapping and reconciliation report, write nothing + --dry-run print the mapping and reconciliation report, leave the target + alone, and exit non-zero while an input is still missing "; #[derive(Debug, thiserror::Error)] @@ -70,6 +72,8 @@ enum MigrateError { MalformedMasterKey { name: MasterKeyEnv }, #[error("{0}")] Refused(Refusals), + #[error("the rehearsal lists what the real run still needs")] + RehearsalIncomplete, #[error("{context}: {source}")] Io { context: String, @@ -375,39 +379,68 @@ fn run(args: &[String]) -> Result<(), MigrateError> { let orphan_alias_count = db.orphan_alias_count()?; let refusals = Refusals::gather(&mapping, args.skip_unreadable); - let written = match (args.dry_run, refusals.is_empty()) { - (false, true) => Some(materialize( - &args, - &hostname, - &plc_directory, - &source_repos, - &mapping, - )?), - _ => None, - }; - print!( - "{}", - Report { - mapping: &mapping, - orphan_alias_count, - adoption: written.as_ref().map(|written| &written.adoption), - cobs: written.as_ref().map(|written| &written.cobs), + match (args.dry_run, refusals.is_empty()) { + (true, ready) => { + let rehearsal = timed("rehearsal", || { + Rehearsal::run(rehearse::Inputs { + source_repos: &source_repos, + adopted: &mapping.repos, + scan_path: &repos_dir(&args.target), + policy: args.source_policy, + }) + }); + report(&mapping, orphan_alias_count, Phase::Rehearsed(&rehearsal)); + match ready { + false => Err(MigrateError::Refused(refusals)), + true => rehearsal + .ready() + .then(|| { + println!(); + println!("we left the target alone. Re-run without --dry-run to migrate."); + }) + .ok_or(MigrateError::RehearsalIncomplete), + } } - ); - match refusals.is_empty() { - false => Err(MigrateError::Refused(refusals)), - true => written.map_or(Ok(()), |written| { + (false, false) => { + report(&mapping, orphan_alias_count, Phase::Refused); + Err(MigrateError::Refused(refusals)) + } + (false, true) => { + let written = materialize(&args, &hostname, &plc_directory, &source_repos, &mapping)?; + report( + &mapping, + orphan_alias_count, + Phase::Written { + adoption: &written.adoption, + cobs: &written.cobs, + }, + ); println!(); println!("knot key identity: {}", written.knot_did); println!("host key algorithm: {}", written.host_key_algorithm); println!("config: {}", written.config_file.display()); println!("key archive: {}", written.archive_file.display()); Ok(()) - }), + } } } -fn timed(phase: &str, work: impl FnOnce() -> Result) -> Result { +fn repos_dir(target: &Path) -> PathBuf { + target.join("repos") +} + +fn report<'a>(mapping: &'a Mapping, orphan_alias_count: u64, phase: Phase<'a>) { + print!( + "{}", + Report { + mapping, + orphan_alias_count, + phase, + } + ); +} + +fn timed(phase: &str, work: impl FnOnce() -> T) -> T { let started = std::time::Instant::now(); let outcome = work(); eprintln!("{phase}: {:.1}s", started.elapsed().as_secs_f64()); @@ -446,7 +479,7 @@ fn materialize( context: format!("canonicalize {}", args.target.display()), source, })?; - let scan_path = target.join("repos"); + let scan_path = repos_dir(&target); let sealed_key_file = target.join("sealed-keys"); let host_key_file = target.join("ssh_host_key"); let archive_file = target.join("repo-signing-keys.json"); diff --git a/knot2/crates/knot-migrate/src/rehearse.rs b/knot2/crates/knot-migrate/src/rehearse.rs new file mode 100644 index 000000000..56bac35a6 --- /dev/null +++ b/knot2/crates/knot-migrate/src/rehearse.rs @@ -0,0 +1,204 @@ +use std::path::{Path, PathBuf}; + +use knot_types::scalar_newtype; + +use crate::adopt::{self, AdoptError, SourcePolicy, Transfer}; +use crate::mapping::AdoptRepo; + +#[derive(Debug, thiserror::Error)] +pub enum ScanPathError { + #[error( + "the real run 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}")] + Unwritable { + path: PathBuf, + source: rustix::io::Errno, + }, + #[error("the real run can't create {path}, which is a symlink to a missing target")] + Dangling { path: PathBuf }, + #[error("read {path}: {source}")] + Unreadable { + path: PathBuf, + source: std::io::Error, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum RoomError { + #[error("measure {path}: {source}")] + Source { + path: PathBuf, + source: std::io::Error, + }, + #[error("read the free space on {path}: {source}")] + Free { + path: PathBuf, + source: rustix::io::Errno, + }, +} + +scalar_newtype! { + pub struct Bytes(u64) => ordered; +} + +impl std::fmt::Display for Bytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + const UNITS: [(&str, u64); 3] = [("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)]; + match UNITS.iter().find(|(_, size)| self.get() >= *size) { + None => write!(f, "{}B", self.get()), + Some((unit, size)) => write!(f, "{:.1}{unit}", self.get() as f64 / *size as f64), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Occupancy { + Fresh, + Occupied, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fit { + Short, + Clear, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Room { + pub source: Bytes, + pub free: Bytes, +} + +impl Room { + pub fn fit(self) -> Fit { + match self.free >= self.source { + true => Fit::Clear, + false => Fit::Short, + } + } +} + +pub struct Inputs<'a> { + pub source_repos: &'a Path, + pub adopted: &'a [AdoptRepo], + pub scan_path: &'a Path, + pub policy: SourcePolicy, +} + +pub struct Rehearsal { + pub fallback: Option, + pub transfer: Result, + pub scan_path: Result, + pub room: Option>, +} + +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); + Self { + fallback: probed.fallback().map(Path::to_path_buf), + room: match transfer { + Ok(Transfer::Copy) => Some(room(inputs.source_repos, inputs.adopted, probed)), + Ok(Transfer::Rename) | Err(_) => None, + }, + transfer, + scan_path: occupancy(probed), + } + } + + pub fn ready(&self) -> bool { + self.transfer.is_ok() + && self.scan_path.is_ok() + && self.room.as_ref().is_none_or(|room| { + room.as_ref() + .is_ok_and(|measured| matches!(measured.fit(), Fit::Clear)) + }) + } +} + +#[derive(Debug, Clone, Copy)] +struct Probed<'a> { + scan_path: &'a Path, + existing: &'a Path, +} + +impl<'a> Probed<'a> { + fn nearest(scan_path: &'a Path) -> Self { + Self { + scan_path, + existing: scan_path + .ancestors() + .find(|candidate| match std::fs::symlink_metadata(candidate) { + Ok(_) => true, + Err(error) => !adopt::reads_as_absent(&error), + }) + .unwrap_or_else(|| Path::new(".")), + } + } + + fn fallback(self) -> Option<&'a Path> { + (self.existing != self.scan_path).then_some(self.existing) + } +} + +fn occupancy(probed: Probed<'_>) -> Result { + match (adopt::writable(probed.existing), probed.fallback()) { + (Err(source), None) if source == rustix::io::Errno::NOENT => Err(ScanPathError::Dangling { + path: probed.scan_path.to_path_buf(), + }), + (Err(source), None) => Err(ScanPathError::Unwritable { + path: probed.scan_path.to_path_buf(), + source, + }), + (Err(source), Some(blocked)) => Err(ScanPathError::Uncreatable { + path: probed.scan_path.to_path_buf(), + blocked: blocked.to_path_buf(), + source, + }), + (Ok(()), Some(_)) => Ok(Occupancy::Fresh), + (Ok(()), None) => std::fs::read_dir(probed.scan_path) + .and_then(|mut entries| entries.next().transpose()) + .map(|entry| match entry { + Some(_) => Occupancy::Occupied, + None => Occupancy::Fresh, + }) + .map_err(|source| ScanPathError::Unreadable { + path: probed.scan_path.to_path_buf(), + source, + }), + } +} + +fn room(source_repos: &Path, adopted: &[AdoptRepo], probed: Probed<'_>) -> Result { + use std::os::unix::fs::MetadataExt; + let source = adopted.iter().try_fold(0_u64, |total, repo| { + let path = adopt::source_dir(source_repos, &repo.source_did); + walkdir::WalkDir::new(&path) + .into_iter() + .try_fold(total, |total, entry| { + entry + .and_then(|entry| entry.metadata()) + .map(|meta| total + meta.blocks() * 512) + }) + .map_err(|source| RoomError::Source { + path, + source: source.into(), + }) + })?; + rustix::fs::statvfs(probed.existing) + .map(|stat| Room { + source: Bytes::new(source), + free: Bytes::new(stat.f_bavail * stat.f_frsize), + }) + .map_err(|source| RoomError::Free { + path: probed.existing.to_path_buf(), + source, + }) +} diff --git a/knot2/crates/knot-migrate/src/report.rs b/knot2/crates/knot-migrate/src/report.rs index 08fa2417e..06f53188e 100644 --- a/knot2/crates/knot-migrate/src/report.rs +++ b/knot2/crates/knot-migrate/src/report.rs @@ -3,12 +3,21 @@ use std::fmt::{self, Display, Formatter}; use crate::adopt::AdoptOutcome; use crate::emit::CobSummary; use crate::mapping::{Mapping, SkipReason}; +use crate::rehearse::{Fit, Occupancy, Rehearsal}; pub struct Report<'a> { pub mapping: &'a Mapping, pub orphan_alias_count: u64, - pub adoption: Option<&'a AdoptOutcome>, - pub cobs: Option<&'a CobSummary>, + pub phase: Phase<'a>, +} + +pub enum Phase<'a> { + Refused, + Rehearsed(&'a Rehearsal), + Written { + adoption: &'a AdoptOutcome, + cobs: &'a CobSummary, + }, } impl Display for Report<'_> { @@ -137,36 +146,74 @@ impl Display for Report<'_> { .iter() .try_for_each(|did| writeln!(f, "drops collaborator grant for {did}")) })?; - self.adoption.map_or(Ok(()), |adoption| { - writeln!(f)?; - writeln!( - f, - "adopted by {}: {} new, {} already present, {} sha1, {} sha256", - adoption.transfer, - adoption.adopted, - adoption.already_present, - adoption.sha1, - adoption.sha256 - ) - })?; - self.cobs.map_or(Ok(()), |cobs| { - writeln!(f)?; - writeln!( - f, - "member grants: {} appended, {} already present", - cobs.members.appended, cobs.members.already_present - )?; - writeln!( - f, - "registrations: {} appended, {} already present", - cobs.registrations.appended, cobs.registrations.already_present - )?; - writeln!( - f, - "collaborator grants: {} appended, {} already present", - cobs.collaborators.appended, cobs.collaborators.already_present - ) - }) + match self.phase { + Phase::Refused => Ok(()), + Phase::Rehearsed(rehearsal) => { + writeln!(f)?; + match &rehearsal.transfer { + Err(error) => writeln!(f, "transfer mode: {error}"), + Ok(transfer) => writeln!(f, "transfer mode: {transfer}"), + }?; + rehearsal.fallback.as_ref().map_or(Ok(()), |fallback| { + writeln!( + f, + "the filesystem checks used {}, since the scan path doesn't exist yet", + fallback.display() + ) + })?; + match &rehearsal.scan_path { + 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" + ), + Err(error) => writeln!(f, "scan path: {error}"), + }?; + rehearsal.room.as_ref().map_or(Ok(()), |room| match room { + Ok(room) => match room.fit() { + Fit::Clear => writeln!( + f, + "room to copy: {} free is enough for the {} that adoption will copy", + room.free, room.source + ), + Fit::Short => writeln!( + f, + "room to copy: {} free isn't enough for the {} that adoption will copy", + room.free, room.source + ), + }, + Err(error) => writeln!(f, "room to copy: {error}"), + }) + } + Phase::Written { adoption, cobs } => { + writeln!(f)?; + writeln!( + f, + "adopted by {}: {} new, {} already present, {} sha1, {} sha256", + adoption.transfer, + adoption.adopted, + adoption.already_present, + adoption.sha1, + adoption.sha256 + )?; + writeln!(f)?; + writeln!( + f, + "member grants: {} appended, {} already present", + cobs.members.appended, cobs.members.already_present + )?; + writeln!( + f, + "registrations: {} appended, {} already present", + cobs.registrations.appended, cobs.registrations.already_present + )?; + writeln!( + f, + "collaborator grants: {} appended, {} already present", + cobs.collaborators.appended, cobs.collaborators.already_present + ) + } + } } } diff --git a/knot2/crates/knot-migrate/tests/migrate.rs b/knot2/crates/knot-migrate/tests/migrate.rs index f9ec86d43..7c48ec353 100644 --- a/knot2/crates/knot-migrate/tests/migrate.rs +++ b/knot2/crates/knot-migrate/tests/migrate.rs @@ -7,6 +7,7 @@ use knot_migrate::casbin; use knot_migrate::emit::MasterKeyEnv; use knot_migrate::emit::{self, ConfigValues}; use knot_migrate::mapping::{self, SkipReason}; +use knot_migrate::rehearse::{self, Rehearsal}; use knot_migrate::report; use knot_migrate::source::{ SourceDb, SourceDid, SourceError, SourceRepoDid, SourceRkey, SourceSchema, @@ -894,8 +895,7 @@ fn two_owners_for_one_repo_are_drift_that_the_report_can_render() { let rendered = report::Report { mapping: &mapping, orphan_alias_count: 0, - adoption: None, - cobs: None, + phase: report::Phase::Refused, } .to_string(); assert!( @@ -927,6 +927,278 @@ fn an_owner_marker_beside_the_repo_keys_owner_is_still_an_extra() { ); } +fn rehearse_scan_path(source: &Path, scan_path: &Path, policy: adopt::SourcePolicy) -> Rehearsal { + rehearse_adopting(source, &[], scan_path, policy) +} + +fn rehearse_adopting( + source: &Path, + adopted: &[mapping::AdoptRepo], + scan_path: &Path, + policy: adopt::SourcePolicy, +) -> Rehearsal { + Rehearsal::run(rehearse::Inputs { + source_repos: source, + adopted, + scan_path, + policy, + }) +} + +#[test] +fn a_rehearsal_plans_its_transfer_and_probes_the_path_that_the_real_run_will_create() { + let fx = fixture(true); + let scan_path = fx.target.join("repos"); + let missing = rehearse_scan_path(&fx.source_repos, &scan_path, adopt::SourcePolicy::Consume); + assert_eq!(missing.transfer.unwrap(), adopt::Transfer::Rename); + 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 \ + that exists now" + ); + + std::fs::create_dir_all(&scan_path).unwrap(); + let fresh = rehearse_scan_path(&fx.source_repos, &scan_path, adopt::SourcePolicy::Consume); + assert_eq!(fresh.transfer.unwrap(), adopt::Transfer::Rename); + assert_eq!(fresh.fallback, None); + assert_eq!(fresh.scan_path.unwrap(), rehearse::Occupancy::Fresh); + assert!( + fresh.room.is_none(), + "a rename doesn't need a second copy of anything" + ); + + std::fs::create_dir(scan_path.join("did:plc:squid")).unwrap(); + let occupied = rehearse_scan_path(&fx.source_repos, &scan_path, adopt::SourcePolicy::Preserve); + assert_eq!( + occupied.transfer.unwrap(), + adopt::Transfer::Copy, + "the default policy copies, so it will never compare the two filesystems" + ); + assert_eq!(occupied.scan_path.unwrap(), rehearse::Occupancy::Occupied); + + let relative = rehearse_scan_path( + &fx.source_repos, + Path::new("knot-migrate-nowhere/repos"), + adopt::SourcePolicy::Preserve, + ); + assert_eq!( + relative.fallback.as_deref(), + Some(Path::new(".")), + "probing / instead would answer for a filesystem that the real run never touches" + ); +} + +fn on_another_filesystem(reference: &Path) -> Option { + use std::os::unix::fs::MetadataExt; + let device = |path: &Path| std::fs::metadata(path).ok().map(|meta| meta.dev()); + tempfile::TempDir::new_in("/dev/shm") + .ok() + .filter(|elsewhere| device(elsewhere.path()) != device(reference)) +} + +#[test] +fn rehearsing_a_cross_filesystem_consume_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let Some(elsewhere) = on_another_filesystem(dir.path()) else { + eprintln!("skipping the cross-filesystem case: /dev/shm is on this tempdir's filesystem"); + return; + }; + assert!(matches!( + rehearse_scan_path(dir.path(), elsewhere.path(), adopt::SourcePolicy::Consume).transfer, + Err(adopt::AdoptError::CrossDeviceConsume { .. }) + )); + assert_eq!( + rehearse_scan_path(dir.path(), elsewhere.path(), adopt::SourcePolicy::Preserve) + .transfer + .unwrap(), + adopt::Transfer::Copy, + "the default policy copies, so two filesystems suit it fine" + ); +} + +#[test] +fn a_rehearsal_measures_the_room_that_adoption_will_copy() { + let fx = fixture(true); + let mapping = map(&fx); + let measure = || { + rehearse_adopting( + &fx.source_repos, + &mapping.repos, + &fx.target.join("repos"), + adopt::SourcePolicy::Preserve, + ) + .room + .unwrap() + .unwrap() + }; + let measured = measure(); + assert!(measured.source > rehearse::Bytes::new(0), "{measured:?}"); + assert_eq!(measured.fit(), rehearse::Fit::Clear, "{measured:?}"); + std::fs::write( + fx.source_repos.join("did:plc:whelk/stray.pack"), + vec![0_u8; 1 << 20], + ) + .unwrap(); + assert_eq!( + measure().source, + measured.source, + "did:plc:whelk doesn't have a HEAD and stays out of the mapping, so adoption will never \ + read a byte of it" + ); + let fit = |source: u64, free: u64| { + rehearse::Room { + source: rehearse::Bytes::new(source), + free: rehearse::Bytes::new(free), + } + .fit() + }; + assert_eq!(fit(1_000, 999), rehearse::Fit::Short); + assert_eq!(fit(1_000, 1_000), rehearse::Fit::Clear); + assert_eq!( + fit(0, 0), + rehearse::Fit::Clear, + "a rehearsal that won't adopt a repo doesn't need room" + ); +} + +fn scan_path_refusal(build: impl FnOnce(&Path) -> PathBuf) -> Rehearsal { + let dir = tempfile::tempdir().unwrap(); + let scan_path = build(dir.path()); + rehearse_scan_path(dir.path(), &scan_path, adopt::SourcePolicy::Preserve) +} + +#[test] +fn a_scan_path_that_the_real_run_cannot_reach_is_refused_whichever_user_runs_it() { + let under_a_file = scan_path_refusal(|dir| { + let blocker = dir.join("not-a-directory"); + std::fs::write(&blocker, "").unwrap(); + blocker.join("knot/repos") + }); + assert!( + matches!( + under_a_file.scan_path, + Err(rehearse::ScanPathError::Uncreatable { .. }) + ), + "root can't traverse a regular file either, so this case covers the refusal under any uid: \ + {:?}", + under_a_file.scan_path + ); + let looped = scan_path_refusal(|dir| { + let loop_path = dir.join("loop"); + std::os::unix::fs::symlink(&loop_path, &loop_path).unwrap(); + loop_path.join("repos") + }); + assert!( + matches!( + 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 \ + create: {:?}", + looped.scan_path + ); + let dangling = scan_path_refusal(|dir| { + let scan_path = dir.join("repos"); + std::os::unix::fs::symlink(dir.join("nowhere"), &scan_path).unwrap(); + scan_path + }); + assert!( + matches!( + dangling.scan_path, + 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: {:?}", + 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 \ + up to its parent" + ); + [under_a_file, looped, dangling] + .iter() + .for_each(|rehearsal| assert!(!rehearsal.ready())); +} + +#[test] +fn a_scan_path_that_this_process_cannot_write_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + if !honors_permission_bits(dir.path()) { + return; + } + let closed = |name: &str| { + let path = dir.path().join(name); + std::fs::create_dir(&path).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o555)).unwrap(); + path + }; + let parent = closed("parent"); + let existing = closed("repos"); + let uncreatable = rehearse_scan_path( + dir.path(), + &parent.join("knot/repos"), + adopt::SourcePolicy::Preserve, + ); + let unwritable = rehearse_scan_path(dir.path(), &existing, adopt::SourcePolicy::Preserve); + [&parent, &existing].iter().for_each(|path| { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + }); + assert!( + matches!( + uncreatable.scan_path, + Err(rehearse::ScanPathError::Uncreatable { .. }) + ), + "the real run will create the scan path, so an unwritable ancestor stops it: {:?}", + uncreatable.scan_path + ); + assert!( + matches!( + unwritable.scan_path, + Err(rehearse::ScanPathError::Unwritable { .. }) + ), + "a scan path that already exists doesn't need creating, so the refusal mustn't blame its \ + parent: {:?}", + unwritable.scan_path + ); + assert!(!uncreatable.ready() && !unwritable.ready()); +} + +fn ready_rehearsal() -> Rehearsal { + Rehearsal { + fallback: None, + transfer: Ok(adopt::Transfer::Copy), + scan_path: Ok(rehearse::Occupancy::Fresh), + room: Some(Ok(rehearse::Room { + source: rehearse::Bytes::new(1), + free: rehearse::Bytes::new(2), + })), + } +} + +#[test] +fn a_rehearsal_is_ready_only_once_the_copy_has_room() { + assert!(ready_rehearsal().ready()); + let cramped = Rehearsal { + room: Some(Ok(rehearse::Room { + source: rehearse::Bytes::new(2), + free: rehearse::Bytes::new(1), + })), + ..ready_rehearsal() + }; + assert!(!cramped.ready()); + let unmeasured = Rehearsal { + room: None, + ..ready_rehearsal() + }; + assert!( + unmeasured.ready(), + "a rename doesn't measure room at all, which mustn't read as a copy that won't fit" + ); +} + fn base64_standard(bytes: &[u8]) -> String { use base64::Engine; base64::engine::general_purpose::STANDARD.encode(bytes) @@ -1048,3 +1320,64 @@ fn a_run_refuses_two_owners_for_one_repo_after_it_reports_them() { ); }); } + +#[test] +fn a_rehearsal_reports_an_unreadable_source_and_a_second_owner_together() { + use std::os::unix::fs::PermissionsExt; + let fx = fixture(true); + if !honors_permission_bits(&fx.source_repos) { + return; + } + set_acl_owner(&fx, "did:plc:scallop", "did:plc:teq"); + let dir = tempfile::tempdir().unwrap(); + let host_key = host_key_file(dir.path()); + let limpet = fx.source_repos.join("did:plc:limpet"); + std::fs::set_permissions(&limpet, std::fs::Permissions::from_mode(0o000)).unwrap(); + let outcome = run_migrate(&fx, &host_key, &["--dry-run"]); + std::fs::set_permissions(&limpet, std::fs::Permissions::from_mode(0o755)).unwrap(); + let stderr = String::from_utf8_lossy(&outcome.stderr).to_string(); + assert!(!outcome.status.success(), "{stderr}"); + assert!( + stderr.contains("different owners for did:plc:scallop"), + "{stderr}" + ); + assert!( + stderr.contains("can't read the source path of did:plc:limpet"), + "both refusals have to appear together: {stderr}" + ); +} + +#[test] +fn consuming_a_source_that_this_process_cannot_write_is_refused() { + use std::os::unix::fs::PermissionsExt; + let fx = fixture(true); + if !honors_permission_bits(&fx.source_repos) { + return; + } + 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 preserving = rehearse_scan_path( + &fx.source_repos, + &fx.target.join("repos"), + adopt::SourcePolicy::Preserve, + ); + std::fs::set_permissions(&fx.source_repos, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + matches!( + rehearsal.transfer, + Err(adopt::AdoptError::UnwritableSource { .. }) + ), + "a rename will move every repo out of the source, so the source has to be writable: {:?}", + rehearsal.transfer + ); + assert!(!rehearsal.ready()); + assert_eq!( + preserving.transfer.unwrap(), + adopt::Transfer::Copy, + "a copy will read the source and write elsewhere, so it doesn't need write permission there" + ); +} -- 2.51.2