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) let state_anchor_layout = StateAnchorLayout::create(inputs.configuration)
.map_err(|error| failure(format!("State Anchor layout: {error}")))?; .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( let staging = RuntimeStaging::create(
inputs.configuration.runtime_domain_root(), inputs.configuration.runtime_domain_root(),
inputs.container_id, inputs.container_id,

View file

@ -599,6 +599,9 @@ fn recover_state_anchor(
) && owners.ownership.state_anchor_mount.is_some(); ) && owners.ownership.state_anchor_mount.is_some();
if !full_mount { if !full_mount {
let _ = owners.prove_absent().map_err(|error| error.to_string())?; 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( return clean_role_after_absence(
store, store,
generation.generation_id, generation.generation_id,
@ -652,6 +655,9 @@ fn recover_state_anchor(
) { ) {
return Err("completed recovery witness changed State Anchor authority".into()); return Err("completed recovery witness changed State Anchor authority".into());
} }
layout
.normalize_inactive_work_directory()
.map_err(|error| error.to_string())?;
coordinator coordinator
.complete_release_after_unmount(&authority, &layout, first, || { .complete_release_after_unmount(&authority, &layout, first, || {
fresh_absence(store, generation.generation_id, &layout) 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] #[test]
fn recovery_preflight_recovers_full_runtime_then_is_idempotent() { fn recovery_preflight_recovers_full_runtime_then_is_idempotent() {
let fixture = RecoveryFixture::new(); 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(); let mut boundaries = Vec::new();
fixture fixture
.recover_with(|boundary, generation| { .recover_with(|boundary, generation| {
@ -251,6 +256,8 @@ fn recovery_preflight_recovers_full_runtime_then_is_idempotent() {
.join(hex_lower(&[6; 32])) .join(hex_lower(&[6; 32]))
.exists() .exists()
); );
let layout = StateAnchorLayout::create(&fixture.configuration).unwrap();
assert!(layout.work().read_dir().unwrap().next().is_none());
recover_before_cold_start( recover_before_cold_start(
&fixture.configuration, &fixture.configuration,

View file

@ -33,10 +33,10 @@ use rustix::fs::{
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};
use storage::{ use storage::{
atomic_replace_at, create_directory, create_regular_file, descriptor_child_path, atomic_replace_at, clear_work_directory, create_directory, create_regular_file,
descriptor_path, host_path, inspect_directory, move_all_entries, move_selected_entries, descriptor_child_path, descriptor_path, host_path, inspect_directory, move_all_entries,
open_directory, open_exact_directory_path, open_mounted_directory, open_or_create_quarantine, move_selected_entries, open_directory, open_exact_directory_path, open_mounted_directory,
open_regular_file, path_from_host, remove_directory_contents, open_or_create_quarantine, open_regular_file, path_from_host, remove_directory_contents,
require_directory_descriptor_identity, require_maintenance_work_shape, require_same_descriptor, require_directory_descriptor_identity, require_maintenance_work_shape, require_same_descriptor,
validate_relative_path, validate_relative_path,
}; };
@ -272,6 +272,15 @@ impl<'configuration> StateAnchorLayout<'configuration> {
self.revalidate() 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. /// Atomically replaces sandbox runtime files in host-private transient storage.
/// ///
/// # Errors /// # Errors

View file

@ -1,7 +1,8 @@
use super::storage::{ 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, 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::{ use super::{
AsFd, AsRawFd, FileType, HostProcessOwnershipSlot, Instant, MOUNT_POLL_INTERVAL, MOUNT_TIMEOUT, AsFd, AsRawFd, FileType, HostProcessOwnershipSlot, Instant, MOUNT_POLL_INTERVAL, MOUNT_TIMEOUT,
@ -161,6 +162,7 @@ pub struct RuntimeStateAnchorAuthority {
lower: OwnedFd, lower: OwnedFd,
persistent_anchor: OwnedFd, persistent_anchor: OwnedFd,
upper: OwnedFd, upper: OwnedFd,
work: OwnedFd,
runtime_anchor: OwnedFd, runtime_anchor: OwnedFd,
merged_label: PathBuf, merged_label: PathBuf,
merged_descriptor: OwnedFd, merged_descriptor: OwnedFd,
@ -233,13 +235,14 @@ impl RuntimeStateAnchorAuthority {
inherited.runtime_anchor_label, inherited.runtime_anchor_label,
(retained.st_dev, retained.st_ino), (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_descriptor = open_directory(&runtime_anchor, Path::new("merged"))?;
let merged_label = inherited.runtime_anchor_label.join("merged"); let merged_label = inherited.runtime_anchor_label.join("merged");
Ok(Self { Ok(Self {
lower, lower,
persistent_anchor, persistent_anchor,
upper, upper,
work,
runtime_anchor, runtime_anchor,
merged_label, merged_label,
merged_descriptor, merged_descriptor,
@ -495,6 +498,9 @@ impl RuntimeStateAnchorMount {
return Err(StateAnchorError::OwnershipMismatch); return Err(StateAnchorError::OwnershipMismatch);
} }
child_broker().reap(process)?; 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 { Ok(RuntimeStateAnchorAbsenceAuthority {
runtime_anchor: authority.runtime_anchor, runtime_anchor: authority.runtime_anchor,
mount, mount,

View file

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

View file

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

View file

@ -18,6 +18,7 @@ use lazaret_state_anchor::{
ProtectedRuntimeFiles, RuntimeStateAnchorAuthority, RuntimeStateAnchorInherited, ProtectedRuntimeFiles, RuntimeStateAnchorAuthority, RuntimeStateAnchorInherited,
StateAnchorLaunchAuthority, StateAnchorLayout, StateAnchorResetScope, StateAnchorLaunchAuthority, StateAnchorLayout, StateAnchorResetScope,
}; };
use rustix::fs::{XattrFlags, fsetxattr};
static SEQUENCE: AtomicU64 = AtomicU64::new(0); static SEQUENCE: AtomicU64 = AtomicU64::new(0);
@ -33,6 +34,40 @@ fn maintenance_accepts_exact_empty_fuse_overlayfs_work_shape() {
layout.inspect_state().unwrap(); 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] #[test]
fn maintenance_refuses_mutated_fuse_overlayfs_work_shapes() { fn maintenance_refuses_mutated_fuse_overlayfs_work_shapes() {
let tree = TestTree::new(); let tree = TestTree::new();
@ -60,6 +95,26 @@ fn maintenance_refuses_mutated_fuse_overlayfs_work_shapes() {
assert!(layout.inspect_state().is_err()); 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] #[test]
fn selected_reset_removes_exact_state_and_whiteout_without_touching_siblings() { fn selected_reset_removes_exact_state_and_whiteout_without_touching_siblings() {
let tree = TestTree::new(); let tree = TestTree::new();

View file

@ -1,6 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::ffi::OsString; use std::ffi::OsString;
use std::fs; use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, Read, Write}; use std::io::{self, BufRead, BufReader, Read, Write};
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
@ -157,10 +157,26 @@ fn run() -> io::Result<()> {
"upper runtime-file entry survived transient override preflight", "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)?; runtime_mount.unmount().map_err(io::Error::other)?;
layout.inspect_state().map_err(io::Error::other)?;
fs::remove_file(layout.upper().join("etc/hosts"))?; fs::remove_file(layout.upper().join("etc/hosts"))?;
let first = acquire(&layout, &artifact)?; 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( fs::write(
layout.merged().join("usr/bin/installed-tool"), layout.merged().join("usr/bin/installed-tool"),
b"persistent", b"persistent",