Add crash-consistent State Anchor maintenance
This commit is contained in:
parent
5cd0772866
commit
24d7d5b6e0
1 changed files with 1590 additions and 7 deletions
|
|
@ -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) => {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue