Add crash-consistent State Anchor maintenance
This commit is contained in:
parent
5cd0772866
commit
24d7d5b6e0
18 changed files with 1590 additions and 7 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1217,6 +1217,7 @@ dependencies = [
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
"rustix",
|
"rustix",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ rustix.workspace = true
|
||||||
rustls.workspace = true
|
rustls.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
nix.workspace = true
|
nix.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "=3.27.0"
|
tempfile = "=3.27.0"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ use sha2::{Digest, Sha256};
|
||||||
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
|
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
|
||||||
|
|
||||||
pub mod production;
|
pub mod production;
|
||||||
|
pub mod state;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,15 @@ fn main() {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if arguments.first().and_then(|value| value.to_str()) == Some("state") {
|
||||||
|
match lazaret_client::state::run_command(&arguments[1..]) {
|
||||||
|
Ok(status) => std::process::exit(status),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Lazaret state maintenance failed: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let current_workspace = match std::env::current_dir() {
|
let current_workspace = match std::env::current_dir() {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
|
||||||
|
|
@ -384,7 +384,7 @@ impl ProductionClientBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_private_domain_lock(root: &impl AsFd) -> Result<File, ProductionClientError> {
|
pub(crate) fn open_private_domain_lock(root: &impl AsFd) -> Result<File, ProductionClientError> {
|
||||||
let file = File::from(
|
let file = File::from(
|
||||||
openat(
|
openat(
|
||||||
root,
|
root,
|
||||||
|
|
@ -416,7 +416,7 @@ fn publication_matches(
|
||||||
== configuration.configuration_fingerprint()
|
== configuration.configuration_fingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_configuration(
|
pub(crate) fn load_configuration(
|
||||||
command: &ClientCommand,
|
command: &ClientCommand,
|
||||||
) -> Result<ResolvedDomainConfiguration, Box<dyn std::error::Error>> {
|
) -> Result<ResolvedDomainConfiguration, Box<dyn std::error::Error>> {
|
||||||
Ok(load_trusted_configuration(ConfigurationLoadRequest {
|
Ok(load_trusted_configuration(ConfigurationLoadRequest {
|
||||||
|
|
|
||||||
367
crates/lazaret-client/src/state.rs
Normal file
367
crates/lazaret-client/src/state.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
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_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::production::{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<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) => {
|
||||||
|
return Ok(write_absent(
|
||||||
|
&configuration,
|
||||||
|
command.operation,
|
||||||
|
command.machine,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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_absent(
|
||||||
|
configuration: &lazaret_core::configuration::ResolvedDomainConfiguration,
|
||||||
|
operation: Operation,
|
||||||
|
machine: bool,
|
||||||
|
) -> i32 {
|
||||||
|
let report = DomainStateReport {
|
||||||
|
profile: configuration.selected_profile().to_owned(),
|
||||||
|
workspace: configuration
|
||||||
|
.workspace()
|
||||||
|
.canonical_label()
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
domain_id: *configuration.domain_id().as_bytes(),
|
||||||
|
anchors: configuration
|
||||||
|
.fingerprint_record()
|
||||||
|
.persistent_dirs
|
||||||
|
.iter()
|
||||||
|
.map(
|
||||||
|
|path| lazaret_host_coordinator::state_maintenance::StateAnchorReport {
|
||||||
|
container_path: path.clone(),
|
||||||
|
changes: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
write_report(&report, operation, machine);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
crates/lazaret-client/tests/state_cli.rs
Normal file
80
crates/lazaret-client/tests/state_cli.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
#[test]
|
||||||
|
fn machine_and_human_discovery_are_versioned_and_content_free() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let machine = fixture.command("list").arg("--json").output().unwrap();
|
||||||
|
assert!(
|
||||||
|
machine.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&machine.stderr)
|
||||||
|
);
|
||||||
|
let document: serde_json::Value = serde_json::from_slice(&machine.stdout).unwrap();
|
||||||
|
assert_eq!(document["schema"], "lazaret-state-output-v1");
|
||||||
|
assert_eq!(document["contents_included"], false);
|
||||||
|
assert_eq!(document["anchors"][0]["container_path"], "/usr");
|
||||||
|
|
||||||
|
let human = fixture.command("inspect").output().unwrap();
|
||||||
|
assert!(
|
||||||
|
human.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&human.stderr)
|
||||||
|
);
|
||||||
|
let output = String::from_utf8(human.stdout).unwrap();
|
||||||
|
assert!(output.contains("State Anchor /usr"));
|
||||||
|
assert!(output.contains("Persisted file contents were not read or printed."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||||
|
#[test]
|
||||||
|
fn reset_refuses_an_arbitrary_container_or_host_path_before_state_access() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let output = fixture
|
||||||
|
.command("reset")
|
||||||
|
.args(["--path", "/tmp/host-upper", "--yes"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!output.status.success());
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
.contains("not an exact configured persistent container path")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
root: tempfile::TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for name in ["workspace", "runtime", "home"] {
|
||||||
|
fs::create_dir(root.path().join(name)).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
root.path().join("config.toml"),
|
||||||
|
format!(
|
||||||
|
"schema = \"lazaret-trusted-host-config-v1\"\n[storage]\ndata_root = \"{}\"\n[network]\ndefault_allow = [\"192.0.2.10/32\"]\n[profiles.default]\nimage = \"example.invalid/state:v1\"\npersistent_dirs = [\"/usr\"]\n[profiles.default.readiness]\ndenied = [{{ address = \"10.20.30.40\", port = 8443 }}]\npermitted = [{{ address = \"192.0.2.10\", port = 443 }}]\ndns = [{{ resolver = \"198.51.100.53\", query = \"example.invalid\" }}]\n",
|
||||||
|
root.path().join("data").display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::disallowed_types)]
|
||||||
|
fn command(&self, operation: &str) -> std::process::Command {
|
||||||
|
let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_lazaret"));
|
||||||
|
command
|
||||||
|
.arg("state")
|
||||||
|
.arg(operation)
|
||||||
|
.args(["--profile", "default", "--workspace"])
|
||||||
|
.arg(self.root.path().join("workspace"))
|
||||||
|
.arg("--config")
|
||||||
|
.arg(self.root.path().join("config.toml"))
|
||||||
|
.env("HOME", self.root.path().join("home"))
|
||||||
|
.env("XDG_RUNTIME_DIR", self.root.path().join("runtime"));
|
||||||
|
command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -150,6 +150,8 @@ schema_types!(
|
||||||
"DomainStore" => DomainStore,
|
"DomainStore" => DomainStore,
|
||||||
"StorageCatalog" => StorageCatalog,
|
"StorageCatalog" => StorageCatalog,
|
||||||
"PrepareStageState" => PrepareStageState;
|
"PrepareStageState" => PrepareStageState;
|
||||||
|
"StateResetIntentV1",
|
||||||
|
"StateResetScopeV1",
|
||||||
);
|
);
|
||||||
|
|
||||||
pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[
|
pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use redb::{
|
||||||
CommitError, Database, DatabaseError, ReadableDatabase, ReadableTable, StorageError,
|
CommitError, Database, DatabaseError, ReadableDatabase, ReadableTable, StorageError,
|
||||||
TableDefinition, TableError, TransactionError,
|
TableDefinition, TableError, TransactionError,
|
||||||
};
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("metadata");
|
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("metadata");
|
||||||
const DOMAINS: TableDefinition<u8, &[u8]> = TableDefinition::new("domains");
|
const DOMAINS: TableDefinition<u8, &[u8]> = TableDefinition::new("domains");
|
||||||
|
|
@ -24,7 +25,51 @@ const SESSIONS: TableDefinition<&[u8; 40], &[u8]> = TableDefinition::new("sessio
|
||||||
const SESSION_TERMINALS: TableDefinition<&[u8; 40], &[u8]> =
|
const SESSION_TERMINALS: TableDefinition<&[u8; 40], &[u8]> =
|
||||||
TableDefinition::new("session_terminals");
|
TableDefinition::new("session_terminals");
|
||||||
const MAILBOXES: TableDefinition<u64, &[u8]> = TableDefinition::new("mailboxes");
|
const MAILBOXES: TableDefinition<u64, &[u8]> = TableDefinition::new("mailboxes");
|
||||||
|
const STATE_RESET: TableDefinition<u8, &[u8]> = TableDefinition::new("state_reset");
|
||||||
const SINGLETON_KEY: u8 = 0;
|
const SINGLETON_KEY: u8 = 0;
|
||||||
|
const STATE_RESET_SCHEMA_V1: &str = "lazaret-state-reset-v1";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub enum StateResetScopeV1 {
|
||||||
|
All,
|
||||||
|
Selected(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct StateResetIntentV1 {
|
||||||
|
schema: String,
|
||||||
|
domain_id: lazaret_core::identity::DomainId,
|
||||||
|
scope: StateResetScopeV1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateResetIntentV1 {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(domain_id: lazaret_core::identity::DomainId, scope: StateResetScopeV1) -> Self {
|
||||||
|
Self {
|
||||||
|
schema: STATE_RESET_SCHEMA_V1.to_owned(),
|
||||||
|
domain_id,
|
||||||
|
scope,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn domain_id(&self) -> lazaret_core::identity::DomainId {
|
||||||
|
self.domain_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn scope(&self) -> &StateResetScopeV1 {
|
||||||
|
&self.scope
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), DomainStoreError> {
|
||||||
|
if self.schema == STATE_RESET_SCHEMA_V1 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DomainStoreError::InvalidRow("State Reset"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum DomainStoreMutation {
|
pub enum DomainStoreMutation {
|
||||||
|
|
@ -231,6 +276,71 @@ impl HostCoordinatorDomainStore {
|
||||||
self.write_snapshot(&snapshot)
|
self.write_snapshot(&snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the typed reset recovery point from the versioned Domain Store.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses malformed, unknown-version, or mismatched-Domain intent.
|
||||||
|
pub fn state_reset_intent(&self) -> Result<Option<StateResetIntentV1>, DomainStoreError> {
|
||||||
|
self.validate_metadata()?;
|
||||||
|
let read = self.database.begin_read()?;
|
||||||
|
let table = match read.open_table(STATE_RESET) {
|
||||||
|
Ok(table) => table,
|
||||||
|
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let Some(value) = table.get(SINGLETON_KEY)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let intent = decode::<StateResetIntentV1>(value.value())?;
|
||||||
|
intent.validate()?;
|
||||||
|
if intent.domain_id != self.snapshot()?.domain.domain_id {
|
||||||
|
return Err(DomainStoreError::DomainMismatch);
|
||||||
|
}
|
||||||
|
Ok(Some(intent))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits the reset recovery point in one immediate-durability transaction.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses an invalid, foreign, or already-present intent.
|
||||||
|
pub fn begin_state_reset(&self, intent: &StateResetIntentV1) -> Result<(), DomainStoreError> {
|
||||||
|
intent.validate()?;
|
||||||
|
if intent.domain_id != self.snapshot()?.domain.domain_id {
|
||||||
|
return Err(DomainStoreError::DomainMismatch);
|
||||||
|
}
|
||||||
|
let write = self.database.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(STATE_RESET)?;
|
||||||
|
if table.get(SINGLETON_KEY)?.is_some() {
|
||||||
|
return Err(DomainStoreError::DuplicateRow("State Reset"));
|
||||||
|
}
|
||||||
|
let encoded = encode(intent)?;
|
||||||
|
table.insert(SINGLETON_KEY, encoded.as_slice())?;
|
||||||
|
}
|
||||||
|
write.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the exact completed intent in one immediate-durability transaction.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses missing or changed intent and storage failures.
|
||||||
|
pub fn finish_state_reset(
|
||||||
|
&self,
|
||||||
|
expected: &StateResetIntentV1,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
if self.state_reset_intent()?.as_ref() != Some(expected) {
|
||||||
|
return Err(DomainStoreError::InvalidRow("State Reset"));
|
||||||
|
}
|
||||||
|
let write = self.database.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(STATE_RESET)?;
|
||||||
|
table.remove(SINGLETON_KEY)?;
|
||||||
|
}
|
||||||
|
write.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn initialize(&self, domain: &Domain) -> Result<(), DomainStoreError> {
|
fn initialize(&self, domain: &Domain) -> Result<(), DomainStoreError> {
|
||||||
let snapshot = DomainStore {
|
let snapshot = DomainStore {
|
||||||
metadata: DomainStoreMetadata::v1(),
|
metadata: DomainStoreMetadata::v1(),
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,65 @@ fn open_store(directory: &TestDirectory) -> HostCoordinatorDomainStore {
|
||||||
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap()
|
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_intent_is_typed_transactional_and_domain_bound() {
|
||||||
|
let directory = TestDirectory::new("state-reset");
|
||||||
|
let store = open_store(&directory);
|
||||||
|
let intent = StateResetIntentV1::new(
|
||||||
|
domain().domain_id,
|
||||||
|
StateResetScopeV1::Selected(b"/usr".to_vec()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(store.state_reset_intent().unwrap(), None);
|
||||||
|
store.begin_state_reset(&intent).unwrap();
|
||||||
|
assert_eq!(store.state_reset_intent().unwrap(), Some(intent.clone()));
|
||||||
|
assert!(store.begin_state_reset(&intent).is_err());
|
||||||
|
store.finish_state_reset(&intent).unwrap();
|
||||||
|
assert_eq!(store.state_reset_intent().unwrap(), None);
|
||||||
|
|
||||||
|
let foreign = StateResetIntentV1::new(DomainId::new(bytes(99)), StateResetScopeV1::All);
|
||||||
|
assert!(matches!(
|
||||||
|
store.begin_state_reset(&foreign),
|
||||||
|
Err(DomainStoreError::DomainMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_intent_unknown_version_refuses_before_scope_use() {
|
||||||
|
let directory = TestDirectory::new("state-reset-version");
|
||||||
|
let store = open_store(&directory);
|
||||||
|
let intent = StateResetIntentV1 {
|
||||||
|
schema: "lazaret-state-reset-v2".into(),
|
||||||
|
domain_id: domain().domain_id,
|
||||||
|
scope: StateResetScopeV1::All,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
store.begin_state_reset(&intent),
|
||||||
|
Err(DomainStoreError::InvalidRow("State Reset"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_persisted_reset_intent_refuses_without_fallback() {
|
||||||
|
let directory = TestDirectory::new("state-reset-malformed");
|
||||||
|
drop(open_store(&directory));
|
||||||
|
let database = Database::open(directory.path("domain.redb")).unwrap();
|
||||||
|
let write = database.begin_write().unwrap();
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(STATE_RESET).unwrap();
|
||||||
|
table
|
||||||
|
.insert(SINGLETON_KEY, b"not-postcard".as_slice())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
write.commit().unwrap();
|
||||||
|
drop(database);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
open_store(&directory).state_reset_intent(),
|
||||||
|
Err(DomainStoreError::Codec(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_store_open_cannot_be_redirected_by_path_replacement() {
|
fn descriptor_store_open_cannot_be_redirected_by_path_replacement() {
|
||||||
let directory = TestDirectory::new("descriptor-open");
|
let directory = TestDirectory::new("descriptor-open");
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ pub mod recovery;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod sandbox_init;
|
pub mod sandbox_init;
|
||||||
pub mod state_anchor;
|
pub mod state_anchor;
|
||||||
|
pub mod state_maintenance;
|
||||||
pub mod terminal;
|
pub mod terminal;
|
||||||
pub mod waiter;
|
pub mod waiter;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ use crate::runtime::StateAnchorAbsenceObservation;
|
||||||
use crate::state_anchor::{
|
use crate::state_anchor::{
|
||||||
StateAnchorCoordinator, StateAnchorCoordinatorError, StateAnchorRecoveryOwner,
|
StateAnchorCoordinator, StateAnchorCoordinatorError, StateAnchorRecoveryOwner,
|
||||||
};
|
};
|
||||||
|
use crate::state_maintenance::recover_state_reset;
|
||||||
|
|
||||||
const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
|
const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
|
@ -627,6 +628,7 @@ where
|
||||||
Open: FnMut(PidfsProcessIdentityV1) -> io::Result<PidfsRecoveryMatch>,
|
Open: FnMut(PidfsProcessIdentityV1) -> io::Result<PidfsRecoveryMatch>,
|
||||||
Delete: FnMut(&std::os::fd::OwnedFd, &str) -> Result<(), String>,
|
Delete: FnMut(&std::os::fd::OwnedFd, &str) -> Result<(), String>,
|
||||||
{
|
{
|
||||||
|
recover_state_reset(configuration, store).map_err(|error| error.to_string())?;
|
||||||
failpoint(ProductionRecoveryBoundary::Snapshot, GenerationId::new(0))?;
|
failpoint(ProductionRecoveryBoundary::Snapshot, GenerationId::new(0))?;
|
||||||
let snapshot = store.snapshot().map_err(|error| error.to_string())?;
|
let snapshot = store.snapshot().map_err(|error| error.to_string())?;
|
||||||
if snapshot.domain.domain_id != configuration.domain_id() {
|
if snapshot.domain.domain_id != configuration.domain_id() {
|
||||||
|
|
|
||||||
482
crates/lazaret-host-coordinator/src/state_maintenance.rs
Normal file
482
crates/lazaret-host-coordinator/src/state_maintenance.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::fmt;
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use lazaret_core::configuration::ResolvedDomainConfiguration;
|
||||||
|
use lazaret_core::records::{
|
||||||
|
DurableDecision, ParticipantState, SessionPhase, SessionTerminalState,
|
||||||
|
};
|
||||||
|
pub use lazaret_state_anchor::StateAnchorEntryKind;
|
||||||
|
use lazaret_state_anchor::{
|
||||||
|
StateAnchorEntry, StateAnchorInspection, StateAnchorLayout, StateAnchorResetScope,
|
||||||
|
require_no_orphan_reset_quarantine,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::domain_store::{HostCoordinatorDomainStore, StateResetIntentV1, StateResetScopeV1};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StateMaintenanceError(String);
|
||||||
|
|
||||||
|
impl fmt::Display for StateMaintenanceError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for StateMaintenanceError {}
|
||||||
|
|
||||||
|
fn failure(error: impl fmt::Display) -> StateMaintenanceError {
|
||||||
|
StateMaintenanceError(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct StateAnchorReport {
|
||||||
|
pub container_path: String,
|
||||||
|
pub changes: Vec<StateAnchorEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DomainStateReport {
|
||||||
|
pub profile: String,
|
||||||
|
pub workspace: Vec<u8>,
|
||||||
|
pub domain_id: [u8; 32],
|
||||||
|
pub anchors: Vec<StateAnchorReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovers a durable reset before any maintenance observer or runtime mount can expose state.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses malformed intent, changed authority, unsupported scope, orphan quarantine, or cleanup
|
||||||
|
/// failure.
|
||||||
|
pub fn recover_state_reset(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
store: &HostCoordinatorDomainStore,
|
||||||
|
) -> Result<(), StateMaintenanceError> {
|
||||||
|
let Some(intent) = store.state_reset_intent().map_err(failure)? else {
|
||||||
|
return require_no_orphan_reset_quarantine(configuration).map_err(failure);
|
||||||
|
};
|
||||||
|
require_intent(configuration, &intent)?;
|
||||||
|
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
|
||||||
|
complete_intent(&layout, &intent)?;
|
||||||
|
store.finish_state_reset(&intent).map_err(failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns content-free State Anchor metadata after mandatory recovery and structural checks.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses active or malformed Domain state, changed authority, mounts, links in fixed paths, and
|
||||||
|
/// unsupported upper-layer entries.
|
||||||
|
pub fn inspect_domain_state(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
store: &HostCoordinatorDomainStore,
|
||||||
|
) -> Result<DomainStateReport, StateMaintenanceError> {
|
||||||
|
recover_state_reset(configuration, store)?;
|
||||||
|
require_inactive(store)?;
|
||||||
|
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
|
||||||
|
let inspection = layout.inspect_state().map_err(failure)?;
|
||||||
|
Ok(report(configuration, &inspection))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits and completes one all-or-selected State Anchor reset generation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses active state, an arbitrary target, malformed durable state, changed authority, mounts,
|
||||||
|
/// links in fixed paths, quarantine collisions, and unsupported upper-layer entries.
|
||||||
|
pub fn reset_domain_state(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
store: &HostCoordinatorDomainStore,
|
||||||
|
scope: StateResetScopeV1,
|
||||||
|
) -> Result<DomainStateReport, StateMaintenanceError> {
|
||||||
|
recover_state_reset(configuration, store)?;
|
||||||
|
require_inactive(store)?;
|
||||||
|
validate_scope(configuration, &scope)?;
|
||||||
|
let intent = StateResetIntentV1::new(configuration.domain_id(), scope);
|
||||||
|
store.begin_state_reset(&intent).map_err(failure)?;
|
||||||
|
let layout = StateAnchorLayout::create(configuration).map_err(failure)?;
|
||||||
|
complete_intent(&layout, &intent)?;
|
||||||
|
store.finish_state_reset(&intent).map_err(failure)?;
|
||||||
|
inspect_domain_state(configuration, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_intent(
|
||||||
|
layout: &StateAnchorLayout<'_>,
|
||||||
|
intent: &StateResetIntentV1,
|
||||||
|
) -> Result<(), StateMaintenanceError> {
|
||||||
|
match intent.scope() {
|
||||||
|
StateResetScopeV1::All => layout
|
||||||
|
.complete_state_reset(StateAnchorResetScope::All)
|
||||||
|
.map_err(failure),
|
||||||
|
StateResetScopeV1::Selected(path) => {
|
||||||
|
let path = PathBuf::from(OsString::from_vec(path.clone()));
|
||||||
|
layout
|
||||||
|
.complete_state_reset(StateAnchorResetScope::Selected(&path))
|
||||||
|
.map_err(failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_intent(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
intent: &StateResetIntentV1,
|
||||||
|
) -> Result<(), StateMaintenanceError> {
|
||||||
|
if intent.domain_id() != configuration.domain_id() {
|
||||||
|
return Err(failure(
|
||||||
|
"reset intent Domain differs from resolved authority",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_scope(configuration, intent.scope())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_scope(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
scope: &StateResetScopeV1,
|
||||||
|
) -> Result<(), StateMaintenanceError> {
|
||||||
|
let StateResetScopeV1::Selected(bytes) = scope else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let selected = PathBuf::from(OsString::from_vec(bytes.clone()));
|
||||||
|
if configuration
|
||||||
|
.fingerprint_record()
|
||||||
|
.persistent_dirs
|
||||||
|
.iter()
|
||||||
|
.any(|configured| Path::new(configured) == selected)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(failure(
|
||||||
|
"reset target is not an exact configured persistent container path",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_inactive(store: &HostCoordinatorDomainStore) -> Result<(), StateMaintenanceError> {
|
||||||
|
let snapshot = store.snapshot().map_err(failure)?;
|
||||||
|
let inactive = snapshot
|
||||||
|
.generations
|
||||||
|
.iter()
|
||||||
|
.all(|generation| generation.durable_decision == DurableDecision::Clean)
|
||||||
|
&& snapshot
|
||||||
|
.participants
|
||||||
|
.iter()
|
||||||
|
.all(|participant| participant.state() == ParticipantState::Clean)
|
||||||
|
&& snapshot.sessions.iter().all(|session| {
|
||||||
|
matches!(
|
||||||
|
session.session_phase,
|
||||||
|
SessionPhase::Closed | SessionPhase::Failed
|
||||||
|
)
|
||||||
|
})
|
||||||
|
&& snapshot
|
||||||
|
.session_terminals
|
||||||
|
.iter()
|
||||||
|
.all(|terminal| terminal.state() == SessionTerminalState::Clean)
|
||||||
|
&& snapshot
|
||||||
|
.mailboxes
|
||||||
|
.iter()
|
||||||
|
.all(|mailbox| mailbox.state == ParticipantState::Clean);
|
||||||
|
if inactive {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(failure("Domain is active or has unfinished runtime state"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
inspection: &StateAnchorInspection,
|
||||||
|
) -> DomainStateReport {
|
||||||
|
let anchors = configuration
|
||||||
|
.fingerprint_record()
|
||||||
|
.persistent_dirs
|
||||||
|
.iter()
|
||||||
|
.map(|container_path| {
|
||||||
|
let relative = container_path.trim_start_matches('/').as_bytes();
|
||||||
|
let path = Path::new(container_path.trim_start_matches('/'));
|
||||||
|
let mut whiteout = path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(Path::new(""))
|
||||||
|
.as_os_str()
|
||||||
|
.as_encoded_bytes()
|
||||||
|
.to_vec();
|
||||||
|
if !whiteout.is_empty() {
|
||||||
|
whiteout.push(b'/');
|
||||||
|
}
|
||||||
|
whiteout.extend_from_slice(b".wh.");
|
||||||
|
whiteout.extend_from_slice(
|
||||||
|
path.file_name()
|
||||||
|
.expect("validated persistent path has a name")
|
||||||
|
.as_encoded_bytes(),
|
||||||
|
);
|
||||||
|
let changes = inspection
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry.path == relative
|
||||||
|
|| entry.path == whiteout
|
||||||
|
|| entry
|
||||||
|
.path
|
||||||
|
.strip_prefix(relative)
|
||||||
|
.is_some_and(|suffix| suffix.first() == Some(&b'/'))
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
StateAnchorReport {
|
||||||
|
container_path: container_path.clone(),
|
||||||
|
changes,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
DomainStateReport {
|
||||||
|
profile: configuration.selected_profile().to_owned(),
|
||||||
|
workspace: configuration
|
||||||
|
.workspace()
|
||||||
|
.canonical_label()
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
domain_id: *configuration.domain_id().as_bytes(),
|
||||||
|
anchors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use lazaret_core::configuration::{
|
||||||
|
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
|
||||||
|
};
|
||||||
|
use lazaret_core::identity::{
|
||||||
|
ConfigurationFingerprint, CoordinatorId, GenerationId, NetworkSidecarId, SandboxId,
|
||||||
|
};
|
||||||
|
use lazaret_core::records::{Domain, Generation};
|
||||||
|
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||||
|
use lazaret_runtime_supervisor::sandbox::production_sandbox_layout;
|
||||||
|
use rustix::fs::{FileType, Mode, XattrFlags, fsetxattr, makedev, mknodat};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum EntryCase {
|
||||||
|
Directory,
|
||||||
|
File,
|
||||||
|
Symlink,
|
||||||
|
WhiteoutFile,
|
||||||
|
WhiteoutDevice,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_reset_boundary_recovers_to_new_empty() {
|
||||||
|
for entry_case in [
|
||||||
|
EntryCase::Directory,
|
||||||
|
EntryCase::File,
|
||||||
|
EntryCase::Symlink,
|
||||||
|
EntryCase::WhiteoutFile,
|
||||||
|
EntryCase::WhiteoutDevice,
|
||||||
|
] {
|
||||||
|
for boundary in 0..9 {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let configuration = test_configuration(root.path());
|
||||||
|
let store = open_store(&configuration);
|
||||||
|
let layout = StateAnchorLayout::create(&configuration).unwrap();
|
||||||
|
let anchor = domain_root(&configuration).join("state-anchor");
|
||||||
|
let upper = anchor.join("upper");
|
||||||
|
fs::create_dir(upper.join("unrelated")).unwrap();
|
||||||
|
fs::write(upper.join("unrelated/value"), b"unchanged").unwrap();
|
||||||
|
create_entry(&upper, entry_case);
|
||||||
|
let intent = StateResetIntentV1::new(
|
||||||
|
configuration.domain_id(),
|
||||||
|
StateResetScopeV1::Selected(b"/usr".to_vec()),
|
||||||
|
);
|
||||||
|
store.begin_state_reset(&intent).unwrap();
|
||||||
|
stage_boundary(&anchor, &upper, entry_case, boundary);
|
||||||
|
if boundary == 8 {
|
||||||
|
store.finish_state_reset(&intent).unwrap();
|
||||||
|
}
|
||||||
|
drop(layout);
|
||||||
|
|
||||||
|
recover_state_reset(&configuration, &store).unwrap();
|
||||||
|
|
||||||
|
assert!(!upper.join("usr").exists());
|
||||||
|
assert!(!upper.join("usr").is_symlink());
|
||||||
|
assert!(!upper.join(".wh.usr").exists());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(upper.join("unrelated/value")).unwrap(),
|
||||||
|
b"unchanged"
|
||||||
|
);
|
||||||
|
assert!(store.state_reset_intent().unwrap().is_none());
|
||||||
|
assert!(!anchor.join("reset-quarantine").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_mismatched_and_orphan_recovery_state_refuses() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let configuration = test_configuration(root.path());
|
||||||
|
let store = open_store(&configuration);
|
||||||
|
let foreign = StateResetIntentV1::new(
|
||||||
|
configuration.domain_id(),
|
||||||
|
StateResetScopeV1::Selected(b"/etc".to_vec()),
|
||||||
|
);
|
||||||
|
store.begin_state_reset(&foreign).unwrap();
|
||||||
|
assert!(recover_state_reset(&configuration, &store).is_err());
|
||||||
|
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let configuration = test_configuration(root.path());
|
||||||
|
let store = open_store(&configuration);
|
||||||
|
StateAnchorLayout::create(&configuration).unwrap();
|
||||||
|
fs::create_dir(domain_root(&configuration).join("state-anchor/reset-quarantine")).unwrap();
|
||||||
|
assert!(recover_state_reset(&configuration, &store).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_domain_refuses_inspection_and_reset() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let configuration = test_configuration(root.path());
|
||||||
|
let store = open_store(&configuration);
|
||||||
|
store
|
||||||
|
.apply(&[crate::domain_store::DomainStoreMutation::PutGeneration(
|
||||||
|
Generation {
|
||||||
|
domain_id: configuration.domain_id(),
|
||||||
|
generation_id: GenerationId::new(1),
|
||||||
|
configuration_fingerprint: ConfigurationFingerprint::new([2; 32]),
|
||||||
|
coordinator_id: CoordinatorId::new([3; 32]),
|
||||||
|
network_sidecar_id: NetworkSidecarId::new([4; 32]),
|
||||||
|
sandbox_id: SandboxId::new([5; 32]),
|
||||||
|
durable_decision: DurableDecision::None,
|
||||||
|
},
|
||||||
|
)])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(inspect_domain_state(&configuration, &store).is_err());
|
||||||
|
assert!(reset_domain_state(&configuration, &store, StateResetScopeV1::All).is_err());
|
||||||
|
assert!(store.state_reset_intent().unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_entry(upper: &Path, entry_case: EntryCase) {
|
||||||
|
match entry_case {
|
||||||
|
EntryCase::Directory => {
|
||||||
|
fs::create_dir(upper.join("usr")).unwrap();
|
||||||
|
fs::write(upper.join("usr/value"), b"state").unwrap();
|
||||||
|
fs::write(upper.join("usr/.wh..wh..opq"), b"").unwrap();
|
||||||
|
let directory = File::open(upper.join("usr")).unwrap();
|
||||||
|
fsetxattr(
|
||||||
|
directory,
|
||||||
|
"user.fuseoverlayfs.opaque",
|
||||||
|
b"y",
|
||||||
|
XattrFlags::empty(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
EntryCase::File => fs::write(upper.join("usr"), b"state").unwrap(),
|
||||||
|
EntryCase::Symlink => symlink("/foreign", upper.join("usr")).unwrap(),
|
||||||
|
EntryCase::WhiteoutFile => fs::write(upper.join(".wh.usr"), b"").unwrap(),
|
||||||
|
EntryCase::WhiteoutDevice => mknodat(
|
||||||
|
rustix::fs::CWD,
|
||||||
|
upper.join("usr"),
|
||||||
|
FileType::CharacterDevice,
|
||||||
|
Mode::empty(),
|
||||||
|
makedev(0, 0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage_boundary(anchor: &Path, upper: &Path, entry_case: EntryCase, boundary: usize) {
|
||||||
|
if boundary == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let quarantine = anchor.join("reset-quarantine");
|
||||||
|
fs::create_dir(&quarantine).unwrap();
|
||||||
|
if boundary == 1 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (source, destination) = match entry_case {
|
||||||
|
EntryCase::WhiteoutFile => (upper.join(".wh.usr"), quarantine.join("whiteout")),
|
||||||
|
_ => (upper.join("usr"), quarantine.join("entry")),
|
||||||
|
};
|
||||||
|
fs::rename(source, &destination).unwrap();
|
||||||
|
if boundary <= 3 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remove_entry(&destination);
|
||||||
|
if boundary <= 5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs::remove_dir(&quarantine).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_entry(path: &Path) {
|
||||||
|
let metadata = fs::symlink_metadata(path).unwrap();
|
||||||
|
if metadata.file_type().is_dir() {
|
||||||
|
fs::remove_dir_all(path).unwrap();
|
||||||
|
} else {
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_store(configuration: &ResolvedDomainConfiguration) -> HostCoordinatorDomainStore {
|
||||||
|
let root = domain_root(configuration);
|
||||||
|
let lock = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(root.join("domain.lock"))
|
||||||
|
.unwrap();
|
||||||
|
HostCoordinatorDomainStore::open(
|
||||||
|
try_exclusive_flock(lock).unwrap(),
|
||||||
|
&root.join("domain.redb"),
|
||||||
|
&Domain {
|
||||||
|
domain_id: configuration.domain_id(),
|
||||||
|
profile_name: configuration.selected_profile().to_owned(),
|
||||||
|
canonical_workspace: configuration.workspace().canonical_label().clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_root(configuration: &ResolvedDomainConfiguration) -> PathBuf {
|
||||||
|
PathBuf::from(std::ffi::OsString::from_vec(
|
||||||
|
configuration
|
||||||
|
.persistent_domain_root()
|
||||||
|
.canonical_label()
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_configuration(root: &Path) -> ResolvedDomainConfiguration {
|
||||||
|
let configuration_directory = root.join("configuration");
|
||||||
|
let workspace = root.join("workspace");
|
||||||
|
let runtime = root.join("runtime");
|
||||||
|
fs::create_dir(&configuration_directory).unwrap();
|
||||||
|
fs::create_dir(&workspace).unwrap();
|
||||||
|
fs::create_dir(&runtime).unwrap();
|
||||||
|
let configuration_path = configuration_directory.join("lazaret.toml");
|
||||||
|
fs::write(
|
||||||
|
&configuration_path,
|
||||||
|
format!(
|
||||||
|
"schema = \"lazaret-trusted-host-config-v1\"\n[storage]\ndata_root = \"{}\"\n[network]\ndefault_allow = [\"192.0.2.10/32\"]\n[profiles.default]\nimage = \"example.invalid/state:v1\"\npersistent_dirs = [\"/usr\"]\n[profiles.default.readiness]\ndenied = [{{ address = \"10.20.30.40\", port = 8443 }}]\npermitted = [{{ address = \"192.0.2.10\", port = 443 }}]\ndns = [{{ resolver = \"198.51.100.53\", query = \"example.invalid\" }}]\n",
|
||||||
|
root.join("data").display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
load_trusted_configuration(ConfigurationLoadRequest {
|
||||||
|
explicit_configuration_path: Some(configuration_path),
|
||||||
|
selected_profile: "default".into(),
|
||||||
|
workspace,
|
||||||
|
home: root.join("home"),
|
||||||
|
xdg_config_home: None,
|
||||||
|
xdg_data_home: None,
|
||||||
|
xdg_runtime_dir: runtime,
|
||||||
|
environment: BTreeMap::new(),
|
||||||
|
sandbox_layout: production_sandbox_layout().unwrap(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,9 @@ use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use lazaret_core::identity::{
|
use lazaret_core::identity::{
|
||||||
BootId, BundleDigest, ClientId, ConfigurationFingerprint, HostPath, HostPidLocator, MountId,
|
BootId, BundleDigest, ClientId, ConfigurationFingerprint, DeviceId, HostPath, HostPidLocator,
|
||||||
MountIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
|
InodeId, MountId, MountIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId,
|
||||||
ProcessStartTimeTicks, RequestId, SandboxId, SessionAnchorId,
|
PidfsProcessAuthorityV1, ProcessStartTimeTicks, RequestId, SandboxId, SessionAnchorId,
|
||||||
};
|
};
|
||||||
use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability};
|
use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability};
|
||||||
use lazaret_core::records::{
|
use lazaret_core::records::{
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ use lazaret_linux::process::{
|
||||||
ProcessOwnershipContext, RetainedProcess, TargetCommand, child_broker, require_pidfs_magic,
|
ProcessOwnershipContext, RetainedProcess, TargetCommand, child_broker, require_pidfs_magic,
|
||||||
};
|
};
|
||||||
use rustix::fs::{
|
use rustix::fs::{
|
||||||
AtFlags, FileType, Mode, OFlags, ResolveFlags, mkdirat, openat2, renameat, unlinkat,
|
AtFlags, FileType, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, mkdirat, openat2, renameat,
|
||||||
|
renameat_with, statat, unlinkat,
|
||||||
};
|
};
|
||||||
use rustix::io::dup;
|
use rustix::io::dup;
|
||||||
use rustix::thread::{ThreadNameSpaceType, move_into_thread_name_spaces};
|
use rustix::thread::{ThreadNameSpaceType, move_into_thread_name_spaces};
|
||||||
|
|
@ -37,6 +38,7 @@ const MOUNT_POLL_INTERVAL: Duration = Duration::from_millis(2);
|
||||||
const PRIVATE_RECOVERY_ROLE: &str = "--lazaret-private-state-anchor-recovery";
|
const PRIVATE_RECOVERY_ROLE: &str = "--lazaret-private-state-anchor-recovery";
|
||||||
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
pub const PROTECTED_RUNTIME_FILES: [&str; 3] = ["resolv.conf", "hosts", "hostname"];
|
pub const PROTECTED_RUNTIME_FILES: [&str; 3] = ["resolv.conf", "hosts", "hostname"];
|
||||||
|
const RESET_QUARANTINE: &str = "reset-quarantine";
|
||||||
|
|
||||||
pub struct ProtectedRuntimeFiles<'a> {
|
pub struct ProtectedRuntimeFiles<'a> {
|
||||||
pub resolv_conf: &'a [u8],
|
pub resolv_conf: &'a [u8],
|
||||||
|
|
@ -301,6 +303,153 @@ impl<'configuration> StateAnchorLayout<'configuration> {
|
||||||
&& ownership.work_path.as_ref() == Some(&host_path(&self.work_label))
|
&& ownership.work_path.as_ref() == Some(&host_path(&self.work_label))
|
||||||
&& ownership.merged_path.as_ref() == Some(&host_path(&self.merged_label))
|
&& ownership.merged_path.as_ref() == Some(&host_path(&self.merged_label))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verifies inactive structural authority and returns content-free upper-layer metadata.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses changed roots, mounts, fixed-path links, orphan quarantine, and unsupported entries.
|
||||||
|
pub fn inspect_state(&self) -> Result<StateAnchorInspection, StateAnchorError> {
|
||||||
|
self.require_maintenance_ready()?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
inspect_directory(&self.upper_descriptor, Path::new(""), &mut entries)?;
|
||||||
|
entries.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
|
Ok(StateAnchorInspection { entries })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes the physical half of one durable reset intent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses changed authority, mounts, unsupported scope, collisions, links in authority paths,
|
||||||
|
/// or incomplete durable cleanup.
|
||||||
|
pub fn complete_state_reset(
|
||||||
|
&self,
|
||||||
|
scope: StateAnchorResetScope<'_>,
|
||||||
|
) -> Result<(), StateAnchorError> {
|
||||||
|
self.require_maintenance_ready_allowing_quarantine()?;
|
||||||
|
let selected = match scope {
|
||||||
|
StateAnchorResetScope::All => None,
|
||||||
|
StateAnchorResetScope::Selected(destination) => {
|
||||||
|
Some(self.selected_relative(destination)?)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let quarantine = open_or_create_quarantine(&self.persistent_anchor)?;
|
||||||
|
if let Some(relative) = selected {
|
||||||
|
move_selected_entries(&self.upper_descriptor, &quarantine, &relative)?;
|
||||||
|
} else {
|
||||||
|
move_all_entries(&self.upper_descriptor, &quarantine)?;
|
||||||
|
}
|
||||||
|
remove_directory_contents(&quarantine)?;
|
||||||
|
rustix::fs::fsync(&quarantine).map_err(io::Error::from)?;
|
||||||
|
unlinkat(
|
||||||
|
self.persistent_anchor.as_fd(),
|
||||||
|
RESET_QUARANTINE,
|
||||||
|
AtFlags::REMOVEDIR,
|
||||||
|
)
|
||||||
|
.map_err(io::Error::from)?;
|
||||||
|
rustix::fs::fsync(&self.persistent_anchor).map_err(io::Error::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses a physical quarantine that lacks a matching durable reset intent.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error for changed layout or an orphan quarantine.
|
||||||
|
pub fn require_no_reset_quarantine(&self) -> Result<(), StateAnchorError> {
|
||||||
|
self.revalidate_authorities()?;
|
||||||
|
match open_directory(&self.persistent_anchor, Path::new(RESET_QUARANTINE)) {
|
||||||
|
Ok(_) => Err(StateAnchorError::InvalidLayout(
|
||||||
|
"State Anchor reset quarantine has no durable intent",
|
||||||
|
)),
|
||||||
|
Err(StateAnchorError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_relative(&self, destination: &Path) -> Result<PathBuf, StateAnchorError> {
|
||||||
|
if !self
|
||||||
|
.persistent_destinations
|
||||||
|
.iter()
|
||||||
|
.any(|configured| configured == destination.strip_prefix("/").unwrap_or(destination))
|
||||||
|
{
|
||||||
|
return Err(StateAnchorError::InvalidLayout(
|
||||||
|
"reset target is not a configured persistent path",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let relative = destination.strip_prefix("/").unwrap_or(destination);
|
||||||
|
validate_relative_path(relative)?;
|
||||||
|
Ok(relative.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_maintenance_ready(&self) -> Result<(), StateAnchorError> {
|
||||||
|
self.require_maintenance_ready_allowing_quarantine()?;
|
||||||
|
self.require_no_reset_quarantine()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_maintenance_ready_allowing_quarantine(&self) -> Result<(), StateAnchorError> {
|
||||||
|
self.require_unmounted()?;
|
||||||
|
if directory_has_entries(&self.work_descriptor)? {
|
||||||
|
return Err(StateAnchorError::InvalidLayout(
|
||||||
|
"State Anchor work directory is not empty",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum StateAnchorEntryKind {
|
||||||
|
Directory,
|
||||||
|
RegularFile,
|
||||||
|
Symlink,
|
||||||
|
Whiteout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct StateAnchorEntry {
|
||||||
|
pub path: Vec<u8>,
|
||||||
|
pub kind: StateAnchorEntryKind,
|
||||||
|
pub mode: u32,
|
||||||
|
pub uid: u32,
|
||||||
|
pub gid: u32,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct StateAnchorInspection {
|
||||||
|
pub entries: Vec<StateAnchorEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum StateAnchorResetScope<'a> {
|
||||||
|
All,
|
||||||
|
Selected(&'a Path),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses an orphan reset quarantine without opening transient mount paths.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Refuses changed persistent authority, fixed-path links, and orphan quarantine.
|
||||||
|
pub fn require_no_orphan_reset_quarantine(
|
||||||
|
configuration: &ResolvedDomainConfiguration,
|
||||||
|
) -> Result<(), StateAnchorError> {
|
||||||
|
let root = configuration
|
||||||
|
.persistent_domain_root()
|
||||||
|
.open_authority()
|
||||||
|
.map_err(|_| StateAnchorError::InvalidLayout("persistent Domain authority changed"))?;
|
||||||
|
let anchor = match open_directory(&root, Path::new("state-anchor")) {
|
||||||
|
Ok(anchor) => anchor,
|
||||||
|
Err(StateAnchorError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
match open_directory(&anchor, Path::new(RESET_QUARANTINE)) {
|
||||||
|
Ok(_) => Err(StateAnchorError::InvalidLayout(
|
||||||
|
"State Anchor reset quarantine has no durable intent",
|
||||||
|
)),
|
||||||
|
Err(StateAnchorError::Io(error)) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pins the exact mounted and lower authorities until the view is dropped before unmount.
|
/// Pins the exact mounted and lower authorities until the view is dropped before unmount.
|
||||||
|
|
@ -1297,6 +1446,215 @@ pub fn observe_mount_absence(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn inspect_directory(
|
||||||
|
directory: impl AsFd,
|
||||||
|
prefix: &Path,
|
||||||
|
entries: &mut Vec<StateAnchorEntry>,
|
||||||
|
) -> Result<(), StateAnchorError> {
|
||||||
|
for name in directory_names(&directory)? {
|
||||||
|
let descriptor = openat2(
|
||||||
|
directory.as_fd(),
|
||||||
|
Path::new(&name),
|
||||||
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
confined_resolve_flags(),
|
||||||
|
)
|
||||||
|
.map_err(io::Error::from)?;
|
||||||
|
let metadata = rustix::fs::fstat(&descriptor).map_err(io::Error::from)?;
|
||||||
|
let kind = FileType::from_raw_mode(metadata.st_mode);
|
||||||
|
let relative = prefix.join(&name);
|
||||||
|
let entry_kind = if kind.is_dir() {
|
||||||
|
StateAnchorEntryKind::Directory
|
||||||
|
} else if kind.is_file() {
|
||||||
|
StateAnchorEntryKind::RegularFile
|
||||||
|
} else if kind.is_symlink() {
|
||||||
|
StateAnchorEntryKind::Symlink
|
||||||
|
} else if kind.is_char_device()
|
||||||
|
&& rustix::fs::major(metadata.st_rdev) == 0
|
||||||
|
&& rustix::fs::minor(metadata.st_rdev) == 0
|
||||||
|
{
|
||||||
|
StateAnchorEntryKind::Whiteout
|
||||||
|
} else {
|
||||||
|
return Err(StateAnchorError::InvalidLayout(
|
||||||
|
"upper layer contains an unsupported entry type",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
entries.push(StateAnchorEntry {
|
||||||
|
path: relative.as_os_str().as_bytes().to_vec(),
|
||||||
|
kind: entry_kind,
|
||||||
|
mode: metadata.st_mode,
|
||||||
|
uid: metadata.st_uid,
|
||||||
|
gid: metadata.st_gid,
|
||||||
|
size: u64::try_from(metadata.st_size).map_err(|_| {
|
||||||
|
StateAnchorError::InvalidLayout("upper-layer entry has a negative size")
|
||||||
|
})?,
|
||||||
|
});
|
||||||
|
if kind.is_dir() {
|
||||||
|
let child = open_directory(&directory, Path::new(&name))?;
|
||||||
|
inspect_directory(child, &relative, entries)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_or_create_quarantine(parent: impl AsFd) -> Result<OwnedFd, StateAnchorError> {
|
||||||
|
match mkdirat(parent.as_fd(), RESET_QUARANTINE, Mode::from_raw_mode(0o700)) {
|
||||||
|
Ok(()) => rustix::fs::fsync(parent.as_fd()).map_err(io::Error::from)?,
|
||||||
|
Err(rustix::io::Errno::EXIST) => {}
|
||||||
|
Err(error) => return Err(io::Error::from(error).into()),
|
||||||
|
}
|
||||||
|
open_directory(parent, Path::new(RESET_QUARANTINE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_all_entries(upper: impl AsFd, quarantine: impl AsFd) -> Result<(), StateAnchorError> {
|
||||||
|
for name in directory_names(&upper)? {
|
||||||
|
move_if_present(&upper, Path::new(&name), &quarantine, Path::new(&name))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_selected_entries(
|
||||||
|
upper: impl AsFd,
|
||||||
|
quarantine: impl AsFd,
|
||||||
|
relative: &Path,
|
||||||
|
) -> Result<(), StateAnchorError> {
|
||||||
|
let leaf = relative.file_name().ok_or(StateAnchorError::InvalidLayout(
|
||||||
|
"reset target has no final component",
|
||||||
|
))?;
|
||||||
|
let parent = open_optional_parent(&upper, relative.parent().unwrap_or(Path::new("")))?;
|
||||||
|
let Some(parent) = parent else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
move_if_present(&parent, Path::new(leaf), &quarantine, Path::new("entry"))?;
|
||||||
|
let mut whiteout = OsString::from(".wh.");
|
||||||
|
whiteout.push(leaf);
|
||||||
|
move_if_present(
|
||||||
|
&parent,
|
||||||
|
Path::new(&whiteout),
|
||||||
|
&quarantine,
|
||||||
|
Path::new("whiteout"),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_optional_parent(root: impl AsFd, path: &Path) -> Result<Option<OwnedFd>, StateAnchorError> {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
return rustix::io::dup(root.as_fd())
|
||||||
|
.map(Some)
|
||||||
|
.map_err(io::Error::from)
|
||||||
|
.map_err(StateAnchorError::from);
|
||||||
|
}
|
||||||
|
match openat2(
|
||||||
|
root.as_fd(),
|
||||||
|
path,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
confined_resolve_flags(),
|
||||||
|
) {
|
||||||
|
Ok(directory) => Ok(Some(directory)),
|
||||||
|
Err(rustix::io::Errno::NOENT) => Ok(None),
|
||||||
|
Err(error) => Err(io::Error::from(error).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_if_present(
|
||||||
|
source_parent: impl AsFd,
|
||||||
|
source: &Path,
|
||||||
|
destination_parent: impl AsFd,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<(), StateAnchorError> {
|
||||||
|
let source_exists = entry_exists(source_parent.as_fd(), source)?;
|
||||||
|
let destination_exists = entry_exists(destination_parent.as_fd(), destination)?;
|
||||||
|
match (source_exists, destination_exists) {
|
||||||
|
(false, false | true) => Ok(()),
|
||||||
|
(true, true) => Err(StateAnchorError::InvalidLayout(
|
||||||
|
"reset source and quarantine entry both exist",
|
||||||
|
)),
|
||||||
|
(true, false) => {
|
||||||
|
renameat_with(
|
||||||
|
source_parent.as_fd(),
|
||||||
|
source,
|
||||||
|
destination_parent.as_fd(),
|
||||||
|
destination,
|
||||||
|
RenameFlags::NOREPLACE,
|
||||||
|
)
|
||||||
|
.map_err(io::Error::from)?;
|
||||||
|
rustix::fs::fsync(source_parent.as_fd()).map_err(io::Error::from)?;
|
||||||
|
rustix::fs::fsync(destination_parent.as_fd()).map_err(io::Error::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_exists(parent: impl AsFd, path: &Path) -> Result<bool, StateAnchorError> {
|
||||||
|
match statat(parent.as_fd(), path, AtFlags::SYMLINK_NOFOLLOW) {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(rustix::io::Errno::NOENT) => Ok(false),
|
||||||
|
Err(error) => Err(io::Error::from(error).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_directory_contents(directory: impl AsFd) -> Result<(), StateAnchorError> {
|
||||||
|
for name in directory_names(&directory)? {
|
||||||
|
let path = Path::new(&name);
|
||||||
|
let descriptor = openat2(
|
||||||
|
directory.as_fd(),
|
||||||
|
path,
|
||||||
|
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||||
|
Mode::empty(),
|
||||||
|
confined_resolve_flags(),
|
||||||
|
)
|
||||||
|
.map_err(io::Error::from)?;
|
||||||
|
let kind = FileType::from_raw_mode(
|
||||||
|
rustix::fs::fstat(&descriptor)
|
||||||
|
.map_err(io::Error::from)?
|
||||||
|
.st_mode,
|
||||||
|
);
|
||||||
|
if kind.is_dir() {
|
||||||
|
let child = open_directory(&directory, path)?;
|
||||||
|
remove_directory_contents(&child)?;
|
||||||
|
unlinkat(directory.as_fd(), path, AtFlags::REMOVEDIR).map_err(io::Error::from)?;
|
||||||
|
} else {
|
||||||
|
unlinkat(directory.as_fd(), path, AtFlags::empty()).map_err(io::Error::from)?;
|
||||||
|
}
|
||||||
|
rustix::fs::fsync(directory.as_fd()).map_err(io::Error::from)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_names(directory: impl AsFd) -> Result<Vec<OsString>, StateAnchorError> {
|
||||||
|
let descriptor = rustix::io::dup(directory.as_fd()).map_err(io::Error::from)?;
|
||||||
|
let mut buffer = [std::mem::MaybeUninit::uninit(); 8192];
|
||||||
|
let mut raw = RawDir::new(descriptor, &mut buffer);
|
||||||
|
let mut names = Vec::new();
|
||||||
|
while let Some(entry) = raw.next() {
|
||||||
|
let entry = entry.map_err(io::Error::from)?;
|
||||||
|
let bytes = entry.file_name().to_bytes();
|
||||||
|
if bytes != b"." && bytes != b".." {
|
||||||
|
names.push(OsString::from_vec(bytes.to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_has_entries(directory: impl AsFd) -> Result<bool, StateAnchorError> {
|
||||||
|
Ok(!directory_names(directory)?.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_relative_path(path: &Path) -> Result<(), StateAnchorError> {
|
||||||
|
if path.as_os_str().is_empty()
|
||||||
|
|| path
|
||||||
|
.components()
|
||||||
|
.any(|component| !matches!(component, Component::Normal(_)))
|
||||||
|
{
|
||||||
|
Err(StateAnchorError::InvalidLayout(
|
||||||
|
"reset target is not a normalized relative path",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create_directory(parent: impl AsFd, name: &str) -> Result<(), StateAnchorError> {
|
fn create_directory(parent: impl AsFd, name: &str) -> Result<(), StateAnchorError> {
|
||||||
match mkdirat(parent.as_fd(), name, Mode::from_raw_mode(0o700)) {
|
match mkdirat(parent.as_fd(), name, Mode::from_raw_mode(0o700)) {
|
||||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,74 @@ use lazaret_core::identity::{
|
||||||
use lazaret_core::records::{ArtifactState, PreparedArtifact};
|
use lazaret_core::records::{ArtifactState, PreparedArtifact};
|
||||||
use lazaret_state_anchor::{
|
use lazaret_state_anchor::{
|
||||||
ProtectedRuntimeFiles, RuntimeStateAnchorAuthority, RuntimeStateAnchorInherited,
|
ProtectedRuntimeFiles, RuntimeStateAnchorAuthority, RuntimeStateAnchorInherited,
|
||||||
StateAnchorLaunchAuthority, StateAnchorLayout,
|
StateAnchorLaunchAuthority, StateAnchorLayout, StateAnchorResetScope,
|
||||||
};
|
};
|
||||||
|
|
||||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_reset_removes_exact_state_and_whiteout_without_touching_siblings() {
|
||||||
|
let tree = TestTree::new();
|
||||||
|
let configuration = configuration(&tree);
|
||||||
|
let layout = StateAnchorLayout::create(&configuration).unwrap();
|
||||||
|
let upper = domain_path(&configuration).join("state-anchor/upper");
|
||||||
|
fs::create_dir_all(upper.join("usr/bin")).unwrap();
|
||||||
|
fs::write(upper.join("usr/bin/tool"), b"state").unwrap();
|
||||||
|
fs::write(upper.join(".wh.usr"), b"").unwrap();
|
||||||
|
fs::create_dir(upper.join("var")).unwrap();
|
||||||
|
fs::write(upper.join("var/unrelated"), b"unchanged").unwrap();
|
||||||
|
|
||||||
|
let inspection = layout.inspect_state().unwrap();
|
||||||
|
assert!(
|
||||||
|
inspection
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.path == b"usr/bin/tool")
|
||||||
|
);
|
||||||
|
layout
|
||||||
|
.complete_state_reset(StateAnchorResetScope::Selected(Path::new("/usr")))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!upper.join("usr").exists());
|
||||||
|
assert!(!upper.join(".wh.usr").exists());
|
||||||
|
assert_eq!(fs::read(upper.join("var/unrelated")).unwrap(), b"unchanged");
|
||||||
|
layout.require_no_reset_quarantine().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_reset_unlinks_symlink_state_without_following_it() {
|
||||||
|
let tree = TestTree::new();
|
||||||
|
let configuration = configuration(&tree);
|
||||||
|
let layout = StateAnchorLayout::create(&configuration).unwrap();
|
||||||
|
let upper = domain_path(&configuration).join("state-anchor/upper");
|
||||||
|
let foreign = tree.path.join("foreign");
|
||||||
|
fs::create_dir(&foreign).unwrap();
|
||||||
|
fs::write(foreign.join("value"), b"foreign").unwrap();
|
||||||
|
std::os::unix::fs::symlink(&foreign, upper.join("link")).unwrap();
|
||||||
|
|
||||||
|
layout
|
||||||
|
.complete_state_reset(StateAnchorResetScope::All)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(upper.read_dir().unwrap().next().is_none());
|
||||||
|
assert_eq!(fs::read(foreign.join("value")).unwrap(), b"foreign");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_target_and_orphan_quarantine_are_refused() {
|
||||||
|
let tree = TestTree::new();
|
||||||
|
let configuration = configuration(&tree);
|
||||||
|
let layout = StateAnchorLayout::create(&configuration).unwrap();
|
||||||
|
assert!(
|
||||||
|
layout
|
||||||
|
.complete_state_reset(StateAnchorResetScope::Selected(Path::new("/etc")))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
fs::create_dir(domain_path(&configuration).join("state-anchor/reset-quarantine")).unwrap();
|
||||||
|
assert!(layout.require_no_reset_quarantine().is_err());
|
||||||
|
assert!(layout.inspect_state().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replaced_runtime_root_is_refused_before_runtime_file_write() {
|
fn replaced_runtime_root_is_refused_before_runtime_file_write() {
|
||||||
let tree = TestTree::new();
|
let tree = TestTree::new();
|
||||||
|
|
@ -226,6 +289,18 @@ fn prepared_artifact(lower: &Path) -> PreparedArtifact {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn domain_path(
|
||||||
|
configuration: &lazaret_core::configuration::ResolvedDomainConfiguration,
|
||||||
|
) -> PathBuf {
|
||||||
|
PathBuf::from(OsString::from_vec(
|
||||||
|
configuration
|
||||||
|
.persistent_domain_root()
|
||||||
|
.canonical_label()
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
struct TestTree {
|
struct TestTree {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,20 @@ classDiagram
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StateResetIntentV1 {
|
||||||
|
<<serialized>>
|
||||||
|
schema
|
||||||
|
domain_id
|
||||||
|
scope
|
||||||
|
}
|
||||||
|
|
||||||
|
class StateResetScopeV1 {
|
||||||
|
<<serialized>>
|
||||||
|
<<union>>
|
||||||
|
All
|
||||||
|
Selected
|
||||||
|
}
|
||||||
|
|
||||||
DomainStore "1" *-- "1" StoreMetadata
|
DomainStore "1" *-- "1" StoreMetadata
|
||||||
DomainStore "1" *-- "1" Domain
|
DomainStore "1" *-- "1" Domain
|
||||||
Domain "1" --> "0..*" Generation
|
Domain "1" --> "0..*" Generation
|
||||||
|
|
@ -394,6 +408,8 @@ classDiagram
|
||||||
SessionTerminal --> SessionTerminalState
|
SessionTerminal --> SessionTerminalState
|
||||||
Generation "1" --> "0..1" Mailbox
|
Generation "1" --> "0..1" Mailbox
|
||||||
Mailbox --> ParticipantState
|
Mailbox --> ParticipantState
|
||||||
|
DomainStore "1" *-- "0..1" StateResetIntentV1
|
||||||
|
StateResetIntentV1 --> StateResetScopeV1
|
||||||
```
|
```
|
||||||
|
|
||||||
`durable_decision` and `session_phase` contain values defined by the application
|
`durable_decision` and `session_phase` contain values defined by the application
|
||||||
|
|
@ -403,6 +419,20 @@ identity is `lazaret-domain-store-v1` with version `1`. Unknown, unversioned,
|
||||||
older, and newer metadata refuses before any row is interpreted. The
|
older, and newer metadata refuses before any row is interpreted. The
|
||||||
Storage Catalog remains `lazaret-storage-catalog-v1` with version `1`.
|
Storage Catalog remains `lazaret-storage-catalog-v1` with version `1`.
|
||||||
|
|
||||||
|
The reset-intent schema is exactly `lazaret-state-reset-v1`. `Selected` contains
|
||||||
|
one exact configured persistent container path as pathname bytes. The singleton
|
||||||
|
intent is committed before physical quarantine begins and removed only after
|
||||||
|
confined cleanup completes. Unknown schemas and mismatched Domain or scope
|
||||||
|
bindings refuse before physical state is interpreted.
|
||||||
|
|
||||||
|
### State maintenance output
|
||||||
|
|
||||||
|
Machine output uses schema `lazaret-state-output-v1`. It contains the operation,
|
||||||
|
Domain ID, profile, hexadecimal canonical-workspace bytes, a list of configured
|
||||||
|
State Anchors, structural change metadata, and `contents_included = false`.
|
||||||
|
Each change contains hexadecimal pathname bytes, entry kind, mode, owner UID and
|
||||||
|
GID, and size. It never contains persisted file contents.
|
||||||
|
|
||||||
### Host process identity and staged ownership
|
### Host process identity and staged ownership
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,11 @@ PROCESS_LINT_ALLOWANCES = {
|
||||||
"match_recovery_process",
|
"match_recovery_process",
|
||||||
},
|
},
|
||||||
"crates/lazaret-host-coordinator/tests/runtime-composition.rs": {"run_benchmark_guest"},
|
"crates/lazaret-host-coordinator/tests/runtime-composition.rs": {"run_benchmark_guest"},
|
||||||
|
"crates/lazaret-client/tests/state_cli.rs": {
|
||||||
|
"machine_and_human_discovery_are_versioned_and_content_free",
|
||||||
|
"reset_refuses_an_arbitrary_container_or_host_path_before_state_access",
|
||||||
|
"command",
|
||||||
|
},
|
||||||
"crates/lazaret-linux/tests/acquisition.rs": {
|
"crates/lazaret-linux/tests/acquisition.rs": {
|
||||||
"run_acquisition",
|
"run_acquisition",
|
||||||
"successful_acquisition",
|
"successful_acquisition",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue