Add crash-consistent State Anchor maintenance

This commit is contained in:
ms2k 2026-07-17 07:54:46 +09:00
commit 24d7d5b6e0
18 changed files with 1590 additions and 7 deletions

1
Cargo.lock generated
View file

@ -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",
] ]

View file

@ -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"

View file

@ -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)]

View file

@ -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) => {

View file

@ -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 {

View 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);
}
}

View 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
}
}

View file

@ -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] = &[

View file

@ -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(),

View file

@ -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");

View file

@ -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;

View file

@ -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() {

View 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()
}
}

View file

@ -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::{

View file

@ -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) => {}

View file

@ -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,
} }

View file

@ -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

View file

@ -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",