use std::ffi::OsString; use std::io::{self, Write}; use std::os::fd::AsFd; use std::path::PathBuf; use lazaret_core::records::Domain; use lazaret_host_coordinator::domain_store::{HostCoordinatorDomainStore, StateResetScopeV1}; use lazaret_host_coordinator::state_maintenance::{ DomainStateReport, StateAnchorEntryKind, inspect_absent_domain_state, inspect_domain_state, reset_domain_state, }; use lazaret_linux::filesystem::try_exclusive_flock; use rustix::fs::{Mode, OFlags, ResolveFlags, openat2}; use serde_json::json; use crate::ClientCommand; use crate::coordinator_backend::{load_configuration, open_private_domain_lock}; const OUTPUT_SCHEMA: &str = "lazaret-state-output-v1"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Operation { List, Inspect, Verify, Reset, } struct StateCommand { operation: Operation, context: ClientCommand, machine: bool, yes: bool, scope: Option, } /// Runs one state-maintenance command using resolved configuration and exclusive Domain authority. /// /// # Errors /// Refuses invalid arguments, active Domains, arbitrary targets, authority changes, malformed /// durable state, structural failures, and reset without confirmation. pub fn run_command(arguments: &[OsString]) -> Result> { let command = parse(arguments)?; let configuration = load_configuration(&command.context)?; if let Some(StateResetScopeV1::Selected(path)) = &command.scope && !configuration .fingerprint_record() .persistent_dirs .iter() .any(|configured| configured.as_bytes() == path) { return Err("reset target is not an exact configured persistent container path".into()); } let root = configuration.persistent_domain_root().open_authority()?; let lock = try_exclusive_flock(open_private_domain_lock(&root)?)?; let database = match openat2( root.as_fd(), "domain.redb", OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW, Mode::empty(), ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_XDEV, ) { Ok(database) => database, Err(rustix::io::Errno::NOENT) => { let report = inspect_absent_domain_state(&configuration)?; write_report(&report, command.operation, command.machine); return Ok(0); } Err(error) => return Err(io::Error::from(error).into()), }; let expected = Domain { domain_id: configuration.domain_id(), profile_name: configuration.selected_profile().to_owned(), canonical_workspace: configuration.workspace().canonical_label().clone(), }; let store = HostCoordinatorDomainStore::open_file(lock, database.into(), &expected)?; let report = match command.operation { Operation::List | Operation::Inspect | Operation::Verify => { inspect_domain_state(&configuration, &store)? } Operation::Reset => { let scope = command.scope.ok_or("reset scope is missing")?; if !command.yes { confirm(&configuration, &scope)?; } reset_domain_state(&configuration, &store, scope)? } }; write_report(&report, command.operation, command.machine); Ok(0) } fn parse(arguments: &[OsString]) -> Result { let operation = match arguments.first().and_then(|value| value.to_str()) { Some("list") => Operation::List, Some("inspect") => Operation::Inspect, Some("verify") => Operation::Verify, Some("reset") => Operation::Reset, _ => return Err("expected `state list|inspect|verify|reset`"), }; let mut profile = None; let mut workspace = None; let mut configuration = None; let mut machine = false; let mut yes = false; let mut scope = None; let mut index = 1; while index < arguments.len() { let option = arguments[index].to_str().ok_or("options must be UTF-8")?; index += 1; match option { "--profile" => profile = Some(value(arguments, &mut index, "--profile")?), "--workspace" => workspace = Some(PathBuf::from(value_os(arguments, &mut index)?)), "--config" => configuration = Some(PathBuf::from(value_os(arguments, &mut index)?)), "--json" => machine = true, "--yes" => yes = true, "--all" => set_scope(&mut scope, StateResetScopeV1::All)?, "--path" => { let path = value(arguments, &mut index, "--path")?; set_scope(&mut scope, StateResetScopeV1::Selected(path.into_bytes()))?; } _ => return Err("unknown state-maintenance option"), } } let profile = profile.ok_or("`--profile ` is required")?; let workspace = match workspace { Some(workspace) => workspace, None => std::env::current_dir().map_err(|_| "cannot determine current workspace")?, }; if operation == Operation::Reset && scope.is_none() { return Err("reset requires exactly one of `--all` or `--path `"); } if operation != Operation::Reset && (scope.is_some() || yes) { return Err("reset scope and confirmation options require `state reset`"); } Ok(StateCommand { operation, context: ClientCommand { profile, workspace, configuration, argv: Vec::new(), }, machine, yes, scope, }) } fn value( arguments: &[OsString], index: &mut usize, option: &'static str, ) -> Result { value_os(arguments, index)? .into_string() .map_err(|_| match option { "--profile" => "profile name must be UTF-8", "--path" => "container path must be UTF-8", _ => "option value must be UTF-8", }) } fn value_os(arguments: &[OsString], index: &mut usize) -> Result { let value = arguments .get(*index) .ok_or("option requires a value")? .clone(); *index += 1; Ok(value) } fn set_scope( current: &mut Option, scope: StateResetScopeV1, ) -> Result<(), &'static str> { if current.replace(scope).is_some() { Err("reset accepts exactly one scope") } else { Ok(()) } } fn confirm( configuration: &lazaret_core::configuration::ResolvedDomainConfiguration, scope: &StateResetScopeV1, ) -> Result<(), Box> { let scope = match scope { StateResetScopeV1::All => "all configured State Anchors".to_owned(), StateResetScopeV1::Selected(path) => { format!("State Anchor {}", String::from_utf8_lossy(path)) } }; eprintln!( "Reset Domain {} profile {} workspace {} scope: {}? [y/N]", hex(configuration.domain_id().as_bytes()), configuration.selected_profile(), String::from_utf8_lossy(configuration.workspace().canonical_label().as_bytes()), scope ); io::stderr().flush()?; let mut answer = String::new(); io::stdin().read_line(&mut answer)?; if matches!(answer.trim(), "y" | "Y" | "yes" | "YES") { Ok(()) } else { Err("reset was not confirmed".into()) } } fn write_report(report: &DomainStateReport, operation: Operation, machine: bool) { if machine { let anchors = report .anchors .iter() .map(|anchor| { json!({ "container_path": anchor.container_path, "changes": anchor.changes.iter().map(|entry| json!({ "path_hex": hex(&entry.path), "kind": entry_kind(entry.kind), "mode": entry.mode, "uid": entry.uid, "gid": entry.gid, "size": entry.size, })).collect::>(), }) }) .collect::>(); println!( "{}", json!({ "schema": OUTPUT_SCHEMA, "operation": operation_name(operation), "domain_id": hex(&report.domain_id), "profile": report.profile, "workspace_hex": hex(&report.workspace), "anchors": anchors, "contents_included": false, }) ); } else { println!("Domain {}", hex(&report.domain_id)); println!("Profile: {}", report.profile); println!("Workspace: {}", String::from_utf8_lossy(&report.workspace)); for anchor in &report.anchors { println!( "State Anchor {}: {} structural change(s)", anchor.container_path, anchor.changes.len() ); if matches!(operation, Operation::Inspect | Operation::Verify) { for entry in &anchor.changes { println!( " {} {} mode {:o} uid {} gid {} size {}", String::from_utf8_lossy(&entry.path), entry_kind(entry.kind), entry.mode, entry.uid, entry.gid, entry.size ); } } } println!("Persisted file contents were not read or printed."); } } const fn operation_name(operation: Operation) -> &'static str { match operation { Operation::List => "list", Operation::Inspect => "inspect", Operation::Verify => "verify", Operation::Reset => "reset", } } const fn entry_kind(kind: StateAnchorEntryKind) -> &'static str { match kind { StateAnchorEntryKind::Directory => "directory", StateAnchorEntryKind::RegularFile => "regular-file", StateAnchorEntryKind::Symlink => "symlink", StateAnchorEntryKind::Whiteout => "whiteout", } } fn hex(bytes: &[u8]) -> String { use std::fmt::Write as _; bytes.iter().fold(String::new(), |mut output, byte| { write!(output, "{byte:02x}").expect("writing to a String cannot fail"); output }) } #[cfg(test)] mod tests { use super::*; #[test] fn parser_requires_explicit_reset_scope_and_rejects_arbitrary_options() { assert!(parse(&["reset", "--profile", "default"].map(OsString::from)).is_err()); assert!( parse( &["reset", "--profile", "default", "--all", "--path", "/usr",].map(OsString::from) ) .is_err() ); assert!(parse(&["inspect", "--profile", "default", "--yes"].map(OsString::from)).is_err()); } #[test] fn parser_preserves_only_container_scope_not_a_host_upper_path() { let command = parse( &[ "reset", "--profile", "default", "--path", "/usr", "--yes", "--json", ] .map(OsString::from), ) .unwrap(); assert_eq!( command.scope, Some(StateResetScopeV1::Selected(b"/usr".to_vec())) ); assert!(command.yes); assert!(command.machine); } }