Seal ignored-workspace routing at Domain startup
This commit is contained in:
parent
b114e3514b
commit
1ad3b84db2
10 changed files with 720 additions and 285 deletions
|
|
@ -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(¤t),
|
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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, ¤t_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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
50
tests/fixtures/ignored-workspace-v1/corpus.json
vendored
50
tests/fixtures/ignored-workspace-v1/corpus.json
vendored
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue