Add crash-consistent State Anchor maintenance

This commit is contained in:
ms2k 2026-07-17 07:54:46 +09:00
commit 24d7d5b6e0

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