Add crash-consistent State Anchor maintenance
This commit is contained in:
parent
5cd0772866
commit
24d7d5b6e0
2 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) => {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue