2026-07-17 07:54:46 +09:00
|
|
|
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::{
|
2026-07-17 08:05:17 +09:00
|
|
|
DomainStateReport, StateAnchorEntryKind, inspect_absent_domain_state, inspect_domain_state,
|
|
|
|
|
reset_domain_state,
|
2026-07-17 07:54:46 +09:00
|
|
|
};
|
|
|
|
|
use lazaret_linux::filesystem::try_exclusive_flock;
|
|
|
|
|
use rustix::fs::{Mode, OFlags, ResolveFlags, openat2};
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
|
|
|
|
|
use crate::ClientCommand;
|
2026-07-18 22:35:48 +09:00
|
|
|
use crate::coordinator_backend::{load_configuration, open_private_domain_lock};
|
2026-07-17 07:54:46 +09:00
|
|
|
|
|
|
|
|
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<StateResetScopeV1>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<i32, Box<dyn std::error::Error>> {
|
|
|
|
|
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) => {
|
2026-07-17 08:05:17 +09:00
|
|
|
let report = inspect_absent_domain_state(&configuration)?;
|
|
|
|
|
write_report(&report, command.operation, command.machine);
|
|
|
|
|
return Ok(0);
|
2026-07-17 07:54:46 +09:00
|
|
|
}
|
|
|
|
|
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<StateCommand, &'static str> {
|
|
|
|
|
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 <name>` 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 <container-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<String, &'static str> {
|
|
|
|
|
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<OsString, &'static str> {
|
|
|
|
|
let value = arguments
|
|
|
|
|
.get(*index)
|
|
|
|
|
.ok_or("option requires a value")?
|
|
|
|
|
.clone();
|
|
|
|
|
*index += 1;
|
|
|
|
|
Ok(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_scope(
|
|
|
|
|
current: &mut Option<StateResetScopeV1>,
|
|
|
|
|
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<dyn std::error::Error>> {
|
|
|
|
|
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::<Vec<_>>(),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|