Normalize inactive State Anchor work storage

This commit is contained in:
ms2k 2026-07-23 21:44:16 +09:00
commit 42e197510e
9 changed files with 133 additions and 11 deletions

View file

@ -167,6 +167,9 @@ impl<'a> ColdStart<'a> {
);
let state_anchor_layout = StateAnchorLayout::create(inputs.configuration)
.map_err(|error| failure(format!("State Anchor layout: {error}")))?;
state_anchor_layout
.normalize_inactive_work_directory()
.map_err(|error| failure(format!("State Anchor work recovery: {error}")))?;
let staging = RuntimeStaging::create(
inputs.configuration.runtime_domain_root(),
inputs.container_id,

View file

@ -599,6 +599,9 @@ fn recover_state_anchor(
) && owners.ownership.state_anchor_mount.is_some();
if !full_mount {
let _ = owners.prove_absent().map_err(|error| error.to_string())?;
StateAnchorLayout::create(configuration)
.and_then(|layout| layout.normalize_inactive_work_directory())
.map_err(|error| error.to_string())?;
return clean_role_after_absence(
store,
generation.generation_id,
@ -652,6 +655,9 @@ fn recover_state_anchor(
) {
return Err("completed recovery witness changed State Anchor authority".into());
}
layout
.normalize_inactive_work_directory()
.map_err(|error| error.to_string())?;
coordinator
.complete_release_after_unmount(&authority, &layout, first, || {
fresh_absence(store, generation.generation_id, &layout)

View file

@ -206,6 +206,11 @@ fn complete_boot_mismatch_inventory_proves_only_after_every_holder_and_mount() {
#[test]
fn recovery_preflight_recovers_full_runtime_then_is_idempotent() {
let fixture = RecoveryFixture::new();
let layout = StateAnchorLayout::create(&fixture.configuration).unwrap();
let residue = layout.work().join("work/4");
fs::create_dir_all(&residue).unwrap();
fs::write(residue.join(".wh..wh..opq"), b"").unwrap();
drop(layout);
let mut boundaries = Vec::new();
fixture
.recover_with(|boundary, generation| {
@ -251,6 +256,8 @@ fn recovery_preflight_recovers_full_runtime_then_is_idempotent() {
.join(hex_lower(&[6; 32]))
.exists()
);
let layout = StateAnchorLayout::create(&fixture.configuration).unwrap();
assert!(layout.work().read_dir().unwrap().next().is_none());
recover_before_cold_start(
&fixture.configuration,

View file

@ -33,10 +33,10 @@ use rustix::fs::{
use rustix::io::dup;
use rustix::thread::{ThreadNameSpaceType, move_into_thread_name_spaces};
use storage::{
atomic_replace_at, create_directory, create_regular_file, descriptor_child_path,
descriptor_path, host_path, inspect_directory, move_all_entries, move_selected_entries,
open_directory, open_exact_directory_path, open_mounted_directory, open_or_create_quarantine,
open_regular_file, path_from_host, remove_directory_contents,
atomic_replace_at, clear_work_directory, create_directory, create_regular_file,
descriptor_child_path, descriptor_path, host_path, inspect_directory, move_all_entries,
move_selected_entries, open_directory, open_exact_directory_path, open_mounted_directory,
open_or_create_quarantine, open_regular_file, path_from_host, remove_directory_contents,
require_directory_descriptor_identity, require_maintenance_work_shape, require_same_descriptor,
validate_relative_path,
};
@ -272,6 +272,15 @@ impl<'configuration> StateAnchorLayout<'configuration> {
self.revalidate()
}
/// Clears the private fuse-overlayfs work directory after proving the anchor inactive.
///
/// # Errors
/// Refuses changed authority, a remaining mount, or descriptor-confined cleanup failure.
pub fn normalize_inactive_work_directory(&self) -> Result<(), StateAnchorError> {
self.require_unmounted()?;
clear_work_directory(&self.work_descriptor)
}
/// Atomically replaces sandbox runtime files in host-private transient storage.
///
/// # Errors

View file

@ -1,7 +1,8 @@
use super::storage::{
descriptor_child_path, descriptor_path, inherit_for_exec, open_directory,
clear_work_directory, descriptor_child_path, descriptor_path, inherit_for_exec, open_directory,
open_exact_directory_path, open_mounted_directory, overlay_options, path_from_host,
reopen_inherited_directory, require_directory_descriptor_identity, rootless_unmount_path,
reopen_inherited_directory, require_directory_descriptor_identity, require_same_descriptor,
rootless_unmount_path,
};
use super::{
AsFd, AsRawFd, FileType, HostProcessOwnershipSlot, Instant, MOUNT_POLL_INTERVAL, MOUNT_TIMEOUT,
@ -161,6 +162,7 @@ pub struct RuntimeStateAnchorAuthority {
lower: OwnedFd,
persistent_anchor: OwnedFd,
upper: OwnedFd,
work: OwnedFd,
runtime_anchor: OwnedFd,
merged_label: PathBuf,
merged_descriptor: OwnedFd,
@ -233,13 +235,14 @@ impl RuntimeStateAnchorAuthority {
inherited.runtime_anchor_label,
(retained.st_dev, retained.st_ino),
)?;
drop(open_directory(&persistent_anchor, Path::new("work"))?);
let work = open_directory(&persistent_anchor, Path::new("work"))?;
let merged_descriptor = open_directory(&runtime_anchor, Path::new("merged"))?;
let merged_label = inherited.runtime_anchor_label.join("merged");
Ok(Self {
lower,
persistent_anchor,
upper,
work,
runtime_anchor,
merged_label,
merged_descriptor,
@ -495,6 +498,9 @@ impl RuntimeStateAnchorMount {
return Err(StateAnchorError::OwnershipMismatch);
}
child_broker().reap(process)?;
let observed_work = open_directory(&authority.persistent_anchor, Path::new("work"))?;
require_same_descriptor(&observed_work, &authority.work)?;
clear_work_directory(&authority.work)?;
Ok(RuntimeStateAnchorAbsenceAuthority {
runtime_anchor: authority.runtime_anchor,
mount,

View file

@ -151,6 +151,7 @@ impl<'layout, 'configuration> FuseOverlayfsMount<'layout, 'configuration> {
}
self.layout.require_unmounted()?;
child_broker().reap(self.process)?;
self.layout.normalize_inactive_work_directory()?;
Ok(())
}
@ -165,6 +166,7 @@ impl<'layout, 'configuration> FuseOverlayfsMount<'layout, 'configuration> {
}
self.layout.require_unmounted()?;
child_broker().reap(self.process)?;
self.layout.normalize_inactive_work_directory()?;
Ok(())
}
}

View file

@ -6,6 +6,8 @@ use super::{
mount_identity, openat2, renameat, renameat_with, statat, unlinkat,
};
const FUSE_OVERLAYFS_NESTED_WORK: &str = "work";
/// Independently verifies that no mount is attached at the exact merged path.
///
/// # Errors
@ -240,18 +242,34 @@ pub(super) fn require_maintenance_work_shape(directory: impl AsFd) -> Result<(),
if names.is_empty() {
return Ok(());
}
if names.as_slice() != [OsString::from("work")] {
if names.as_slice() != [OsString::from(FUSE_OVERLAYFS_NESTED_WORK)] {
return Err(StateAnchorError::InvalidLayout(
"State Anchor work directory has unsupported entries",
));
}
let nested = open_directory(&directory, Path::new("work"))?;
let nested = open_directory(&directory, Path::new(FUSE_OVERLAYFS_NESTED_WORK))?;
require_nested_work_authority(&directory, &nested)?;
if directory_has_entries(&nested)? {
return Err(StateAnchorError::InvalidLayout(
"State Anchor work directory has unsupported entries",
));
}
Ok(())
}
pub(super) fn clear_work_directory(directory: impl AsFd) -> Result<(), StateAnchorError> {
remove_directory_contents(directory)
}
fn require_nested_work_authority(
directory: impl AsFd,
nested: impl AsFd,
) -> Result<(), StateAnchorError> {
let parent = rustix::fs::fstat(&directory).map_err(io::Error::from)?;
let child = rustix::fs::fstat(&nested).map_err(io::Error::from)?;
if child.st_mode & 0o7777 != 0o700
|| child.st_uid != parent.st_uid
|| child.st_gid != parent.st_gid
|| directory_has_entries(&nested)?
{
return Err(StateAnchorError::InvalidLayout(
"State Anchor work directory has unsupported entries",

View file

@ -18,6 +18,7 @@ use lazaret_state_anchor::{
ProtectedRuntimeFiles, RuntimeStateAnchorAuthority, RuntimeStateAnchorInherited,
StateAnchorLaunchAuthority, StateAnchorLayout, StateAnchorResetScope,
};
use rustix::fs::{XattrFlags, fsetxattr};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
@ -33,6 +34,40 @@ fn maintenance_accepts_exact_empty_fuse_overlayfs_work_shape() {
layout.inspect_state().unwrap();
}
#[test]
fn inactive_normalization_removes_exact_opaque_fuse_overlayfs_residue() {
let tree = TestTree::new();
let configuration = configuration(&tree);
let layout = StateAnchorLayout::create(&configuration).unwrap();
let nested = domain_path(&configuration).join("state-anchor/work/work");
fs::create_dir(&nested).unwrap();
fs::set_permissions(&nested, fs::Permissions::from_mode(0o700)).unwrap();
create_opaque_work_residue(&nested, "4");
create_opaque_work_residue(&nested, "12");
assert!(layout.inspect_state().is_err());
layout.normalize_inactive_work_directory().unwrap();
layout.inspect_state().unwrap();
let work = domain_path(&configuration).join("state-anchor/work");
assert!(work.read_dir().unwrap().next().is_none());
}
#[test]
fn inactive_normalization_unlinks_work_symlinks_without_following_them() {
let tree = TestTree::new();
let configuration = configuration(&tree);
let layout = StateAnchorLayout::create(&configuration).unwrap();
let work = domain_path(&configuration).join("state-anchor/work");
let foreign = tree.path.join("foreign-work");
fs::create_dir(&foreign).unwrap();
fs::write(foreign.join("value"), b"foreign").unwrap();
std::os::unix::fs::symlink(&foreign, work.join("residue")).unwrap();
layout.normalize_inactive_work_directory().unwrap();
assert!(!work.join("residue").exists());
assert_eq!(fs::read(foreign.join("value")).unwrap(), b"foreign");
}
#[test]
fn maintenance_refuses_mutated_fuse_overlayfs_work_shapes() {
let tree = TestTree::new();
@ -60,6 +95,26 @@ fn maintenance_refuses_mutated_fuse_overlayfs_work_shapes() {
assert!(layout.inspect_state().is_err());
}
fn create_opaque_work_residue(nested: &Path, name: &str) {
let residue = nested.join(name);
fs::create_dir(&residue).unwrap();
fs::set_permissions(&residue, fs::Permissions::from_mode(0o755)).unwrap();
fs::write(residue.join(".wh..wh..opq"), b"").unwrap();
fs::set_permissions(
residue.join(".wh..wh..opq"),
fs::Permissions::from_mode(0o700),
)
.unwrap();
let residue = File::open(residue).unwrap();
fsetxattr(
&residue,
"user.fuseoverlayfs.opaque",
b"y",
XattrFlags::empty(),
)
.unwrap();
}
#[test]
fn selected_reset_removes_exact_state_and_whiteout_without_touching_siblings() {
let tree = TestTree::new();

View file

@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, Read, Write};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
@ -157,10 +157,26 @@ fn run() -> io::Result<()> {
"upper runtime-file entry survived transient override preflight",
));
}
let held_directory = layout.merged().join("root/open-lock");
fs::create_dir_all(&held_directory)?;
let held = File::open(&held_directory)?;
fs::remove_dir(&held_directory)?;
drop(held);
if layout.work().join("work").read_dir()?.next().is_none() {
return Err(io::Error::other(
"open-directory removal did not create fuse-overlayfs work residue",
));
}
runtime_mount.unmount().map_err(io::Error::other)?;
layout.inspect_state().map_err(io::Error::other)?;
fs::remove_file(layout.upper().join("etc/hosts"))?;
let first = acquire(&layout, &artifact)?;
if layout.merged().join("root/open-lock").exists() {
return Err(io::Error::other(
"removed open directory returned after normalized remount",
));
}
fs::write(
layout.merged().join("usr/bin/installed-tool"),
b"persistent",