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",
|
||||
"rustix",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ rustix.workspace = true
|
|||
rustls.workspace = true
|
||||
sha2.workspace = true
|
||||
nix.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use sha2::{Digest, Sha256};
|
|||
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
|
||||
|
||||
pub mod production;
|
||||
pub mod state;
|
||||
pub mod terminal;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ fn main() {
|
|||
}
|
||||
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() {
|
||||
Ok(path) => path,
|
||||
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(
|
||||
openat(
|
||||
root,
|
||||
|
|
@ -416,7 +416,7 @@ fn publication_matches(
|
|||
== configuration.configuration_fingerprint()
|
||||
}
|
||||
|
||||
fn load_configuration(
|
||||
pub(crate) fn load_configuration(
|
||||
command: &ClientCommand,
|
||||
) -> Result<ResolvedDomainConfiguration, Box<dyn std::error::Error>> {
|
||||
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,
|
||||
"StorageCatalog" => StorageCatalog,
|
||||
"PrepareStageState" => PrepareStageState;
|
||||
"StateResetIntentV1",
|
||||
"StateResetScopeV1",
|
||||
);
|
||||
|
||||
pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use redb::{
|
|||
CommitError, Database, DatabaseError, ReadableDatabase, ReadableTable, StorageError,
|
||||
TableDefinition, TableError, TransactionError,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("metadata");
|
||||
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]> =
|
||||
TableDefinition::new("session_terminals");
|
||||
const MAILBOXES: TableDefinition<u64, &[u8]> = TableDefinition::new("mailboxes");
|
||||
const STATE_RESET: TableDefinition<u8, &[u8]> = TableDefinition::new("state_reset");
|
||||
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)]
|
||||
pub enum DomainStoreMutation {
|
||||
|
|
@ -231,6 +276,71 @@ impl HostCoordinatorDomainStore {
|
|||
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> {
|
||||
let snapshot = DomainStore {
|
||||
metadata: DomainStoreMetadata::v1(),
|
||||
|
|
|
|||
|
|
@ -138,6 +138,65 @@ fn open_store(directory: &TestDirectory) -> HostCoordinatorDomainStore {
|
|||
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]
|
||||
fn descriptor_store_open_cannot_be_redirected_by_path_replacement() {
|
||||
let directory = TestDirectory::new("descriptor-open");
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ pub mod recovery;
|
|||
pub mod runtime;
|
||||
pub mod sandbox_init;
|
||||
pub mod state_anchor;
|
||||
pub mod state_maintenance;
|
||||
pub mod terminal;
|
||||
pub mod waiter;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ use crate::runtime::StateAnchorAbsenceObservation;
|
|||
use crate::state_anchor::{
|
||||
StateAnchorCoordinator, StateAnchorCoordinatorError, StateAnchorRecoveryOwner,
|
||||
};
|
||||
use crate::state_maintenance::recover_state_reset;
|
||||
|
||||
const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
|
|
@ -627,6 +628,7 @@ where
|
|||
Open: FnMut(PidfsProcessIdentityV1) -> io::Result<PidfsRecoveryMatch>,
|
||||
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))?;
|
||||
let snapshot = store.snapshot().map_err(|error| error.to_string())?;
|
||||
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 lazaret_core::identity::{
|
||||
BootId, BundleDigest, ClientId, ConfigurationFingerprint, HostPath, HostPidLocator, MountId,
|
||||
MountIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
|
||||
ProcessStartTimeTicks, RequestId, SandboxId, SessionAnchorId,
|
||||
BootId, BundleDigest, ClientId, ConfigurationFingerprint, DeviceId, HostPath, HostPidLocator,
|
||||
InodeId, MountId, MountIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId,
|
||||
PidfsProcessAuthorityV1, ProcessStartTimeTicks, RequestId, SandboxId, SessionAnchorId,
|
||||
};
|
||||
use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability};
|
||||
use lazaret_core::records::{
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ use lazaret_linux::process::{
|
|||
ProcessOwnershipContext, RetainedProcess, TargetCommand, child_broker, require_pidfs_magic,
|
||||
};
|
||||
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::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";
|
||||
static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
pub const PROTECTED_RUNTIME_FILES: [&str; 3] = ["resolv.conf", "hosts", "hostname"];
|
||||
const RESET_QUARANTINE: &str = "reset-quarantine";
|
||||
|
||||
pub struct ProtectedRuntimeFiles<'a> {
|
||||
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.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.
|
||||
|
|
@ -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> {
|
||||
match mkdirat(parent.as_fd(), name, Mode::from_raw_mode(0o700)) {
|
||||
Ok(()) | Err(rustix::io::Errno::EXIST) => {}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,74 @@ use lazaret_core::identity::{
|
|||
use lazaret_core::records::{ArtifactState, PreparedArtifact};
|
||||
use lazaret_state_anchor::{
|
||||
ProtectedRuntimeFiles, RuntimeStateAnchorAuthority, RuntimeStateAnchorInherited,
|
||||
StateAnchorLaunchAuthority, StateAnchorLayout,
|
||||
StateAnchorLaunchAuthority, StateAnchorLayout, StateAnchorResetScope,
|
||||
};
|
||||
|
||||
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]
|
||||
fn replaced_runtime_root_is_refused_before_runtime_file_write() {
|
||||
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 {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,6 +383,20 @@ classDiagram
|
|||
state
|
||||
}
|
||||
|
||||
class StateResetIntentV1 {
|
||||
<<serialized>>
|
||||
schema
|
||||
domain_id
|
||||
scope
|
||||
}
|
||||
|
||||
class StateResetScopeV1 {
|
||||
<<serialized>>
|
||||
<<union>>
|
||||
All
|
||||
Selected
|
||||
}
|
||||
|
||||
DomainStore "1" *-- "1" StoreMetadata
|
||||
DomainStore "1" *-- "1" Domain
|
||||
Domain "1" --> "0..*" Generation
|
||||
|
|
@ -394,6 +408,8 @@ classDiagram
|
|||
SessionTerminal --> SessionTerminalState
|
||||
Generation "1" --> "0..1" Mailbox
|
||||
Mailbox --> ParticipantState
|
||||
DomainStore "1" *-- "0..1" StateResetIntentV1
|
||||
StateResetIntentV1 --> StateResetScopeV1
|
||||
```
|
||||
|
||||
`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
|
||||
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
|
||||
|
||||
```mermaid
|
||||
|
|
|
|||
|
|
@ -177,6 +177,11 @@ PROCESS_LINT_ALLOWANCES = {
|
|||
"match_recovery_process",
|
||||
},
|
||||
"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": {
|
||||
"run_acquisition",
|
||||
"successful_acquisition",
|
||||
|
|
|
|||
Loading…
Reference in a new issue