Seal ignored-workspace routing at Domain startup

This commit is contained in:
ms2k 2026-07-30 00:52:58 +09:00
commit 1ad3b84db2
10 changed files with 720 additions and 285 deletions

View file

@ -4,9 +4,10 @@ use std::io;
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
use std::os::unix::ffi::OsStringExt; use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use bstr::BStr; use bstr::BStr;
use serde::{Deserialize, Serialize};
use crate::backing::{BackingRoot, validate_relative}; use crate::backing::{BackingRoot, validate_relative};
@ -40,10 +41,7 @@ impl ClassificationSnapshot {
} }
pub struct GitClassifier { pub struct GitClassifier {
workspace: PathBuf, current: Arc<SnapshotInner>,
root: BackingRoot,
global_excludes: Option<PathBuf>,
current: Mutex<Arc<SnapshotInner>>,
} }
/// Resolves the root repository's effective common Git directory. /// Resolves the root repository's effective common Git directory.
@ -59,29 +57,19 @@ pub fn git_common_directory(workspace: &Path) -> io::Result<PathBuf> {
impl GitClassifier { impl GitClassifier {
/// # Errors /// # Errors
/// Refuses a non-repository workspace or unreadable Git policy input. /// Refuses a non-repository workspace or unreadable Git policy input.
pub fn open(workspace: &Path, global_excludes: Option<PathBuf>) -> io::Result<Self> { pub fn open(workspace: &Path, global_excludes: Option<&Path>) -> io::Result<Self> {
let workspace = workspace.canonicalize()?; let workspace = workspace.canonicalize()?;
let root = BackingRoot::open(&workspace)?; let root = BackingRoot::open(&workspace)?;
Self::from_root(workspace, root, global_excludes) Self::from_root(&workspace, &root, global_excludes)
} }
pub(crate) fn from_root( pub(crate) fn from_root(
workspace: PathBuf, workspace: &Path,
root: BackingRoot, root: &BackingRoot,
global_excludes: Option<PathBuf>, global_excludes: Option<&Path>,
) -> io::Result<Self> { ) -> io::Result<Self> {
let current = Arc::new(SnapshotInner::load( let current = Arc::new(SnapshotInner::load(workspace, root, global_excludes, 1)?);
&workspace, Ok(Self { current })
&root,
global_excludes.as_deref(),
1,
)?);
Ok(Self {
workspace,
root,
global_excludes,
current: Mutex::new(current),
})
} }
#[must_use] #[must_use]
@ -96,35 +84,51 @@ impl GitClassifier {
#[must_use] #[must_use]
pub(crate) fn snapshot_for_paths(&self, relatives: &[&Path]) -> ClassificationSnapshot { pub(crate) fn snapshot_for_paths(&self, relatives: &[&Path]) -> ClassificationSnapshot {
let mut current = self let _ = relatives;
.current
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if relatives
.iter()
.any(|relative| current.relevant_input_changed(&self.root, relative))
{
let generation = current.generation.saturating_add(1);
if let Ok(next) = SnapshotInner::load(
&self.workspace,
&self.root,
self.global_excludes.as_deref(),
generation,
) {
*current = Arc::new(next);
}
}
ClassificationSnapshot { ClassificationSnapshot {
inner: Arc::clone(&current), inner: Arc::clone(&self.current),
} }
} }
pub(crate) fn policy_record(&self) -> PolicyRecord {
self.current.record.clone()
}
pub(crate) fn snapshot_from_record(
workspace: &Path,
record: PolicyRecord,
) -> io::Result<ClassificationSnapshot> {
Ok(ClassificationSnapshot {
inner: Arc::new(SnapshotInner::from_record(workspace, record)?),
})
}
} }
struct SnapshotInner { struct SnapshotInner {
generation: u64, generation: u64,
repositories: Vec<RepositoryPolicy>, repositories: Vec<RepositoryPolicy>,
workspace_inputs: BTreeMap<PathBuf, Option<InputStamp>>, record: PolicyRecord,
external_inputs: BTreeMap<PathBuf, Option<InputStamp>>, }
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct PolicyRecord {
schema: u32,
repositories: Vec<RepositoryRecord>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct RepositoryRecord {
prefix: Vec<u8>,
tracked: Vec<Vec<u8>>,
patterns: Vec<PatternRecord>,
ignore_case: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct PatternRecord {
bytes: Vec<u8>,
source: Vec<u8>,
root: Option<Vec<u8>>,
} }
impl SnapshotInner { impl SnapshotInner {
@ -193,11 +197,37 @@ impl SnapshotInner {
)); ));
} }
} }
let record = PolicyRecord {
schema: 1,
repositories: repositories
.iter()
.map(|repository| repository.record.clone())
.collect(),
};
Ok(Self { Ok(Self {
generation, generation,
repositories, repositories,
workspace_inputs, record,
external_inputs, })
}
fn from_record(workspace: &Path, record: PolicyRecord) -> io::Result<Self> {
if record.schema != 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"unsupported routing policy record",
));
}
let repositories = record
.repositories
.iter()
.cloned()
.map(|record| RepositoryPolicy::from_record(workspace, record))
.collect::<io::Result<Vec<_>>>()?;
Ok(Self {
generation: 0,
repositories,
record,
}) })
} }
@ -227,44 +257,6 @@ impl SnapshotInner {
.max_by_key(|repository| repository.prefix.components().count()) .max_by_key(|repository| repository.prefix.components().count())
.is_some_and(|repository| repository.is_tracked(relative)) .is_some_and(|repository| repository.is_tracked(relative))
} }
fn relevant_input_changed(&self, root: &BackingRoot, relative: &Path) -> bool {
if validate_relative(relative).is_err() {
return false;
}
let mut directory = if root
.stat(relative)
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR)
{
relative
} else {
relative.parent().unwrap_or_else(|| Path::new(""))
};
loop {
for name in [OsStr::new(".gitignore"), OsStr::new(".git")] {
let path = directory.join(name);
let Ok(current) = workspace_input_stamp(root, &path) else {
return true;
};
if self.workspace_inputs.get(&path).copied().unwrap_or(None) != current {
return true;
}
}
let Some(parent) = directory.parent() else {
break;
};
if parent == directory {
break;
}
directory = parent;
}
self.external_inputs
.iter()
.any(|(path, expected)| match input_stamp(path) {
Ok(current) => current != *expected,
Err(_) => true,
})
}
} }
struct RepositoryPolicy { struct RepositoryPolicy {
@ -273,6 +265,7 @@ struct RepositoryPolicy {
tracked_directories: BTreeSet<Vec<u8>>, tracked_directories: BTreeSet<Vec<u8>>,
search: gix_ignore::Search, search: gix_ignore::Search,
case: gix_ignore::glob::pattern::Case, case: gix_ignore::glob::pattern::Case,
record: RepositoryRecord,
} }
impl RepositoryPolicy { impl RepositoryPolicy {
@ -301,6 +294,7 @@ impl RepositoryPolicy {
} }
let mut search = gix_ignore::Search::default(); let mut search = gix_ignore::Search::default();
let mut patterns = Vec::new();
if let Some(global) = global_excludes if let Some(global) = global_excludes
&& let Some(bytes) = read_external_optional(global)? && let Some(bytes) = read_external_optional(global)?
{ {
@ -310,6 +304,7 @@ impl RepositoryPolicy {
Some(&repository), Some(&repository),
gix_ignore::search::Ignore::default(), gix_ignore::search::Ignore::default(),
); );
patterns.push(PatternRecord::new(&bytes, global, Some(&repository)));
} }
if let Some(bytes) = read_external_optional(&metadata.info_exclude)? { if let Some(bytes) = read_external_optional(&metadata.info_exclude)? {
search.add_patterns_buffer( search.add_patterns_buffer(
@ -318,6 +313,7 @@ impl RepositoryPolicy {
None, None,
gix_ignore::search::Ignore::default(), gix_ignore::search::Ignore::default(),
); );
patterns.push(PatternRecord::new(&bytes, &metadata.info_exclude, None));
} }
ignore_files.sort_by_key(|path| path.components().count()); ignore_files.sort_by_key(|path| path.components().count());
for path in ignore_files { for path in ignore_files {
@ -325,10 +321,11 @@ impl RepositoryPolicy {
let source = workspace.join(&path); let source = workspace.join(&path);
search.add_patterns_buffer( search.add_patterns_buffer(
&bytes, &bytes,
source, &source,
Some(&repository), Some(&repository),
gix_ignore::search::Ignore::default(), gix_ignore::search::Ignore::default(),
); );
patterns.push(PatternRecord::new(&bytes, &source, Some(&repository)));
} }
let ignore_case = match read_external_optional(&metadata.config)? { let ignore_case = match read_external_optional(&metadata.config)? {
Some(bytes) => config_boolean(&bytes, "core", "ignoreCase")?, Some(bytes) => config_boolean(&bytes, "core", "ignoreCase")?,
@ -339,12 +336,65 @@ impl RepositoryPolicy {
} else { } else {
gix_ignore::glob::pattern::Case::Sensitive gix_ignore::glob::pattern::Case::Sensitive
}; };
let record = RepositoryRecord {
prefix: path_bytes(&prefix),
tracked: tracked.iter().cloned().collect(),
patterns,
ignore_case,
};
Ok(Self { Ok(Self {
prefix, prefix,
tracked, tracked,
tracked_directories, tracked_directories,
search, search,
case, case,
record,
})
}
fn from_record(workspace: &Path, record: RepositoryRecord) -> io::Result<Self> {
let prefix = path_from_bytes(&record.prefix);
validate_relative(&prefix)?;
let tracked = record.tracked.iter().cloned().collect::<BTreeSet<_>>();
let mut tracked_directories = BTreeSet::new();
for path in &tracked {
for separator in path
.iter()
.enumerate()
.filter_map(|(index, byte)| (*byte == b'/').then_some(index))
{
tracked_directories.insert(path[..separator].to_vec());
}
}
let mut search = gix_ignore::Search::default();
for pattern in &record.patterns {
let source = path_from_bytes(&pattern.source);
let root = pattern.root.as_deref().map(path_from_bytes);
search.add_patterns_buffer(
&pattern.bytes,
&source,
root.as_deref(),
gix_ignore::search::Ignore::default(),
);
}
let case = if record.ignore_case {
gix_ignore::glob::pattern::Case::Fold
} else {
gix_ignore::glob::pattern::Case::Sensitive
};
if !prefix.as_os_str().is_empty() && !workspace.join(&prefix).starts_with(workspace) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"routing policy repository escapes the workspace",
));
}
Ok(Self {
prefix,
tracked,
tracked_directories,
search,
case,
record,
}) })
} }
@ -394,6 +444,16 @@ impl RepositoryPolicy {
} }
} }
impl PatternRecord {
fn new(bytes: &[u8], source: &Path, root: Option<&Path>) -> Self {
Self {
bytes: bytes.to_vec(),
source: path_bytes(source),
root: root.map(path_bytes),
}
}
}
struct RepositoryMetadata { struct RepositoryMetadata {
index: PathBuf, index: PathBuf,
config: PathBuf, config: PathBuf,
@ -707,6 +767,14 @@ fn config_boolean(bytes: &[u8], section: &str, key: &str) -> io::Result<bool> {
.map_err(|error| io::Error::other(format!("Git configuration boolean is invalid: {error}"))) .map_err(|error| io::Error::other(format!("Git configuration boolean is invalid: {error}")))
} }
fn path_bytes(path: &Path) -> Vec<u8> {
path.as_os_str().as_bytes().to_vec()
}
fn path_from_bytes(bytes: &[u8]) -> PathBuf {
PathBuf::from(OsString::from_vec(bytes.to_vec()))
}
fn input_stamp(path: &Path) -> io::Result<Option<InputStamp>> { fn input_stamp(path: &Path) -> io::Result<Option<InputStamp>> {
let parent = path let parent = path
.parent() .parent()
@ -872,7 +940,7 @@ mod tests {
} }
#[test] #[test]
fn snapshots_route_tracked_ordinary_ignored_and_live_policy_changes() { fn snapshots_route_tracked_ordinary_and_startup_sealed_ignored_paths() {
let fixture = Fixture::new("classification"); let fixture = Fixture::new("classification");
repository(&fixture.workspace, &[b"tracked.cache"]); repository(&fixture.workspace, &[b"tracked.cache"]);
fs::write(fixture.workspace.join(".gitignore"), b"*.cache\n").unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"*.cache\n").unwrap();
@ -895,10 +963,10 @@ mod tests {
fs::write(fixture.workspace.join(".gitignore"), b"*.different\n").unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"*.different\n").unwrap();
let second = classifier.snapshot_for(std::path::Path::new("ignored.cache")); let second = classifier.snapshot_for(std::path::Path::new("ignored.cache"));
assert!(second.generation() > first.generation()); assert_eq!(second.generation(), first.generation());
assert_eq!( assert_eq!(
second.classify(std::path::Path::new("ignored.cache"), false), second.classify(std::path::Path::new("ignored.cache"), false),
Classification::Host Classification::Isolated
); );
} }

View file

@ -94,7 +94,7 @@ impl IgnoredWorkspace {
pub fn open( pub fn open(
workspace: &Path, workspace: &Path,
isolated_state: &Path, isolated_state: &Path,
global_excludes: Option<PathBuf>, global_excludes: Option<&Path>,
) -> io::Result<Self> { ) -> io::Result<Self> {
let workspace = workspace.canonicalize()?; let workspace = workspace.canonicalize()?;
let isolated_state = isolated_state.canonicalize()?; let isolated_state = isolated_state.canonicalize()?;
@ -103,16 +103,16 @@ impl IgnoredWorkspace {
} }
let host = BackingRoot::open(&workspace)?; let host = BackingRoot::open(&workspace)?;
let isolated = BackingRoot::open(&isolated_state)?; let isolated = BackingRoot::open(&isolated_state)?;
Self::from_roots(workspace, host, isolated, global_excludes) Self::from_roots(&workspace, host, isolated, global_excludes)
} }
/// # Errors /// # Errors
/// Refuses aliased or non-directory authorities and unreadable Git policy inputs. /// Refuses aliased or non-directory authorities and unreadable Git policy inputs.
pub fn from_authorities( pub fn from_authorities(
workspace_label: PathBuf, workspace_label: &Path,
workspace: File, workspace: File,
isolated_state: File, isolated_state: File,
global_excludes: Option<PathBuf>, global_excludes: Option<&Path>,
) -> io::Result<Self> { ) -> io::Result<Self> {
let host = BackingRoot::from_file(workspace)?; let host = BackingRoot::from_file(workspace)?;
let isolated = BackingRoot::from_file(isolated_state)?; let isolated = BackingRoot::from_file(isolated_state)?;
@ -122,10 +122,10 @@ impl IgnoredWorkspace {
/// # Errors /// # Errors
/// Refuses aliased or non-directory authorities and unreadable Git policy inputs. /// Refuses aliased or non-directory authorities and unreadable Git policy inputs.
pub fn from_authorities_with_policy( pub fn from_authorities_with_policy(
workspace_label: PathBuf, workspace_label: &Path,
workspace: File, workspace: File,
isolated_state: File, isolated_state: File,
global_excludes: Option<PathBuf>, global_excludes: Option<&Path>,
isolate_ignored: bool, isolate_ignored: bool,
) -> io::Result<Self> { ) -> io::Result<Self> {
let host = BackingRoot::from_file(workspace)?; let host = BackingRoot::from_file(workspace)?;
@ -140,19 +140,19 @@ impl IgnoredWorkspace {
} }
fn from_roots( fn from_roots(
workspace_label: PathBuf, workspace_label: &Path,
host: BackingRoot, host: BackingRoot,
isolated: BackingRoot, isolated: BackingRoot,
global_excludes: Option<PathBuf>, global_excludes: Option<&Path>,
) -> io::Result<Self> { ) -> io::Result<Self> {
Self::from_roots_with_policy(workspace_label, host, isolated, global_excludes, true) Self::from_roots_with_policy(workspace_label, host, isolated, global_excludes, true)
} }
fn from_roots_with_policy( fn from_roots_with_policy(
workspace_label: PathBuf, workspace_label: &Path,
host: BackingRoot, host: BackingRoot,
isolated: BackingRoot, isolated: BackingRoot,
global_excludes: Option<PathBuf>, global_excludes: Option<&Path>,
isolate_ignored: bool, isolate_ignored: bool,
) -> io::Result<Self> { ) -> io::Result<Self> {
if host.identity()? == isolated.identity()? { if host.identity()? == isolated.identity()? {
@ -161,7 +161,10 @@ impl IgnoredWorkspace {
crate::migration::initialize(&host, &isolated)?; crate::migration::initialize(&host, &isolated)?;
let classifier_root = BackingRoot::from_file(host.duplicate()?)?; let classifier_root = BackingRoot::from_file(host.duplicate()?)?;
let classifier = let classifier =
GitClassifier::from_root(workspace_label, classifier_root, global_excludes)?; GitClassifier::from_root(workspace_label, &classifier_root, global_excludes)?;
if isolate_ignored {
crate::migration::admit_policy(workspace_label, &host, &isolated, &classifier)?;
}
Ok(Self { Ok(Self {
host, host,
isolated, isolated,
@ -183,7 +186,7 @@ impl IgnoredWorkspace {
let filesystem = Self::open( let filesystem = Self::open(
&configuration.workspace, &configuration.workspace,
&configuration.isolated_state, &configuration.isolated_state,
configuration.global_excludes, configuration.global_excludes.as_deref(),
)?; )?;
let config = mount_config(configuration.threads); let config = mount_config(configuration.threads);
fuser::mount2(filesystem, configuration.mountpoint, &config) fuser::mount2(filesystem, configuration.mountpoint, &config)
@ -196,10 +199,10 @@ impl IgnoredWorkspace {
ready: impl FnOnce() -> io::Result<()>, ready: impl FnOnce() -> io::Result<()>,
) -> io::Result<()> { ) -> io::Result<()> {
let filesystem = Self::from_authorities_with_policy( let filesystem = Self::from_authorities_with_policy(
configuration.workspace_label, &configuration.workspace_label,
configuration.workspace, configuration.workspace,
configuration.isolated_state, configuration.isolated_state,
configuration.global_excludes, configuration.global_excludes.as_deref(),
configuration.isolate_ignored, configuration.isolate_ignored,
)?; )?;
let session = fuser::spawn_mount2( let session = fuser::spawn_mount2(

View file

@ -49,15 +49,13 @@ impl IgnoredWorkspace {
if snapshot.is_tracked(&path) { if snapshot.is_tracked(&path) {
continue; continue;
} }
let host = self.host.stat(&path).ok(); let directory = self
let isolated = self.isolated.stat(&path).ok(); .isolated
if let Some(isolated) = isolated { .stat(&path)
let compatible = host.is_some_and(|host| { .or_else(|_| self.host.stat(&path))
host.st_mode & libc::S_IFMT == isolated.st_mode & libc::S_IFMT .is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR);
}); if snapshot.classify(&path, directory) == Classification::Isolated {
if !compatible { return Err(io::Error::from_raw_os_error(libc::EBUSY));
return Err(io::Error::from_raw_os_error(libc::EBUSY));
}
} }
} }
Ok(()) Ok(())

View file

@ -66,7 +66,7 @@ fn disabled_isolation_routes_ignored_paths_to_the_host_backing() {
repository(&fixture.workspace); repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap();
let filesystem = IgnoredWorkspace::from_authorities_with_policy( let filesystem = IgnoredWorkspace::from_authorities_with_policy(
fixture.workspace.clone(), &fixture.workspace,
fs::File::open(&fixture.workspace).unwrap(), fs::File::open(&fixture.workspace).unwrap(),
fs::File::open(&fixture.isolated).unwrap(), fs::File::open(&fixture.isolated).unwrap(),
None, None,
@ -82,7 +82,139 @@ fn disabled_isolation_routes_ignored_paths_to_the_host_backing() {
} }
#[test] #[test]
fn newly_ignored_created_directory_remains_a_safe_traversal_shell() { fn first_admission_does_not_import_ignored_host_material() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"secret\n").unwrap();
fs::write(fixture.workspace.join("secret"), b"host secret").unwrap();
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
assert!(!fixture.isolated.join("secret").exists());
let snapshot = filesystem
.classifier
.snapshot_for(std::path::Path::new("secret"));
assert_eq!(
filesystem
.resolve(&snapshot, std::path::Path::new("secret"))
.err()
.unwrap()
.raw_os_error(),
Some(libc::ENOENT)
);
}
#[test]
fn restart_clones_host_paths_that_become_ignored() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join("artifact"), b"host generation").unwrap();
fs::write(fixture.isolated.join("artifact"), b"stale isolated").unwrap();
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
fs::write(fixture.workspace.join(".gitignore"), b"artifact\n").unwrap();
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
assert_eq!(
fs::read(fixture.isolated.join("artifact")).unwrap(),
b"host generation"
);
let snapshot = filesystem
.classifier
.snapshot_for(std::path::Path::new("artifact"));
assert_eq!(
filesystem
.resolve(&snapshot, std::path::Path::new("artifact"))
.unwrap()
.backing,
Classification::Isolated
);
}
#[test]
fn restart_removes_isolated_paths_that_return_to_host_routing() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"artifact\n").unwrap();
fs::write(fixture.workspace.join("artifact"), b"host generation").unwrap();
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
fs::write(fixture.isolated.join("artifact"), b"isolated generation").unwrap();
fs::write(fixture.workspace.join(".gitignore"), b"").unwrap();
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
assert!(!fixture.isolated.join("artifact").exists());
let snapshot = filesystem
.classifier
.snapshot_for(std::path::Path::new("artifact"));
assert_eq!(
filesystem
.resolve(&snapshot, std::path::Path::new("artifact"))
.unwrap()
.backing,
Classification::Host
);
}
#[test]
fn restart_preserves_paths_that_remain_ignored() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"artifact\n").unwrap();
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
fs::write(fixture.isolated.join("artifact"), b"isolated generation").unwrap();
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
assert_eq!(
fs::read(fixture.isolated.join("artifact")).unwrap(),
b"isolated generation"
);
}
#[test]
fn restart_discards_unpublished_policy_staging() {
let fixture = Fixture::new();
repository(&fixture.workspace);
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
let staging = fixture
.isolated
.join(".lazaret-ignored-workspace-transactions/routing/stage-interrupted");
fs::write(&staging, b"unpublished").unwrap();
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
assert!(!staging.exists());
}
#[test]
fn index_publication_cannot_change_an_isolated_route_mid_domain() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"artifact\n").unwrap();
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
fs::write(
fixture.workspace.join("candidate-index"),
index_with_path(b"artifact"),
)
.unwrap();
let snapshot = filesystem
.classifier
.snapshot_for(std::path::Path::new("artifact"));
let error = filesystem
.require_index_publication_safe(
Classification::Host,
std::path::Path::new("candidate-index"),
&snapshot,
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EBUSY));
}
#[test]
fn policy_created_during_a_domain_does_not_change_its_routes() {
let fixture = Fixture::new(); let fixture = Fixture::new();
repository(&fixture.workspace); repository(&fixture.workspace);
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap(); let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
@ -106,7 +238,7 @@ fn newly_ignored_created_directory_remains_a_safe_traversal_shell() {
assert_eq!(entry.backing, Classification::Host); assert_eq!(entry.backing, Classification::Host);
assert_eq!( assert_eq!(
filesystem.classify(&snapshot, &path.join("python3"), false), filesystem.classify(&snapshot, &path.join("python3"), false),
Classification::Isolated Classification::Host
); );
} }
@ -140,7 +272,7 @@ fn ignored_host_directory_requires_current_creation_provenance() {
#[test] #[test]
#[ignore = "requires a usable host FUSE device"] #[ignore = "requires a usable host FUSE device"]
fn physical_router_preserves_lock_domains_across_transition() { fn physical_router_keeps_lock_domains_on_the_startup_route() {
let fixture = Fixture::new(); let fixture = Fixture::new();
repository(&fixture.workspace); repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap();
@ -165,42 +297,25 @@ fn physical_router_preserves_lock_domains_across_transition() {
.write(true) .write(true)
.open(&mounted_path) .open(&mounted_path)
.unwrap(); .unwrap();
let lock = record_lock(0, 0, libc::F_WRLCK).unwrap();
fcntl(old.as_fd(), FcntlArg::F_SETLK(&lock)).unwrap();
fs::write(fixture.workspace.join(".gitignore"), b"different/\n").unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"different/\n").unwrap();
let new = OpenOptions::new() let new = OpenOptions::new()
.read(true) .read(true)
.write(true) .write(true)
.open(&mounted_path) .open(&mounted_path)
.unwrap(); .unwrap();
assert_eq!(fs::read(&mounted_path).unwrap(), b"host"); assert_eq!(fs::read(&mounted_path).unwrap(), b"isolated");
let isolated = OpenOptions::new()
.read(true)
.write(true)
.open(fixture.isolated.join("ignored/locked"))
.unwrap();
assert_eq!(
fcntl(isolated.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap_err(),
nix::errno::Errno::EAGAIN
);
let host = OpenOptions::new() let host = OpenOptions::new()
.read(true) .read(true)
.write(true) .write(true)
.open(fixture.workspace.join("ignored/locked")) .open(fixture.workspace.join("ignored/locked"))
.unwrap(); .unwrap();
let lock = record_lock(0, 0, libc::F_WRLCK).unwrap();
fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap(); fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap();
let unlock = record_lock(0, 0, libc::F_UNLCK).unwrap(); let unlock = record_lock(0, 0, libc::F_UNLCK).unwrap();
fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&unlock)).unwrap(); fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&unlock)).unwrap();
fcntl(new.as_fd(), FcntlArg::F_SETLK(&lock)).unwrap();
assert_eq!(
fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap_err(),
nix::errno::Errno::EAGAIN
);
drop(old); drop(old);
fcntl(isolated.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap();
drop(new); drop(new);
fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap(); fcntl(host.as_fd(), FcntlArg::F_OFD_SETLK(&lock)).unwrap();
@ -209,10 +324,14 @@ fn physical_router_preserves_lock_domains_across_transition() {
#[test] #[test]
#[ignore = "requires a usable host FUSE device"] #[ignore = "requires a usable host FUSE device"]
fn physical_router_preserves_routes_transitions_and_cross_route_atomicity() { fn physical_router_seals_routes_and_preserves_cross_route_atomicity() {
let fixture = Fixture::new(); let fixture = Fixture::new();
repository(&fixture.workspace); repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap(); fs::write(
fixture.workspace.join(".gitignore"),
b"ignored/\ndestination\n",
)
.unwrap();
fs::create_dir(fixture.workspace.join("ignored")).unwrap(); fs::create_dir(fixture.workspace.join("ignored")).unwrap();
fs::write(fixture.workspace.join("ignored/sentinel"), b"host").unwrap(); fs::write(fixture.workspace.join("ignored/sentinel"), b"host").unwrap();
fs::create_dir(fixture.isolated.join("ignored")).unwrap(); fs::create_dir(fixture.isolated.join("ignored")).unwrap();
@ -246,7 +365,7 @@ fn physical_router_preserves_routes_transitions_and_cross_route_atomicity() {
fs::write(fixture.workspace.join(".gitignore"), b"different/\n").unwrap(); fs::write(fixture.workspace.join(".gitignore"), b"different/\n").unwrap();
assert_eq!( assert_eq!(
fs::read(fixture.mountpoint.join("ignored/sentinel")).unwrap(), fs::read(fixture.mountpoint.join("ignored/sentinel")).unwrap(),
b"host" b"isolated"
); );
pinned.write_all(b"-pinned").unwrap(); pinned.write_all(b"-pinned").unwrap();
pinned.flush().unwrap(); pinned.flush().unwrap();
@ -402,6 +521,21 @@ fn repository(root: &std::path::Path) {
fs::write(root.join(".git/index"), index).unwrap(); fs::write(root.join(".git/index"), index).unwrap();
} }
fn index_with_path(path: &[u8]) -> Vec<u8> {
let mut index = b"DIRC".to_vec();
index.extend_from_slice(&2u32.to_be_bytes());
index.extend_from_slice(&1u32.to_be_bytes());
index.extend_from_slice(&[0; 60]);
index.extend_from_slice(&(u16::try_from(path.len()).unwrap()).to_be_bytes());
index.extend_from_slice(path);
index.push(0);
while !(index.len() - 12).is_multiple_of(8) {
index.push(0);
}
index.extend_from_slice(&[0; 20]);
index
}
struct Fixture { struct Fixture {
root: std::path::PathBuf, root: std::path::PathBuf,
workspace: std::path::PathBuf, workspace: std::path::PathBuf,

View file

@ -1,4 +1,4 @@
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
use std::fs::File; use std::fs::File;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
@ -14,8 +14,12 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::backing::BackingRoot; use crate::backing::BackingRoot;
use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier, PolicyRecord};
const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions"; 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"; const INTENT_SUFFIX: &str = ".json";
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0); static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
@ -154,6 +158,44 @@ pub(crate) fn initialize(host: &BackingRoot, isolated: &BackingRoot) -> io::Resu
recover(host, isolated) 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( pub(crate) fn migrate(
host: &BackingRoot, host: &BackingRoot,
isolated: &BackingRoot, isolated: &BackingRoot,
@ -261,8 +303,227 @@ pub(crate) fn migrate(
Ok(()) 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<()> { fn recover(host: &BackingRoot, isolated: &BackingRoot) -> io::Result<()> {
for (name, kind) in isolated.typed_entries(Path::new(TRANSACTIONS))? { for (name, kind) in isolated.typed_entries(Path::new(TRANSACTIONS))? {
if name == OsStr::new(ROUTING) && kind == rustix::fs::FileType::Directory {
continue;
}
if kind == rustix::fs::FileType::RegularFile && name.as_bytes().ends_with(b".tmp") { if kind == rustix::fs::FileType::RegularFile && name.as_bytes().ends_with(b".tmp") {
isolated.unlink(&Path::new(TRANSACTIONS).join(name), false)?; isolated.unlink(&Path::new(TRANSACTIONS).join(name), false)?;
continue; continue;

View file

@ -720,24 +720,34 @@ repositories, and `core.ignoreCase`. Non-UTF-8 path components are supported as
opaque bytes; NUL and `/` retain their ordinary pathname meaning and cannot be opaque bytes; NUL and `/` retain their ordinary pathname meaning and cannot be
components. components.
Each pathname operation uses one immutable classification snapshot. Publication Domain initialization reads and seals one complete classification snapshot before
of a changed index or ignore input serializes against new pathname operations and router readiness. Every pathname operation in that Domain generation uses the
either installs one complete new snapshot or leaves the prior snapshot active. sealed snapshot. Changes to an ignore file, repository configuration, global
There is no partially updated matcher. Existing file descriptions, mappings, excludes, nested-repository boundary, or Git index remain ordinary shared host
locks, and directory streams remain pinned to the backing object and snapshot mutations but do not change routing in the running generation. There is no live
under which they were opened. A subsequent lookup or open uses the current matcher publication and no partially updated matcher. File descriptions,
snapshot. Writes, flushes, locks, and mapping writeback through an old handle mappings, locks, and directory streams remain pinned to their original backing
affect only its original backing object; a transition never rebinds a handle or objects for their lifetimes.
copies its content to the other backing.
Directory lookup and enumeration expose only the entry selected by the current The router persists the admitted classification inputs in private Domain state.
snapshot. An ignored host entry is hidden even when no isolated entry exists. A Before admitting a later generation it compares the prior and candidate
stale isolated entry is retained but hidden while its pathname is host-backed. classifiers across the host and isolated namespaces. A path ignored on first
On a non-ignored-to-ignored transition, subsequent lookups expose the retained admission starts absent in isolated state; corresponding host material is never
isolated entry or absence and hide the host entry. On an imported. A path that remains ignored preserves its isolated state. A path that
ignored-to-non-ignored transition, subsequent lookups expose the host entry or changes from host-backed to ignored is cloned from the current host object into
absence and retain the isolated entry hidden. Neither transition imports, isolated state, replacing stale isolated state, while the host object remains
exports, merges, deletes, or otherwise copies content between backings. unchanged. A path that changes from ignored to host-backed has its isolated object
deleted before the host object or absence is exposed. Structural isolated
directories may remain only where required to reach descendants that are still
ignored.
Reconciliation completes before router readiness and publishes the candidate
policy only after the host inputs copied by the transition remain stable. A
failure or concurrent host mutation leaves the prior policy authoritative and
the generation unadmitted; a later initialization retries from that policy.
Recovery may repeat a clone or deletion, but never exports isolated bytes to the
host. No open handle spans a policy transition because transitions occur only
between Domain generations.
A same-backing `rename` or hard link has the backing filesystem's atomicity and A same-backing `rename` or hard link has the backing filesystem's atomicity and
error behavior. An isolated-to-host rename, cross-backing hard link, error behavior. An isolated-to-host rename, cross-backing hard link,
@ -792,17 +802,15 @@ processes that bypass FUSE.
All other cross-backing renames retain `EXDEV` behavior. Applications may react All other cross-backing renames retain `EXDEV` behavior. Applications may react
to `EXDEV` with their own visible copy and unlink operations. to `EXDEV` with their own visible copy and unlink operations.
An index publication that would make an isolated object tracked is accepted only An index publication in a running generation that newly tracks any path routed to
when a type-compatible host worktree object already exists independently. The isolated state returns `EBUSY` before replacing the index, even when a
router then selects that host object and retains the isolated object hidden; it type-compatible host worktree object exists. Accepting it would change the
does not compare, merge, or copy isolated bytes. A guest index-lock rename that meaning of the shared index without changing the sealed route. Unmediated host
would instead create a tracked path with only an isolated object returns `EBUSY` index changes likewise do not change routing in the running generation. On the
before replacing the index. If an unmediated host index change creates that next initialization they participate in the candidate classifier and the same
state, pathname access to the inconsistent path fails with `EIO` until the host between-generation reconciliation rules apply. Consequently, ordinary add and
index/worktree tuple is made consistent or reverted. The isolated object remains commit are supported for host-backed paths, while force-adding a path routed to
recoverable Domain state. Consequently, ordinary add and commit are isolated state is explicitly unsupported rather than implicit host copy-back or
supported for host-backed paths, while force-adding an isolated-only path is an
explicitly unsupported Git operation rather than implicit host copy-back or
silent disappearance. silent disappearance.
The v1 operation contract supports lookup, bytewise directory enumeration, The v1 operation contract supports lookup, bytewise directory enumeration,
@ -829,9 +837,9 @@ promises.
The repository `lazaret-ignored-workspace-contract-v1` corpus is a The repository `lazaret-ignored-workspace-contract-v1` corpus is a
non-authoritative executable test inventory derived from these semantics. It non-authoritative executable test inventory derived from these semantics. It
covers ignore precedence and live changes, non-UTF-8 and case-folded matching, covers ignore precedence and startup-sealed changes, non-UTF-8 and case-folded matching,
linked worktrees and nested repositories, every operation class above, linked worktrees and nested repositories, every operation class above,
open-handle transitions, concurrent host and sandbox mutation, router loss, and generation transitions, concurrent host and sandbox mutation, router loss, and
absence of silent loss or copy-back. absence of silent loss or copy-back.
Its benchmark protocol compares direct-host and routed placements using identical Its benchmark protocol compares direct-host and routed placements using identical
fresh fixture materialization and reports metadata, Git, offline package fresh fixture materialization and reports metadata, Git, offline package

View file

@ -44,19 +44,18 @@
}, },
{ {
"id": "ignored-to-host-policy", "id": "ignored-to-host-policy",
"trigger": "path becomes non-ignored without becoming tracked", "trigger": "next Domain initialization classifies an isolated path as host-backed",
"new_lookups": "host-or-absent", "new_lookups": "host-or-absent",
"open_handles": "remain-isolated", "isolated_state": "deleted-before-readiness",
"isolated_state": "retained-hidden",
"copy": "none" "copy": "none"
}, },
{ {
"id": "host-to-ignored-policy", "id": "host-to-ignored-policy",
"trigger": "untracked path becomes ignored", "trigger": "next Domain initialization classifies a host-backed path as ignored",
"new_lookups": "retained-isolated-or-absent", "new_lookups": "host-clone-or-absent",
"open_handles": "remain-host", "host_state": "retained-unchanged",
"host_state": "retained-hidden", "isolated_state": "host-clone-replaces-stale-state",
"copy": "none" "copy": "host-to-isolated-only"
}, },
{ {
"id": "isolated-to-tracked-guest-publication", "id": "isolated-to-tracked-guest-publication",
@ -65,22 +64,6 @@
"index_mutation": "none", "index_mutation": "none",
"isolated_state": "retained-visible", "isolated_state": "retained-visible",
"copy": "none" "copy": "none"
},
{
"id": "isolated-to-tracked-host-established",
"trigger": "published index and type-compatible host object both exist",
"new_lookups": "host",
"open_handles": "remain-isolated",
"isolated_state": "retained-hidden",
"copy": "none"
},
{
"id": "isolated-to-tracked-unmediated-inconsistent",
"trigger": "unmediated index publication tracks a path without a host object",
"result": "EIO",
"path_state": "fail-closed-until-consistent",
"isolated_state": "retained-hidden-recoverable",
"copy": "none"
} }
], ],
"filesystem_operations": [ "filesystem_operations": [

View file

@ -67,12 +67,12 @@
"expect": ["nested-rules-selected", "host-sentinel-hidden"] "expect": ["nested-rules-selected", "host-sentinel-hidden"]
}, },
{ {
"id": "live-ignore-publication", "id": "startup-sealed-ignore-mutation",
"tags": ["ignore-precedence", "live-policy-change", "concurrent-mutation"], "tags": ["ignore-precedence", "startup-sealed-policy", "concurrent-mutation"],
"path_hex": "67656e6572617465642f6f7574707574", "path_hex": "67656e6572617465642f6f7574707574",
"setup": ["host-object", "path-initially-non-ignored"], "setup": ["host-object", "path-initially-non-ignored"],
"actions": ["host-publish-ignore", "sandbox-open-after-publication"], "actions": ["host-mutate-ignore", "sandbox-open-in-same-generation"],
"expect": ["single-classification-epoch", "host-object-hidden", "no-copy"] "expect": ["single-classification-epoch", "host-object-remains-visible", "no-copy"]
}, },
{ {
"id": "cargo-target-host-to-isolated-rename", "id": "cargo-target-host-to-isolated-rename",
@ -108,19 +108,19 @@
}, },
{ {
"id": "ignored-becomes-non-ignored", "id": "ignored-becomes-non-ignored",
"tags": ["ignored-to-tracked-transition", "open-handle-transition", "no-copy-back"], "tags": ["startup-policy-transition", "no-copy-back"],
"path_hex": "63616368652f64617461", "path_hex": "63616368652f64617461",
"setup": ["isolated-object", "host-object", "open-isolated-handle"], "setup": ["isolated-object", "host-object", "path-ignored"],
"actions": ["host-negate-ignore", "sandbox-open-new", "write-old-handle"], "actions": ["stop-domain", "host-negate-ignore", "start-domain"],
"expect": ["new-handle-host", "old-handle-isolated", "no-copy", "both-objects-retained"] "expect": ["host-object-visible", "isolated-object-deleted", "no-copy-back"]
}, },
{ {
"id": "non-ignored-becomes-ignored", "id": "non-ignored-becomes-ignored",
"tags": ["ignored-to-tracked-transition", "open-handle-transition", "no-copy-back"], "tags": ["startup-policy-transition", "host-to-isolated-copy", "no-copy-back"],
"path_hex": "6f75742f64617461", "path_hex": "6f75742f64617461",
"setup": ["host-object", "retained-isolated-object", "open-host-handle"], "setup": ["host-object", "stale-isolated-object", "path-initially-non-ignored"],
"actions": ["host-publish-ignore", "sandbox-open-new", "write-old-handle"], "actions": ["stop-domain", "host-publish-ignore", "start-domain"],
"expect": ["new-handle-isolated", "old-handle-host", "no-copy", "both-objects-retained"] "expect": ["isolated-clone-visible", "stale-isolated-replaced", "host-object-unchanged"]
}, },
{ {
"id": "guest-force-add-isolated-only", "id": "guest-force-add-isolated-only",
@ -132,35 +132,35 @@
}, },
{ {
"id": "host-established-tracked-cutover", "id": "host-established-tracked-cutover",
"tags": ["ignored-to-tracked-transition", "open-handle-transition", "no-copy-back"], "tags": ["ignored-to-tracked-transition", "startup-policy-transition", "no-copy-back"],
"path_hex": "6173736574732f67656e657261746564", "path_hex": "6173736574732f67656e657261746564",
"setup": ["isolated-object", "open-isolated-handle"], "setup": ["isolated-object", "path-ignored"],
"actions": ["host-create-type-compatible-object", "host-publish-tracked-index", "sandbox-open-new"], "actions": ["host-create-type-compatible-object", "host-publish-tracked-index", "sandbox-open-in-same-generation", "restart-domain"],
"expect": ["new-handle-host", "old-handle-isolated", "isolated-object-retained-hidden", "no-copy"] "expect": ["same-generation-route-isolated", "next-generation-route-host", "isolated-object-deleted", "no-copy-back"]
}, },
{ {
"id": "unmediated-inconsistent-index", "id": "unmediated-inconsistent-index",
"tags": ["ignored-to-tracked-transition", "no-silent-loss", "concurrent-mutation"], "tags": ["ignored-to-tracked-transition", "startup-policy-transition", "no-silent-loss", "concurrent-mutation"],
"path_hex": "646570732f6c6962", "path_hex": "646570732f6c6962",
"setup": ["isolated-object", "host-path-absent"], "setup": ["isolated-object", "host-path-absent"],
"actions": ["host-publish-tracked-index", "sandbox-lookup"], "actions": ["host-publish-tracked-index", "sandbox-lookup-in-same-generation", "restart-domain"],
"expect": ["EIO", "path-fail-closed", "isolated-object-recoverable", "host-path-absent"] "expect": ["same-generation-route-isolated", "next-generation-path-absent", "isolated-object-deleted", "host-path-absent"]
}, },
{ {
"id": "mmap-policy-transition", "id": "mmap-policy-transition",
"tags": ["mmap", "open-handle-transition", "no-copy-back"], "tags": ["mmap", "startup-sealed-policy", "no-copy-back"],
"path_hex": "63616368652f6d6170706564", "path_hex": "63616368652f6d6170706564",
"setup": ["isolated-object", "shared-write-mapping"], "setup": ["isolated-object", "shared-write-mapping"],
"actions": ["host-negate-ignore", "mapped-write", "mapping-sync"], "actions": ["host-negate-ignore", "mapped-write", "mapping-sync"],
"expect": ["isolated-object-updated", "host-object-unchanged", "no-copy"] "expect": ["same-generation-route-isolated", "isolated-object-updated", "host-object-unchanged", "no-copy"]
}, },
{ {
"id": "locks-policy-transition", "id": "locks-policy-transition",
"tags": ["locks", "open-handle-transition"], "tags": ["locks", "startup-sealed-policy"],
"path_hex": "63616368652f6c6f636b6564", "path_hex": "63616368652f6c6f636b6564",
"setup": ["isolated-object", "isolated-posix-lock", "host-object"], "setup": ["isolated-object", "isolated-posix-lock", "host-object"],
"actions": ["host-negate-ignore", "sandbox-open-new", "host-lock-attempt"], "actions": ["host-negate-ignore", "sandbox-open-new", "host-lock-attempt"],
"expect": ["old-lock-remains-isolated", "new-lock-domain-host", "no-lock-transfer"] "expect": ["same-generation-lock-domain-isolated", "host-lock-domain-independent", "no-lock-transfer"]
}, },
{ {
"id": "extended-attributes-and-acls", "id": "extended-attributes-and-acls",
@ -204,11 +204,11 @@
}, },
{ {
"id": "directory-stream-policy-snapshot", "id": "directory-stream-policy-snapshot",
"tags": ["concurrent-mutation", "open-handle-transition", "ignore-precedence"], "tags": ["concurrent-mutation", "startup-sealed-policy", "ignore-precedence"],
"path_hex": "6f7574", "path_hex": "6f7574",
"setup": ["open-directory-stream", "host-and-isolated-children"], "setup": ["open-directory-stream", "host-and-isolated-children"],
"actions": ["host-publish-ignore-change", "continue-readdir", "open-new-directory-stream"], "actions": ["host-publish-ignore-change", "continue-readdir", "open-new-directory-stream"],
"expect": ["old-stream-single-snapshot", "new-stream-new-snapshot", "no-duplicate-name"] "expect": ["old-stream-single-snapshot", "new-stream-same-generation-snapshot", "no-duplicate-name"]
}, },
{ {
"id": "git-ordinary-workflow", "id": "git-ordinary-workflow",

View file

@ -179,14 +179,17 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
self.assertEqual(transitions["cross-route-hard-link"]["result"], "EXDEV") self.assertEqual(transitions["cross-route-hard-link"]["result"], "EXDEV")
self.assertEqual(transitions["rename-whiteout"]["result"], "EOPNOTSUPP") self.assertEqual(transitions["rename-whiteout"]["result"], "EOPNOTSUPP")
def test_transitions_never_copy_or_rebind_open_handles(self) -> None: def test_transitions_copy_only_toward_isolated_state_between_generations(self) -> None:
transitions = load("contract.json")["transition_contracts"] transitions = load("contract.json")["transition_contracts"]
copying = [ copying = [
transition["id"] transition["id"]
for transition in transitions for transition in transitions
if "copy" in transition and transition["copy"] != "none" if "copy" in transition and transition["copy"] != "none"
] ]
self.assertEqual(copying, ["qualified-host-to-isolated-rename"]) self.assertEqual(
copying,
["qualified-host-to-isolated-rename", "host-to-ignored-policy"],
)
handle_transitions = { handle_transitions = {
transition["id"]: transition["open_handles"] transition["id"]: transition["open_handles"]
for transition in transitions for transition in transitions
@ -194,11 +197,7 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
} }
self.assertEqual( self.assertEqual(
handle_transitions, handle_transitions,
{ {},
"ignored-to-host-policy": "remain-isolated",
"host-to-ignored-policy": "remain-host",
"isolated-to-tracked-host-established": "remain-isolated",
},
) )
def test_isolated_only_force_add_is_explicitly_refused(self) -> None: def test_isolated_only_force_add_is_explicitly_refused(self) -> None:
@ -251,19 +250,18 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
}, },
{ {
"id": "ignored-to-host-policy", "id": "ignored-to-host-policy",
"trigger": "path becomes non-ignored without becoming tracked", "trigger": "next Domain initialization classifies an isolated path as host-backed",
"new_lookups": "host-or-absent", "new_lookups": "host-or-absent",
"open_handles": "remain-isolated", "isolated_state": "deleted-before-readiness",
"isolated_state": "retained-hidden",
"copy": "none", "copy": "none",
}, },
{ {
"id": "host-to-ignored-policy", "id": "host-to-ignored-policy",
"trigger": "untracked path becomes ignored", "trigger": "next Domain initialization classifies a host-backed path as ignored",
"new_lookups": "retained-isolated-or-absent", "new_lookups": "host-clone-or-absent",
"open_handles": "remain-host", "host_state": "retained-unchanged",
"host_state": "retained-hidden", "isolated_state": "host-clone-replaces-stale-state",
"copy": "none", "copy": "host-to-isolated-only",
}, },
{ {
"id": "isolated-to-tracked-guest-publication", "id": "isolated-to-tracked-guest-publication",
@ -273,22 +271,6 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
"isolated_state": "retained-visible", "isolated_state": "retained-visible",
"copy": "none", "copy": "none",
}, },
{
"id": "isolated-to-tracked-host-established",
"trigger": "published index and type-compatible host object both exist",
"new_lookups": "host",
"open_handles": "remain-isolated",
"isolated_state": "retained-hidden",
"copy": "none",
},
{
"id": "isolated-to-tracked-unmediated-inconsistent",
"trigger": "unmediated index publication tracks a path without a host object",
"result": "EIO",
"path_state": "fail-closed-until-consistent",
"isolated_state": "retained-hidden-recoverable",
"copy": "none",
},
], ],
) )
@ -372,7 +354,9 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
"concurrent-mutation", "concurrent-mutation",
"cross-policy-rename", "cross-policy-rename",
"ignored-to-tracked-transition", "ignored-to-tracked-transition",
"open-handle-transition", "startup-sealed-policy",
"startup-policy-transition",
"host-to-isolated-copy",
"no-silent-loss", "no-silent-loss",
"no-copy-back", "no-copy-back",
} }
@ -384,7 +368,7 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
cases["cross-route-file-rename"]["expect"], cases["cross-route-file-rename"]["expect"],
["EXDEV", "source-unchanged", "destination-absent"], ["EXDEV", "source-unchanged", "destination-absent"],
) )
self.assertIn("isolated-object-recoverable", cases["unmediated-inconsistent-index"]["expect"]) self.assertIn("isolated-object-deleted", cases["unmediated-inconsistent-index"]["expect"])
self.assertIn("access-error-not-host-fallback", cases["router-loss-fails-closed"]["expect"]) self.assertIn("access-error-not-host-fallback", cases["router-loss-fails-closed"]["expect"])
self.assertIn("host-sentinel-unchanged", cases["router-loss-fails-closed"]["expect"]) self.assertIn("host-sentinel-unchanged", cases["router-loss-fails-closed"]["expect"])

View file

@ -215,13 +215,13 @@ def classification_case(case_id, fixture):
require((fixture.isolated / "vendor/nested/target/item").is_file(), "nested policy not selected") require((fixture.isolated / "vendor/nested/target/item").is_file(), "nested policy not selected")
require((fixture.host / "vendor/nested/ordinary.cache").is_file(), "parent policy crossed repository boundary") require((fixture.host / "vendor/nested/ordinary.cache").is_file(), "parent policy crossed repository boundary")
return return
if case_id == "live-ignore-publication": if case_id == "startup-sealed-ignore-mutation":
write(fixture.host / ".gitignore", b"different/\n") write(fixture.host / ".gitignore", b"different/\n")
write(fixture.host / "cache/item", b"host") write(fixture.host / "cache/item", b"host")
with fixture.start(): with fixture.start():
require((fixture.mount / "cache/item").read_bytes() == b"host", "initial host route missing") require((fixture.mount / "cache/item").read_bytes() == b"host", "initial host route missing")
write(fixture.host / ".gitignore", b"cache/\n") write(fixture.host / ".gitignore", b"cache/\n")
require(not (fixture.mount / "cache/item").exists(), "published ignore did not hide host object") require((fixture.mount / "cache/item").read_bytes() == b"host", "live ignore mutation changed the sealed route")
require((fixture.host / "cache/item").read_bytes() == b"host", "publication copied or removed host object") require((fixture.host / "cache/item").read_bytes() == b"host", "publication copied or removed host object")
return return
raise AssertionError(f"classification case is not implemented: {case_id}") raise AssertionError(f"classification case is not implemented: {case_id}")
@ -270,7 +270,7 @@ def migration_case(case_id, fixture):
require(not (fixture.host / "target").exists(), "isolated target was exported to host") require(not (fixture.host / "target").exists(), "isolated target was exported to host")
require((fixture.isolated / "target/CACHEDIR.TAG").read_bytes() == payload, "isolated target missing") require((fixture.isolated / "target/CACHEDIR.TAG").read_bytes() == payload, "isolated target missing")
intents = fixture.isolated / ".lazaret-ignored-workspace-transactions" intents = fixture.isolated / ".lazaret-ignored-workspace-transactions"
require(not any(intents.iterdir()), "completed migration retained intent") require(not list(intents.glob("*.json")), "completed migration retained intent")
def rename_exchange(source, destination): def rename_exchange(source, destination):
@ -319,24 +319,23 @@ def policy_transition_case(case_id, fixture):
with fixture.start(): with fixture.start():
if case_id == "ignored-becomes-non-ignored": if case_id == "ignored-becomes-non-ignored":
require((fixture.mount / "cache/item").read_bytes() == b"isolated", "initial isolated route missing") require((fixture.mount / "cache/item").read_bytes() == b"isolated", "initial isolated route missing")
old_descriptor = os.open(fixture.mount / "cache/item", os.O_RDWR)
write(fixture.host / ".gitignore", b"different/\n")
require((fixture.mount / "cache/item").read_bytes() == b"host", "host route not selected")
os.pwrite(old_descriptor, b"old-isolated", 0)
os.close(old_descriptor)
require((fixture.isolated / "cache/item").read_bytes().startswith(b"old-isolated"), "old handle rebound from isolated backing")
else: else:
require((fixture.mount / "cache/item").read_bytes() == b"host", "initial host route missing") require((fixture.mount / "cache/item").read_bytes() == b"host", "initial host route missing")
old_descriptor = os.open(fixture.mount / "cache/item", os.O_RDWR) write(
write(fixture.host / ".gitignore", b"cache/\n") fixture.host / ".gitignore",
require((fixture.mount / "cache/item").read_bytes() == b"isolated", "isolated route not selected") b"different/\n" if case_id == "ignored-becomes-non-ignored" else b"cache/\n",
os.pwrite(old_descriptor, b"old-host", 0) )
os.close(old_descriptor) with fixture.start():
require((fixture.host / "cache/item").read_bytes().startswith(b"old-host"), "old handle rebound from host backing") if case_id == "ignored-becomes-non-ignored":
require((fixture.mount / "cache/item").read_bytes() == b"host", "host route not selected after restart")
require(not (fixture.isolated / "cache/item").exists(), "released isolated object was retained")
else:
require((fixture.mount / "cache/item").read_bytes() == b"host", "host clone not selected after restart")
require((fixture.isolated / "cache/item").read_bytes() == b"host", "host object was not cloned")
if case_id == "ignored-becomes-non-ignored": if case_id == "ignored-becomes-non-ignored":
require((fixture.host / "cache/item").read_bytes() == b"host", "transition copied to host") require((fixture.host / "cache/item").read_bytes() == b"host", "transition changed the host")
else: else:
require((fixture.isolated / "cache/item").read_bytes() == b"isolated", "transition copied to isolated backing") require((fixture.host / "cache/item").read_bytes() == b"host", "clone changed the host")
def index_case(case_id, fixture): def index_case(case_id, fixture):
@ -353,21 +352,21 @@ def index_case(case_id, fixture):
require(not (fixture.host / "deps/lib").exists(), "refused force-add created a host object") require(not (fixture.host / "deps/lib").exists(), "refused force-add created a host object")
elif case_id == "host-established-tracked-cutover": elif case_id == "host-established-tracked-cutover":
(fixture.host / "deps/lib").unlink() (fixture.host / "deps/lib").unlink()
old_descriptor = os.open(fixture.mount / "deps/lib", os.O_RDWR)
write(fixture.host / "deps/lib", b"independent-host") write(fixture.host / "deps/lib", b"independent-host")
command(["git", "add", "-f", "--", "deps/lib"], fixture.host) command(["git", "add", "-f", "--", "deps/lib"], fixture.host)
new_descriptor = os.open(fixture.mount / "deps/lib", os.O_RDONLY) require((fixture.mount / "deps/lib").read_bytes() == b"isolated", "host index mutation changed the sealed route")
require(os.read(new_descriptor, 64) == b"independent-host", "new handle did not select host object")
os.pwrite(old_descriptor, b"old-handle", 0)
os.close(old_descriptor)
os.close(new_descriptor)
require((fixture.host / "deps/lib").read_bytes() == b"independent-host", "old handle rebound to host")
require((fixture.isolated / "deps/lib").read_bytes().startswith(b"old-handle"), "old handle did not remain isolated")
else: else:
(fixture.host / "deps/lib").unlink() (fixture.host / "deps/lib").unlink()
command(["git", "update-index", "--add", "--cacheinfo", "100644", "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", "deps/lib"], fixture.host) command(["git", "update-index", "--add", "--cacheinfo", "100644", "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", "deps/lib"], fixture.host)
expect_errno(errno.EIO, lambda: (fixture.mount / "deps/lib").read_bytes()) require((fixture.mount / "deps/lib").read_bytes() == b"isolated", "host index mutation changed the sealed route")
require((fixture.isolated / "deps/lib").read_bytes() == b"isolated", "inconsistent isolated object was lost") if case_id == "host-established-tracked-cutover":
with fixture.start():
require((fixture.mount / "deps/lib").read_bytes() == b"independent-host", "restart did not select host object")
require(not (fixture.isolated / "deps/lib").exists(), "restart retained released isolated object")
elif case_id == "unmediated-inconsistent-index":
with fixture.start():
require(not (fixture.mount / "deps/lib").exists(), "tracked path without a host object did not become absent")
require(not (fixture.isolated / "deps/lib").exists(), "released isolated object was retained")
def open_handle_case(case_id, fixture): def open_handle_case(case_id, fixture):
@ -392,20 +391,17 @@ def open_handle_case(case_id, fixture):
iterator.close() iterator.close()
require("isolated-child" in names and "host-child" not in names, "old stream mixed snapshots") require("isolated-child" in names and "host-child" not in names, "old stream mixed snapshots")
new_names = {entry.name for entry in os.scandir(fixture.mount / "cache")} new_names = {entry.name for entry in os.scandir(fixture.mount / "cache")}
require("host-child" in new_names and "isolated-child" not in new_names, "new stream did not use new snapshot") require("isolated-child" in new_names and "host-child" not in new_names, "new stream changed the sealed route")
else: else:
descriptor = os.open(fixture.mount / "cache/pinned", os.O_RDWR) descriptor = os.open(fixture.mount / "cache/pinned", os.O_RDWR)
fcntl.lockf(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB, 1) fcntl.lockf(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB, 1)
write(fixture.host / ".gitignore", b"different/\n") write(fixture.host / ".gitignore", b"different/\n")
new_descriptor = os.open(fixture.mount / "cache/pinned", os.O_RDWR) new_descriptor = os.open(fixture.mount / "cache/pinned", os.O_RDWR)
fcntl.lockf(new_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB, 1)
require(lock_attempt(fixture.isolated / "cache/pinned") == errno.EAGAIN, "old isolated lock was transferred or lost") require(lock_attempt(fixture.isolated / "cache/pinned") == errno.EAGAIN, "old isolated lock was transferred or lost")
require(lock_attempt(fixture.host / "cache/pinned") == errno.EAGAIN, "new host lock was not established") require(lock_attempt(fixture.host / "cache/pinned") == 0, "host lock domain was not independent")
os.close(descriptor)
require(lock_attempt(fixture.isolated / "cache/pinned") == 0, "isolated lock remained after old handle closed")
require(lock_attempt(fixture.host / "cache/pinned") == errno.EAGAIN, "isolated unlock affected host lock")
os.close(new_descriptor) os.close(new_descriptor)
require(lock_attempt(fixture.host / "cache/pinned") == 0, "host lock remained after new handle closed") os.close(descriptor)
require(lock_attempt(fixture.isolated / "cache/pinned") == 0, "isolated lock remained after handles closed")
def lock_attempt(path): def lock_attempt(path):
@ -682,7 +678,7 @@ CASE_GROUPS = {
**{name: classification_case for name in [ **{name: classification_case for name in [
"non-utf8-ignore-match", "root-negation-precedence", "nested-ignore-precedence", "non-utf8-ignore-match", "root-negation-precedence", "nested-ignore-precedence",
"info-exclude", "global-excludes", "ignore-case-folding", "nested-repository-boundary", "info-exclude", "global-excludes", "ignore-case-folding", "nested-repository-boundary",
"live-ignore-publication", "startup-sealed-ignore-mutation",
]}, ]},
**{name: atomic_case for name in ["cross-route-file-rename", "cross-route-directory-exchange", "cross-route-hard-link"]}, **{name: atomic_case for name in ["cross-route-file-rename", "cross-route-directory-exchange", "cross-route-hard-link"]},
"cargo-target-host-to-isolated-rename": migration_case, "cargo-target-host-to-isolated-rename": migration_case,