Preserve hardlink topology during startup routing

This commit is contained in:
ms2k 2026-07-30 23:43:33 +09:00
commit 394fb65c86
7 changed files with 954 additions and 363 deletions

View file

@ -110,13 +110,13 @@ struct SnapshotInner {
record: PolicyRecord,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) struct PolicyRecord {
schema: u32,
repositories: Vec<RepositoryRecord>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct RepositoryRecord {
prefix: Vec<u8>,
tracked: Vec<Vec<u8>>,
@ -124,7 +124,7 @@ struct RepositoryRecord {
ignore_case: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct PatternRecord {
bytes: Vec<u8>,
source: Vec<u8>,

View file

@ -163,7 +163,7 @@ impl IgnoredWorkspace {
let classifier =
GitClassifier::from_root(workspace_label, &classifier_root, global_excludes)?;
if isolate_ignored {
crate::migration::admit_policy(workspace_label, &host, &isolated, &classifier)?;
crate::routing::admit_policy(workspace_label, &host, &isolated, &classifier)?;
}
Ok(Self {
host,

View file

@ -1,6 +1,7 @@
use std::fs::{self, OpenOptions};
use std::io::{Read, Seek, Write};
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::MetadataExt;
use std::sync::atomic::{AtomicU64, Ordering};
use fuser::{Config, LockOwner, MountOption};
@ -131,6 +132,62 @@ fn restart_clones_host_paths_that_become_ignored() {
);
}
#[test]
fn restart_clone_preserves_admitted_hardlink_topology() {
let fixture = Fixture::new();
repository(&fixture.workspace);
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
fs::write(fixture.workspace.join("full-a"), b"shared").unwrap();
fs::hard_link(
fixture.workspace.join("full-a"),
fixture.workspace.join("full-b"),
)
.unwrap();
fs::write(fixture.workspace.join("split-a"), b"boundary").unwrap();
fs::hard_link(
fixture.workspace.join("split-a"),
fixture.workspace.join("split-b"),
)
.unwrap();
fs::write(fixture.workspace.join("equal-a"), b"equal").unwrap();
fs::write(fixture.workspace.join("equal-b"), b"equal").unwrap();
fs::write(
fixture.workspace.join(".gitignore"),
b"full-a\nfull-b\nsplit-a\nequal-a\nequal-b\n",
)
.unwrap();
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
let host_full_a = fs::metadata(fixture.workspace.join("full-a")).unwrap();
let host_full_b = fs::metadata(fixture.workspace.join("full-b")).unwrap();
let isolated_full_a = fs::metadata(fixture.isolated.join("full-a")).unwrap();
let isolated_full_b = fs::metadata(fixture.isolated.join("full-b")).unwrap();
assert_eq!(host_full_a.ino(), host_full_b.ino());
assert_eq!(isolated_full_a.ino(), isolated_full_b.ino());
assert_ne!(host_full_a.ino(), isolated_full_a.ino());
let host_split_a = fs::metadata(fixture.workspace.join("split-a")).unwrap();
let host_split_b = fs::metadata(fixture.workspace.join("split-b")).unwrap();
let isolated_split_a = fs::metadata(fixture.isolated.join("split-a")).unwrap();
assert_eq!(host_split_a.ino(), host_split_b.ino());
assert_ne!(host_split_a.ino(), isolated_split_a.ino());
assert!(!fixture.isolated.join("split-b").exists());
let isolated_equal_a = fs::metadata(fixture.isolated.join("equal-a")).unwrap();
let isolated_equal_b = fs::metadata(fixture.isolated.join("equal-b")).unwrap();
assert_ne!(isolated_equal_a.ino(), isolated_equal_b.ino());
assert_eq!(
fs::read(fixture.isolated.join("full-a")).unwrap(),
b"shared"
);
assert_eq!(
fs::read(fixture.isolated.join("split-a")).unwrap(),
b"boundary"
);
}
#[test]
fn restart_removes_isolated_paths_that_return_to_host_routing() {
let fixture = Fixture::new();

View file

@ -3,6 +3,7 @@ mod classifier;
mod daemon;
mod filesystem;
mod migration;
mod routing;
mod sentry;
pub use classifier::{Classification, ClassificationSnapshot, GitClassifier, git_common_directory};

View file

@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{self, Read, Write};
@ -14,12 +14,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::backing::BackingRoot;
use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier, PolicyRecord};
const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions";
pub(crate) const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions";
const ROUTING: &str = "routing";
const ROUTING_POLICY: &str = "policy.json";
const ROUTING_POLICY_TEMPORARY: &str = "policy.tmp";
const INTENT_SUFFIX: &str = ".json";
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
@ -158,44 +155,6 @@ pub(crate) fn initialize(host: &BackingRoot, isolated: &BackingRoot) -> io::Resu
recover(host, isolated)
}
pub(crate) fn admit_policy(
workspace: &Path,
host: &BackingRoot,
isolated: &BackingRoot,
classifier: &GitClassifier,
) -> io::Result<()> {
let routing = Path::new(TRANSACTIONS).join(ROUTING);
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_policy_staging(isolated)?;
let previous = read_policy(isolated)?;
if let Some(previous) = previous {
let previous = GitClassifier::snapshot_from_record(workspace, previous)?;
reconcile_policy(host, isolated, &previous, &classifier.snapshot())?;
}
persist_policy(isolated, &classifier.policy_record())
}
fn clean_policy_staging(isolated: &BackingRoot) -> io::Result<()> {
let directory = Path::new(TRANSACTIONS).join(ROUTING);
for (name, kind) in isolated.typed_entries(&directory)? {
let path = directory.join(&name);
if name == OsStr::new(ROUTING_POLICY) && kind == rustix::fs::FileType::RegularFile {
continue;
}
if name == OsStr::new(ROUTING_POLICY_TEMPORARY) || name.as_bytes().starts_with(b"stage-") {
remove_tree(isolated, &path)?;
continue;
}
return Err(io::Error::from_raw_os_error(libc::EIO));
}
isolated.sync_directory(&directory)
}
pub(crate) fn migrate(
host: &BackingRoot,
isolated: &BackingRoot,
@ -303,222 +262,6 @@ pub(crate) fn migrate(
Ok(())
}
fn read_policy(isolated: &BackingRoot) -> io::Result<Option<PolicyRecord>> {
let path = Path::new(TRANSACTIONS).join(ROUTING).join(ROUTING_POLICY);
match isolated.read_file(&path, 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 = Path::new(TRANSACTIONS).join(ROUTING);
let temporary = directory.join(ROUTING_POLICY_TEMPORARY);
let destination = directory.join(ROUTING_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 reconcile_policy(
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.remove(Path::new(TRANSACTIONS));
paths.retain(|path, _| !reserved(path));
let mut transitions = paths
.into_iter()
.map(|(path, directory)| {
let old = previous.classify(&path, directory);
let new = current.classify(&path, directory);
(path, directory, old, new)
})
.filter(|(_, _, old, new)| old != new)
.collect::<Vec<_>>();
let expected_admissions = admission_stamps(host, &transitions)?;
transitions.sort_by_key(|(path, _, old, new)| {
let depth = path.components().count();
match (*old, *new) {
(Classification::Isolated, Classification::Host) => usize::MAX - depth,
_ => depth,
}
});
for (path, directory, old, new) in transitions {
match (old, new) {
(Classification::Host, Classification::Isolated) => {
clone_admitted_path(host, isolated, &path, directory)?;
}
(Classification::Isolated, Classification::Host) => {
remove_released_path(isolated, current, &path, directory)?;
}
_ => {}
}
}
let mut current_paths = BTreeMap::new();
collect_paths(host, Path::new(""), &mut current_paths)?;
collect_paths(isolated, Path::new(""), &mut current_paths)?;
current_paths.retain(|path, _| !reserved(path));
let current_transitions = current_paths
.into_iter()
.map(|(path, directory)| {
let old = previous.classify(&path, directory);
let new = current.classify(&path, directory);
(path, directory, old, new)
})
.filter(|(_, _, old, new)| old != new)
.collect::<Vec<_>>();
if admission_stamps(host, &current_transitions)? != expected_admissions {
return Err(io::Error::from_raw_os_error(libc::EAGAIN));
}
Ok(())
}
fn admission_stamps(
host: &BackingRoot,
transitions: &[(PathBuf, bool, Classification, Classification)],
) -> io::Result<BTreeMap<PathBuf, Option<ObjectStamp>>> {
transitions
.iter()
.filter(|(_, _, old, new)| *old == Classification::Host && *new == Classification::Isolated)
.map(|(path, _, _, _)| {
stat_if_exists(host, path)
.map(|stat| (path.clone(), stat.as_ref().map(ObjectStamp::from_stat)))
})
.collect()
}
fn collect_paths(
root: &BackingRoot,
directory: &Path,
paths: &mut BTreeMap<PathBuf, bool>,
) -> 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 clone_admitted_path(
host: &BackingRoot,
isolated: &BackingRoot,
path: &Path,
directory: bool,
) -> io::Result<()> {
let Some(host_stat) = stat_if_exists(host, path)? else {
if stat_if_exists(isolated, path)?.is_some() {
remove_tree(isolated, path)?;
}
return Ok(());
};
let host_directory = host_stat.st_mode & libc::S_IFMT == libc::S_IFDIR;
if host_directory != directory {
return Err(io::Error::from_raw_os_error(libc::EIO));
}
if directory {
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(host_stat.st_mode & 0o7777)) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
let output = isolated.open_directory(path)?;
set_metadata(&output, &host_stat)?;
output.sync_all()?;
return Ok(());
}
let sequence = TRANSACTION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let staging = Path::new(TRANSACTIONS)
.join(ROUTING)
.join(format!("stage-{}-{sequence}", std::process::id()));
if stat_if_exists(isolated, &staging)?.is_some() {
remove_tree(isolated, &staging)?;
}
let manifest = scan_tree(host, path)?;
copy_tree(host, path, isolated, &staging)?;
require_exact_tree(host, path, &manifest)?;
require_equivalent_tree(isolated, &staging, &manifest)?;
if stat_if_exists(isolated, path)?.is_some() {
remove_tree(isolated, path)?;
}
isolated.ensure_parent_directories(path)?;
isolated.rename(&staging, path, RenameFlags::NOREPLACE)?;
isolated.sync_directory(path.parent().unwrap_or_else(|| Path::new("")))?;
require_exact_tree(host, path, &manifest)?;
require_equivalent_tree(isolated, path, &manifest)
}
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<bool> {
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 recover(host: &BackingRoot, isolated: &BackingRoot) -> io::Result<()> {
for (name, kind) in isolated.typed_entries(Path::new(TRANSACTIONS))? {
if name == OsStr::new(ROUTING) && kind == rustix::fs::FileType::Directory {
@ -958,14 +701,18 @@ fn path_from_bytes(bytes: &[u8]) -> PathBuf {
PathBuf::from(OsString::from_vec(bytes.to_vec()))
}
#[cfg(test)]
#[path = "migration/recovery_tests.rs"]
mod recovery_tests;
#[cfg(test)]
mod tests {
use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use super::{
BackingRoot, Intent, Mode, ObjectStamp, Provenance, copy_tree, initialize, migrate,
path_bytes, path_from_bytes, persist_intent, scan_tree,
BackingRoot, Intent, Mode, Provenance, initialize, migrate, path_bytes, path_from_bytes,
persist_intent, scan_tree,
};
#[test]
@ -1217,104 +964,6 @@ mod tests {
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
}
#[test]
fn startup_restores_replaced_destination_before_publication() {
let fixture = Fixture::new("replacement-rollback");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
let quarantine = std::path::Path::new(".lazaret-quarantine-rollback");
let staging = std::path::Path::new(".lazaret-staging-rollback");
let backup = std::path::Path::new(".lazaret-backup-rollback");
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let intent = Intent {
schema: 2,
id: "rollback".to_owned(),
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
backup: path_bytes(backup),
destination_existed: true,
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
manifest: scan_tree(&host, source).unwrap(),
};
persist_intent(&isolated, &intent).unwrap();
host.rename(source, quarantine, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
host.sync_directory(std::path::Path::new("")).unwrap();
copy_tree(&host, quarantine, &isolated, staging).unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
initialize(&host, &isolated).unwrap();
assert_eq!(host.read_file(source, 16).unwrap(), b"new");
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
assert!(isolated.stat(staging).is_err());
assert!(isolated.stat(backup).is_err());
}
#[test]
fn startup_completes_published_replacement() {
let fixture = Fixture::new("replacement-published");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
let quarantine = std::path::Path::new(".lazaret-quarantine-published");
let staging = std::path::Path::new(".lazaret-staging-published");
let backup = std::path::Path::new(".lazaret-backup-published");
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let intent = Intent {
schema: 2,
id: "published".to_owned(),
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
backup: path_bytes(backup),
destination_existed: true,
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
manifest: scan_tree(&host, source).unwrap(),
};
persist_intent(&isolated, &intent).unwrap();
host.rename(source, quarantine, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
host.sync_directory(std::path::Path::new("")).unwrap();
copy_tree(&host, quarantine, &isolated, staging).unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(staging, destination, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
initialize(&host, &isolated).unwrap();
assert!(host.stat(source).is_err());
assert!(host.stat(quarantine).is_err());
assert!(isolated.stat(backup).is_err());
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"new");
}
struct Fixture {
root: std::path::PathBuf,
host: std::path::PathBuf,

View file

@ -0,0 +1,165 @@
use super::{
BackingRoot, Intent, Mode, ObjectStamp, Provenance, copy_tree, initialize, migrate, path_bytes,
persist_intent, scan_tree,
};
#[test]
fn runtime_migration_refuses_multiply_linked_regular_files() {
let fixture = Fixture::new("runtime-hardlink");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.bin");
let peer = std::path::Path::new("peer.bin");
host.write_file(source, b"shared", Mode::from_raw_mode(0o600))
.unwrap();
host.link(source, peer).unwrap();
let mut provenance = Provenance::default();
provenance.record(source.to_path_buf(), &host.stat(source).unwrap());
let error = migrate(
&host,
&isolated,
&provenance,
source,
std::path::Path::new("isolated.bin"),
true,
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
assert_eq!(
host.stat(source).unwrap().st_ino,
host.stat(peer).unwrap().st_ino
);
assert!(isolated.stat(std::path::Path::new("isolated.bin")).is_err());
}
#[test]
fn startup_restores_replaced_destination_before_publication() {
let fixture = Fixture::new("replacement-rollback");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
let quarantine = std::path::Path::new(".lazaret-quarantine-rollback");
let staging = std::path::Path::new(".lazaret-staging-rollback");
let backup = std::path::Path::new(".lazaret-backup-rollback");
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let intent = Intent {
schema: 2,
id: "rollback".to_owned(),
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
backup: path_bytes(backup),
destination_existed: true,
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
manifest: scan_tree(&host, source).unwrap(),
};
persist_intent(&isolated, &intent).unwrap();
host.rename(source, quarantine, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
host.sync_directory(std::path::Path::new("")).unwrap();
copy_tree(&host, quarantine, &isolated, staging).unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
initialize(&host, &isolated).unwrap();
assert_eq!(host.read_file(source, 16).unwrap(), b"new");
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
assert!(isolated.stat(staging).is_err());
assert!(isolated.stat(backup).is_err());
}
#[test]
fn startup_completes_published_replacement() {
let fixture = Fixture::new("replacement-published");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
let quarantine = std::path::Path::new(".lazaret-quarantine-published");
let staging = std::path::Path::new(".lazaret-staging-published");
let backup = std::path::Path::new(".lazaret-backup-published");
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let intent = Intent {
schema: 2,
id: "published".to_owned(),
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
backup: path_bytes(backup),
destination_existed: true,
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
manifest: scan_tree(&host, source).unwrap(),
};
persist_intent(&isolated, &intent).unwrap();
host.rename(source, quarantine, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
host.sync_directory(std::path::Path::new("")).unwrap();
copy_tree(&host, quarantine, &isolated, staging).unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(staging, destination, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
initialize(&host, &isolated).unwrap();
assert!(host.stat(source).is_err());
assert!(host.stat(quarantine).is_err());
assert!(isolated.stat(backup).is_err());
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"new");
}
struct Fixture {
root: std::path::PathBuf,
host: std::path::PathBuf,
isolated: std::path::PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let root = std::env::current_dir()
.unwrap()
.join("target/ignored-workspace-tests")
.join(format!("migration-recovery-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let host = root.join("host");
let isolated = root.join("isolated");
std::fs::create_dir_all(&host).unwrap();
std::fs::create_dir_all(&isolated).unwrap();
Self {
root,
host,
isolated,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}

View file

@ -0,0 +1,719 @@
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<Vec<u8>>,
}
#[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(&current_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, &current_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<PathBuf, Option<AdmissionRecord>>,
candidate: &Path,
) -> io::Result<()> {
let mut releases = expected_transitions
.iter()
.filter(|transition| {
transition.old == Classification::Isolated && transition.new == Classification::Host
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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, &current_transitions)?;
if !same_admissions(&current_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<PathBuf, Option<AdmissionRecord>>,
candidate: &Path,
) -> io::Result<HashMap<ObjectKey, PathBuf>> {
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<BTreeMap<PathBuf, Option<AdmissionRecord>>> {
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<Option<AdmissionRecord>> {
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<PathBuf, Option<AdmissionRecord>>,
) -> 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<PathBuf, Option<AdmissionRecord>>,
) -> 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<Vec<Transition>> {
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<PathBuf, bool>,
) -> 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<bool> {
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<PathBuf> {
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<Option<PolicyRecord>> {
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<Option<rustix::fs::Stat>> {
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<Self> {
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<Vec<u8>>,
) -> 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<PathBuf, Option<AdmissionRecord>>,
right: &BTreeMap<PathBuf, Option<AdmissionRecord>>,
) -> 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()))
})
}