use std::collections::{BTreeMap, HashMap}; use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::{self, Read}; use std::os::fd::AsFd; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use rustix::fs::{Mode, OFlags, RenameFlags, Timestamps, fchmod, fchown, flistxattr, futimens}; use rustix::process::{Gid, Uid}; use rustix::time::Timespec; use sha2::{Digest, Sha256}; use crate::backing::BackingRoot; use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier, PolicyRecord}; use crate::migration::{TRANSACTIONS, reserved}; const ROUTING: &str = "routing"; const POLICY: &str = "policy.json"; const POLICY_TEMPORARY: &str = "policy.tmp"; const CANDIDATE_PREFIX: &[u8] = b"candidate-"; const LEGACY_STAGE_PREFIX: &[u8] = b"stage-"; static CANDIDATE_SEQUENCE: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] struct ObjectKey { device: u64, inode: u64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum EntryKind { Directory, Regular, Symlink, } #[derive(Clone, Debug, Eq, PartialEq)] struct AdmissionRecord { kind: EntryKind, key: ObjectKey, links: u64, mode: u32, uid: u32, gid: u32, size: i64, access_seconds: i64, access_nanoseconds: u64, modification_seconds: i64, modification_nanoseconds: u64, change_seconds: i64, change_nanoseconds: u64, digest: Option<[u8; 32]>, link_target: Option>, } #[derive(Clone, Debug, Eq, PartialEq)] struct Transition { path: PathBuf, directory: bool, old: Classification, new: Classification, } pub(crate) fn admit_policy( workspace: &Path, host: &BackingRoot, isolated: &BackingRoot, classifier: &GitClassifier, ) -> io::Result<()> { let routing = routing_directory(); match isolated.mkdir(&routing, Mode::from_raw_mode(0o700)) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error), } isolated.sync_directory(Path::new(TRANSACTIONS))?; clean_candidates(isolated)?; let previous = read_policy(isolated)?; let current_record = classifier.policy_record(); if previous.as_ref() == Some(¤t_record) { return Ok(()); } if let Some(previous) = previous { let previous = GitClassifier::snapshot_from_record(workspace, previous)?; reconcile(host, isolated, &previous, &classifier.snapshot())?; } persist_policy(isolated, ¤t_record) } fn reconcile( host: &BackingRoot, isolated: &BackingRoot, previous: &ClassificationSnapshot, current: &ClassificationSnapshot, ) -> io::Result<()> { let transitions = transitions(host, isolated, previous, current)?; let admissions = capture_admissions(host, &transitions)?; let candidate = create_candidate(isolated)?; let result = reconcile_candidate( host, isolated, previous, current, &transitions, &admissions, &candidate, ); if result.is_ok() { remove_tree(isolated, &candidate)?; isolated.sync_directory(&routing_directory())?; } result } fn reconcile_candidate( host: &BackingRoot, isolated: &BackingRoot, previous: &ClassificationSnapshot, current: &ClassificationSnapshot, expected_transitions: &[Transition], admissions: &BTreeMap>, candidate: &Path, ) -> io::Result<()> { let mut releases = expected_transitions .iter() .filter(|transition| { transition.old == Classification::Isolated && transition.new == Classification::Host }) .collect::>(); releases.sort_by_key(|transition| usize::MAX - transition.path.components().count()); for transition in releases { remove_released_path(isolated, current, &transition.path, transition.directory)?; } let mut directories = admissions .iter() .filter_map(|(path, record)| { record .as_ref() .filter(|record| record.kind == EntryKind::Directory) .map(|record| (path, record)) }) .collect::>(); directories.sort_by_key(|(path, _)| path.components().count()); for (path, record) in &directories { ensure_directory(isolated, path, record)?; } let anchors = create_regular_anchors(host, isolated, admissions, candidate)?; let names = candidate.join("names"); let mut name_sequence = 0_u64; for (path, record) in admissions { let Some(record) = record else { if stat_if_exists(isolated, path)?.is_some() { remove_tree(isolated, path)?; } continue; }; match record.kind { EntryKind::Directory => {} EntryKind::Regular => { let staging = names.join(format!("name-{name_sequence}")); name_sequence += 1; isolated.link(&anchors[&record.key], &staging)?; install_name(isolated, &staging, path)?; } EntryKind::Symlink => { let staging = names.join(format!("name-{name_sequence}")); name_sequence += 1; let target = PathBuf::from(OsString::from_vec( record.link_target.clone().ok_or_else(invalid_manifest)?, )); isolated.symlink(&target, &staging)?; isolated.set_symlink_owner( &staging, Uid::from_raw(record.uid), Gid::from_raw(record.gid), )?; isolated.set_symlink_times(&staging, &record.timestamps())?; install_name(isolated, &staging, path)?; } } } directories.sort_by_key(|(path, _)| usize::MAX - path.components().count()); for (path, record) in directories { let directory = isolated.open_directory(path)?; set_metadata(&directory, record)?; directory.sync_all()?; } require_admissions(host, admissions)?; verify_isolated(isolated, admissions)?; let current_transitions = transitions(host, isolated, previous, current)?; let current_admissions = capture_admissions(host, ¤t_transitions)?; if !same_admissions(¤t_admissions, admissions) { return Err(io::Error::from_raw_os_error(libc::EAGAIN)); } require_admissions(host, admissions) } fn create_regular_anchors( host: &BackingRoot, isolated: &BackingRoot, admissions: &BTreeMap>, candidate: &Path, ) -> io::Result> { let mut sources = BTreeMap::new(); for (path, record) in admissions { if let Some(record) = record && record.kind == EntryKind::Regular { sources.entry(record.key).or_insert((path, record)); } } let objects = candidate.join("objects"); let mut anchors = HashMap::new(); for (sequence, (key, (source, record))) in sources.into_iter().enumerate() { let anchor = objects.join(format!("object-{sequence}")); let mut input = host.open_entry(source, OFlags::RDONLY, Mode::empty())?; let mut output = isolated.create_file(&anchor, Mode::from_raw_mode(0o600))?; io::copy(&mut input, &mut output)?; set_metadata(&output, record)?; output.sync_all()?; anchors.insert(key, anchor); } isolated.sync_directory(&objects)?; Ok(anchors) } fn ensure_directory( isolated: &BackingRoot, path: &Path, record: &AdmissionRecord, ) -> io::Result<()> { if let Some(stat) = stat_if_exists(isolated, path)? && stat.st_mode & libc::S_IFMT != libc::S_IFDIR { remove_tree(isolated, path)?; } isolated.ensure_parent_directories(path)?; match isolated.mkdir(path, Mode::from_raw_mode(record.mode & 0o7777)) { Ok(()) => {} Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error), } Ok(()) } fn install_name(isolated: &BackingRoot, staging: &Path, destination: &Path) -> io::Result<()> { if stat_if_exists(isolated, destination)?.is_some() { remove_tree(isolated, destination)?; } isolated.ensure_parent_directories(destination)?; isolated.rename(staging, destination, RenameFlags::NOREPLACE)?; isolated.sync_directory(destination.parent().unwrap_or_else(|| Path::new(""))) } fn capture_admissions( host: &BackingRoot, transitions: &[Transition], ) -> io::Result>> { transitions .iter() .filter(|transition| { transition.old == Classification::Host && transition.new == Classification::Isolated }) .map(|transition| { capture_record(host, &transition.path, transition.directory) .map(|record| (transition.path.clone(), record)) }) .collect() } fn capture_record( root: &BackingRoot, path: &Path, directory: bool, ) -> io::Result> { let Some(before) = stat_if_exists(root, path)? else { return Ok(None); }; let kind = EntryKind::from_mode(before.st_mode)?; if (kind == EntryKind::Directory) != directory { return Err(invalid_manifest()); } let (digest, link_target) = match kind { EntryKind::Directory => (None, None), EntryKind::Regular => { let file = root.open_entry(path, OFlags::RDONLY, Mode::empty())?; require_copyable_xattrs(&file)?; (Some(digest(file)?), None) } EntryKind::Symlink => { if root .symlink_xattrs(path)? .iter() .any(|name| unsupported_xattr_name(name.as_bytes())) { return Err(io::Error::from_raw_os_error(libc::EXDEV)); } (None, Some(root.readlink(path)?)) } }; let record = AdmissionRecord::new(&before, kind, digest, link_target); let after = root.stat(path)?; if !record.same_source(&after) { return Err(io::Error::from_raw_os_error(libc::EAGAIN)); } Ok(Some(record)) } fn require_admissions( host: &BackingRoot, expected: &BTreeMap>, ) -> io::Result<()> { for (path, expected) in expected { let actual = capture_record( host, path, expected .as_ref() .is_some_and(|record| record.kind == EntryKind::Directory), )?; if !same_admission(actual.as_ref(), expected.as_ref()) { return Err(io::Error::from_raw_os_error(libc::EAGAIN)); } } Ok(()) } fn verify_isolated( isolated: &BackingRoot, expected: &BTreeMap>, ) -> io::Result<()> { let mut source_to_destination = HashMap::new(); let mut destination_to_source = HashMap::new(); let mut digests = HashMap::new(); for (path, expected) in expected { let Some(expected) = expected else { if stat_if_exists(isolated, path)?.is_some() { return Err(invalid_manifest()); } continue; }; let actual = isolated.stat(path)?; if !expected.equivalent_destination(&actual) { return Err(invalid_manifest()); } match expected.kind { EntryKind::Regular => { let destination = ObjectKey::from_stat(&actual); if destination == expected.key { return Err(invalid_manifest()); } if source_to_destination .insert(expected.key, destination) .is_some_and(|known| known != destination) || destination_to_source .insert(destination, expected.key) .is_some_and(|known| known != expected.key) { return Err(invalid_manifest()); } let actual_digest = if let Some(digest) = digests.get(&destination) { *digest } else { let digest = digest(isolated.open_entry(path, OFlags::RDONLY, Mode::empty())?)?; digests.insert(destination, digest); digest }; if Some(actual_digest) != expected.digest { return Err(invalid_manifest()); } } EntryKind::Symlink => { if Some(isolated.readlink(path)?) != expected.link_target { return Err(invalid_manifest()); } } EntryKind::Directory => {} } } Ok(()) } fn transitions( host: &BackingRoot, isolated: &BackingRoot, previous: &ClassificationSnapshot, current: &ClassificationSnapshot, ) -> io::Result> { let mut paths = BTreeMap::new(); collect_paths(host, Path::new(""), &mut paths)?; collect_paths(isolated, Path::new(""), &mut paths)?; paths.retain(|path, _| !reserved(path)); Ok(paths .into_iter() .filter_map(|(path, directory)| { let old = previous.classify(&path, directory); let new = current.classify(&path, directory); (old != new).then_some(Transition { path, directory, old, new, }) }) .collect()) } fn collect_paths( root: &BackingRoot, directory: &Path, paths: &mut BTreeMap, ) -> io::Result<()> { for (name, kind) in root.typed_entries(directory)? { let path = directory.join(name); if reserved(&path) { continue; } let is_directory = kind == rustix::fs::FileType::Directory || (kind == rustix::fs::FileType::Unknown && root .stat(&path) .is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR)); paths .entry(path.clone()) .and_modify(|known| *known |= is_directory) .or_insert(is_directory); if is_directory { collect_paths(root, &path, paths)?; } } Ok(()) } fn remove_released_path( isolated: &BackingRoot, current: &ClassificationSnapshot, path: &Path, directory: bool, ) -> io::Result<()> { if stat_if_exists(isolated, path)?.is_none() { return Ok(()); } if directory && has_isolated_descendant(isolated, current, path)? { return Ok(()); } remove_tree(isolated, path) } fn has_isolated_descendant( isolated: &BackingRoot, current: &ClassificationSnapshot, directory: &Path, ) -> io::Result { for (name, kind) in isolated.typed_entries(directory)? { let child = directory.join(name); let child_directory = kind == rustix::fs::FileType::Directory || (kind == rustix::fs::FileType::Unknown && isolated .stat(&child) .is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR)); if current.classify(&child, child_directory) == Classification::Isolated || (child_directory && has_isolated_descendant(isolated, current, &child)?) { return Ok(true); } } Ok(false) } fn create_candidate(isolated: &BackingRoot) -> io::Result { let sequence = CANDIDATE_SEQUENCE.fetch_add(1, Ordering::Relaxed); let candidate = routing_directory().join(format!("candidate-{}-{sequence}", std::process::id())); isolated.mkdir(&candidate, Mode::from_raw_mode(0o700))?; isolated.mkdir(&candidate.join("objects"), Mode::from_raw_mode(0o700))?; isolated.mkdir(&candidate.join("names"), Mode::from_raw_mode(0o700))?; isolated.sync_directory(&candidate)?; isolated.sync_directory(&routing_directory())?; Ok(candidate) } fn clean_candidates(isolated: &BackingRoot) -> io::Result<()> { let directory = routing_directory(); for (name, kind) in isolated.typed_entries(&directory)? { let path = directory.join(&name); if name == OsStr::new(POLICY) && kind == rustix::fs::FileType::RegularFile { continue; } if name == OsStr::new(POLICY_TEMPORARY) || name.as_bytes().starts_with(CANDIDATE_PREFIX) || name.as_bytes().starts_with(LEGACY_STAGE_PREFIX) { remove_tree(isolated, &path)?; continue; } return Err(io::Error::from_raw_os_error(libc::EIO)); } isolated.sync_directory(&directory) } fn read_policy(isolated: &BackingRoot) -> io::Result> { match isolated.read_file(&routing_directory().join(POLICY), 64 * 1024 * 1024) { Ok(bytes) => serde_json::from_slice(&bytes) .map(Some) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error), } } fn persist_policy(isolated: &BackingRoot, policy: &PolicyRecord) -> io::Result<()> { let directory = routing_directory(); let temporary = directory.join(POLICY_TEMPORARY); let destination = directory.join(POLICY); if stat_if_exists(isolated, &temporary)?.is_some() { isolated.unlink(&temporary, false)?; } let bytes = serde_json::to_vec(policy).map_err(io::Error::other)?; isolated.write_file(&temporary, &bytes, Mode::from_raw_mode(0o600))?; isolated.rename(&temporary, &destination, RenameFlags::empty())?; isolated.sync_directory(&directory) } fn routing_directory() -> PathBuf { Path::new(TRANSACTIONS).join(ROUTING) } fn stat_if_exists(root: &BackingRoot, path: &Path) -> io::Result> { match root.stat(path) { Ok(stat) => Ok(Some(stat)), Err(error) if error.raw_os_error() == Some(libc::ENOENT) => Ok(None), Err(error) => Err(error), } } fn remove_tree(root: &BackingRoot, path: &Path) -> io::Result<()> { let stat = root.stat(path)?; if stat.st_mode & libc::S_IFMT == libc::S_IFDIR { for (name, _) in root.typed_entries(path)? { remove_tree(root, &path.join(name))?; } root.unlink(path, true) } else { root.unlink(path, false) } } fn require_copyable_xattrs(file: &File) -> io::Result<()> { let mut xattrs = vec![0; 64 * 1024]; let length = flistxattr(file.as_fd(), &mut xattrs)?; xattrs.truncate(length); if xattrs.split(|byte| *byte == 0).any(unsupported_xattr_name) { return Err(io::Error::from_raw_os_error(libc::EXDEV)); } Ok(()) } fn unsupported_xattr_name(name: &[u8]) -> bool { name.starts_with(b"user.") || matches!( name, b"system.posix_acl_access" | b"system.posix_acl_default" ) } fn digest(mut file: File) -> io::Result<[u8; 32]> { let mut hasher = Sha256::new(); let mut buffer = vec![0; 64 * 1024]; loop { let length = file.read(&mut buffer)?; if length == 0 { break; } hasher.update(&buffer[..length]); } Ok(hasher.finalize().into()) } fn set_metadata(file: &File, record: &AdmissionRecord) -> io::Result<()> { fchmod(file.as_fd(), Mode::from_raw_mode(record.mode & 0o7777))?; fchown( file.as_fd(), Some(Uid::from_raw(record.uid)), Some(Gid::from_raw(record.gid)), )?; futimens(file.as_fd(), &record.timestamps())?; Ok(()) } impl ObjectKey { fn from_stat(stat: &rustix::fs::Stat) -> Self { Self { device: stat.st_dev, inode: stat.st_ino, } } } impl EntryKind { fn from_mode(mode: u32) -> io::Result { match mode & libc::S_IFMT { libc::S_IFDIR => Ok(Self::Directory), libc::S_IFREG => Ok(Self::Regular), libc::S_IFLNK => Ok(Self::Symlink), _ => Err(io::Error::from_raw_os_error(libc::EXDEV)), } } } impl AdmissionRecord { fn new( stat: &rustix::fs::Stat, kind: EntryKind, digest: Option<[u8; 32]>, link_target: Option>, ) -> Self { Self { kind, key: ObjectKey::from_stat(stat), links: stat.st_nlink, mode: stat.st_mode, uid: stat.st_uid, gid: stat.st_gid, size: stat.st_size, access_seconds: stat.st_atime, access_nanoseconds: stat.st_atime_nsec, modification_seconds: stat.st_mtime, modification_nanoseconds: stat.st_mtime_nsec, change_seconds: stat.st_ctime, change_nanoseconds: stat.st_ctime_nsec, digest, link_target, } } fn same_source(&self, stat: &rustix::fs::Stat) -> bool { self.key == ObjectKey::from_stat(stat) && self.links == stat.st_nlink && self.mode == stat.st_mode && self.uid == stat.st_uid && self.gid == stat.st_gid && self.size == stat.st_size && self.modification_seconds == stat.st_mtime && self.modification_nanoseconds == stat.st_mtime_nsec && self.change_seconds == stat.st_ctime && self.change_nanoseconds == stat.st_ctime_nsec } fn same_admission(&self, other: &Self) -> bool { self.kind == other.kind && self.key == other.key && self.links == other.links && self.mode == other.mode && self.uid == other.uid && self.gid == other.gid && self.size == other.size && self.modification_seconds == other.modification_seconds && self.modification_nanoseconds == other.modification_nanoseconds && self.change_seconds == other.change_seconds && self.change_nanoseconds == other.change_nanoseconds && self.digest == other.digest && self.link_target == other.link_target } fn equivalent_destination(&self, stat: &rustix::fs::Stat) -> bool { EntryKind::from_mode(stat.st_mode).ok() == Some(self.kind) && self.mode & 0o7777 == stat.st_mode & 0o7777 && self.uid == stat.st_uid && self.gid == stat.st_gid && (self.kind == EntryKind::Directory || self.size == stat.st_size) && self.modification_seconds == stat.st_mtime && self.modification_nanoseconds == stat.st_mtime_nsec } fn timestamps(&self) -> Timestamps { Timestamps { last_access: Timespec { tv_sec: self.access_seconds, tv_nsec: i64::try_from(self.access_nanoseconds).unwrap_or(999_999_999), }, last_modification: Timespec { tv_sec: self.modification_seconds, tv_nsec: i64::try_from(self.modification_nanoseconds).unwrap_or(999_999_999), }, } } } fn invalid_manifest() -> io::Error { io::Error::from_raw_os_error(libc::EIO) } fn same_admission(left: Option<&AdmissionRecord>, right: Option<&AdmissionRecord>) -> bool { match (left, right) { (Some(left), Some(right)) => left.same_admission(right), (None, None) => true, _ => false, } } fn same_admissions( left: &BTreeMap>, right: &BTreeMap>, ) -> bool { left.len() == right.len() && left.iter().all(|(path, left)| { right .get(path) .is_some_and(|right| same_admission(left.as_ref(), right.as_ref())) }) }