Add crash-consistent State Anchor maintenance

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

View file

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

View file

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

View file

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

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

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,
"StorageCatalog" => StorageCatalog,
"PrepareStageState" => PrepareStageState;
"StateResetIntentV1",
"StateResetScopeV1",
);
pub const DECLARED_RUNTIME_SCHEMA_TYPE_NAMES: &[&str] = &[

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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