Support durable host-to-isolated workspace migration

This commit is contained in:
ms2k 2026-07-26 00:43:30 +09:00
commit 0dda65fc2e
14 changed files with 1176 additions and 32 deletions

1
Cargo.lock generated
View file

@ -1752,6 +1752,7 @@ dependencies = [
"libc",
"nix 0.31.3",
"rustix",
"serde",
"serde_json",
"sha2 0.11.0",
]

View file

@ -13,6 +13,7 @@ gix-ignore.workspace = true
libc.workspace = true
nix.workspace = true
rustix.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true

View file

@ -2,6 +2,7 @@ use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io;
use std::io::Read;
use std::io::Write;
use std::os::fd::AsFd;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Component, Path};
@ -121,6 +122,24 @@ impl BackingRoot {
Ok(mkdirat(parent.as_fd(), name, mode)?)
}
pub(crate) fn create_file(&self, relative: &Path, mode: Mode) -> io::Result<File> {
self.open_entry(
relative,
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL,
mode,
)
}
pub(crate) fn write_file(&self, relative: &Path, bytes: &[u8], mode: Mode) -> io::Result<()> {
let mut file = self.create_file(relative, mode)?;
file.write_all(bytes)?;
file.sync_all()
}
pub(crate) fn sync_directory(&self, relative: &Path) -> io::Result<()> {
self.open_directory(relative)?.sync_all()
}
pub(crate) fn unlink(&self, relative: &Path, directory: bool) -> io::Result<()> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;

View file

@ -8,8 +8,8 @@ use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::FileExt;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use fuser::{
@ -32,6 +32,7 @@ use rustix::time::Timespec;
use crate::backing::{BackingRoot, validate_name, validate_relative};
use crate::classifier::tracked_paths_from_index;
use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier};
use crate::migration::{Provenance, reserved};
use nodes::{
DirectoryEntry, DirectoryStream, NodeTable, OpenFile, PosixLockKey, ResolvedEntry, errno,
file_type, open_flags, system_time,
@ -67,6 +68,8 @@ pub struct IgnoredWorkspace {
files: Mutex<HashMap<u64, Arc<OpenFile>>>,
directories: Mutex<HashMap<u64, DirectoryStream>>,
posix_lock_files: Mutex<HashMap<PosixLockKey, Arc<File>>>,
namespace: RwLock<()>,
provenance: Mutex<Provenance>,
next_handle: AtomicU64,
}
@ -155,6 +158,7 @@ impl IgnoredWorkspace {
if host.identity()? == isolated.identity()? {
return Err(io::Error::other("workspace and isolated authorities alias"));
}
crate::migration::initialize(&host, &isolated)?;
let classifier_root = BackingRoot::from_file(host.duplicate()?)?;
let classifier =
GitClassifier::from_root(workspace_label, classifier_root, global_excludes)?;
@ -167,6 +171,8 @@ impl IgnoredWorkspace {
files: Mutex::new(HashMap::new()),
directories: Mutex::new(HashMap::new()),
posix_lock_files: Mutex::new(HashMap::new()),
namespace: RwLock::new(()),
provenance: Mutex::new(Provenance::default()),
next_handle: AtomicU64::new(1),
})
}
@ -229,6 +235,9 @@ impl IgnoredWorkspace {
validate_name(name)?;
let path = self.path_for(parent)?.join(name);
validate_relative(&path)?;
if reserved(&path) {
return Err(io::Error::from_raw_os_error(libc::ENOENT));
}
Ok(path)
}
@ -243,6 +252,9 @@ impl IgnoredWorkspace {
}
fn resolve(&self, snapshot: &ClassificationSnapshot, path: &Path) -> io::Result<ResolvedEntry> {
if reserved(path) {
return Err(io::Error::from_raw_os_error(libc::ENOENT));
}
let host = self.host.stat(path).ok();
let isolated = self.isolated.stat(path).ok();
let directory = isolated
@ -305,6 +317,9 @@ impl IgnoredWorkspace {
let file = self
.root(entry.backing)
.open_entry(&path, open_flags(flags), Mode::empty())?;
if flags & libc::O_TRUNC != 0 {
self.record_changed(entry.backing, &path);
}
let handle = self.next_handle.fetch_add(1, Ordering::Relaxed);
self.files
.lock()
@ -314,7 +329,9 @@ impl IgnoredWorkspace {
Arc::new(OpenFile {
file,
backing: entry.backing,
path,
access_mode: flags & libc::O_ACCMODE,
migration_quiesced: AtomicBool::new(flags & libc::O_ACCMODE == libc::O_RDONLY),
}),
);
Ok(handle)
@ -347,6 +364,60 @@ impl IgnoredWorkspace {
Ok(entries)
}
fn record_created(&self, backing: Classification, path: &Path) -> io::Result<()> {
if backing != Classification::Host {
return Ok(());
}
let stat = self.host.stat(path)?;
let mut provenance = self
.provenance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
provenance.record(path.to_path_buf(), &stat);
let mut parent = path.parent();
while let Some(path) = parent {
if let Ok(stat) = self.host.stat(path) {
provenance.refresh(path, &stat);
}
parent = path.parent();
}
Ok(())
}
fn record_changed(&self, backing: Classification, path: &Path) {
if backing != Classification::Host {
return;
}
let mut provenance = self
.provenance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Ok(stat) = self.host.stat(path) {
provenance.refresh(path, &stat);
}
}
fn has_unquiesced_descendant(&self, source: &Path) -> bool {
self.files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.values()
.any(|open| {
(open.path == source || open.path.starts_with(source))
&& !open.migration_quiesced.load(Ordering::Acquire)
})
}
fn wait_for_unquiesced_descendants(&self, source: &Path) -> bool {
for _ in 0..100 {
if !self.has_unquiesced_descendant(source) {
return false;
}
std::thread::sleep(Duration::from_millis(1));
}
self.has_unquiesced_descendant(source)
}
fn reply_entry(&self, path: &Path, reply: ReplyEntry) {
let snapshot = self.classifier.snapshot_for(path);
match self

View file

@ -11,7 +11,14 @@ impl IgnoredWorkspace {
let path = self.child_path(parent, name)?;
let snapshot = self.classifier.snapshot_for(&path);
let entry = self.resolve(&snapshot, &path)?;
self.root(entry.backing).unlink(&path, directory)
self.root(entry.backing).unlink(&path, directory)?;
if entry.backing == Classification::Host {
self.provenance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove_tree(&path);
}
Ok(())
})();
reply_result(result, reply);
}
@ -66,7 +73,9 @@ pub(super) struct ResolvedEntry {
pub(super) struct OpenFile {
pub(super) file: File,
pub(super) backing: Classification,
pub(super) path: PathBuf,
pub(super) access_mode: i32,
pub(super) migration_quiesced: std::sync::atomic::AtomicBool,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]

View file

@ -1,17 +1,19 @@
use std::io::Write as _;
use super::nodes::{
DirectoryStream, OpenFile, ResolvedEntry, bsd_lock_operation, errno, fuse_lock, record_lock,
reply_result, reply_xattr, timestamp, unsupported_lock_flags, writable_xattr,
};
use super::{
Arc, AsFd, BsdFileFlags, CopyFileRangeFlags, DirectoryEntry, Errno, FallocateFlags, FcntlArg,
FileAttr, FileExt, FileHandle, FileType, Filesystem, FopenFlags, FuseRenameFlags, Generation,
Gid, INodeNo, IgnoredWorkspace, InitFlags, KernelConfig, LockFlags, LockOwner, Mode, OFlags,
OpenFlags, Ordering, OsStr, OsString, Path, RenameFlags, ReplyAttr, ReplyCreate, ReplyData,
ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyLock, ReplyLseek, ReplyOpen, ReplyStatfs,
ReplyWrite, ReplyXattr, Request, SeekFrom, SystemTime, TTL, TimeOrNow, Timestamps, Uid,
WriteFlags, XattrFlags, bsd_flock, copy_file_range, fallocate, fchmod, fchown, fcntl,
fgetxattr, flistxattr, fremovexattr, fsetxattr, fstat, fstatvfs, ftruncate, futimens, io,
mknodat, open_flags, seek,
Arc, AsFd, BsdFileFlags, Classification, CopyFileRangeFlags, DirectoryEntry, Errno,
FallocateFlags, FcntlArg, FileAttr, FileExt, FileHandle, FileType, Filesystem, FopenFlags,
FuseRenameFlags, Generation, Gid, INodeNo, IgnoredWorkspace, InitFlags, KernelConfig,
LockFlags, LockOwner, Mode, OFlags, OpenFlags, Ordering, OsStr, OsString, Path, RenameFlags,
ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyLock,
ReplyLseek, ReplyOpen, ReplyStatfs, ReplyWrite, ReplyXattr, Request, SeekFrom, SystemTime, TTL,
TimeOrNow, Timestamps, Uid, WriteFlags, XattrFlags, bsd_flock, copy_file_range, fallocate,
fchmod, fchown, fcntl, fgetxattr, flistxattr, fremovexattr, fsetxattr, fstat, fstatvfs,
ftruncate, futimens, io, mknodat, open_flags, seek,
};
impl Filesystem for IgnoredWorkspace {
@ -31,6 +33,10 @@ impl Filesystem for IgnoredWorkspace {
}
fn lookup(&self, _request: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match self.child_path(parent, name) {
Ok(path) => self.reply_entry(&path, reply),
Err(error) => reply.error(errno(&error)),
@ -44,6 +50,10 @@ impl Filesystem for IgnoredWorkspace {
handle: Option<FileHandle>,
reply: ReplyAttr,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = if let Some(handle) = handle {
self.with_file(handle, |open| {
let stat = fstat(open.file.as_fd())?;
@ -85,7 +95,12 @@ impl Filesystem for IgnoredWorkspace {
_flags: Option<BsdFileFlags>,
reply: ReplyAttr,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let operation = |open: &OpenFile| -> io::Result<FileAttr> {
open.migration_quiesced.store(false, Ordering::Release);
if let Some(mode) = mode {
fchmod(open.file.as_fd(), Mode::from_raw_mode(mode & 0o7777))?;
}
@ -108,6 +123,7 @@ impl Filesystem for IgnoredWorkspace {
},
)?;
}
self.record_changed(open.backing, &open.path);
let path = self.path_for(inode)?;
self.attributes(&ResolvedEntry {
path,
@ -178,6 +194,10 @@ impl Filesystem for IgnoredWorkspace {
}
fn readlink(&self, _request: &Request, inode: INodeNo, reply: ReplyData) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = self.path_for(inode).and_then(|path| {
let snapshot = self.classifier.snapshot_for(&path);
let entry = self.resolve(&snapshot, &path)?;
@ -199,6 +219,10 @@ impl Filesystem for IgnoredWorkspace {
rdev: u32,
reply: ReplyEntry,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if matches!(mode & libc::S_IFMT, libc::S_IFCHR | libc::S_IFBLK) {
reply.error(Errno::EPERM);
return;
@ -217,6 +241,7 @@ impl Filesystem for IgnoredWorkspace {
Mode::from_raw_mode(mode & !umask),
rustix::fs::makedev(rdev >> 8, rdev & 0xff),
)?;
self.record_created(backing, &path)?;
Ok(path)
})();
match result {
@ -234,12 +259,17 @@ impl Filesystem for IgnoredWorkspace {
umask: u32,
reply: ReplyEntry,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = (|| {
let path = self.child_path(parent, name)?;
let snapshot = self.classifier.snapshot_for(&path);
let backing = self.create_backing(&snapshot, &path, true)?;
self.root(backing)
.mkdir(&path, Mode::from_raw_mode((mode & !umask) & 0o7777))?;
self.record_created(backing, &path)?;
Ok(path)
})();
match result {
@ -249,10 +279,18 @@ impl Filesystem for IgnoredWorkspace {
}
fn unlink(&self, _request: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.remove(parent, name, false, reply);
}
fn rmdir(&self, _request: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.remove(parent, name, true, reply);
}
@ -264,11 +302,16 @@ impl Filesystem for IgnoredWorkspace {
target: &Path,
reply: ReplyEntry,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = (|| {
let path = self.child_path(parent, name)?;
let snapshot = self.classifier.snapshot_for(&path);
let backing = self.create_backing(&snapshot, &path, false)?;
self.root(backing).symlink(target, &path)?;
self.record_created(backing, &path)?;
Ok(path)
})();
match result {
@ -287,6 +330,10 @@ impl Filesystem for IgnoredWorkspace {
flags: FuseRenameFlags,
reply: ReplyEmpty,
) {
let _namespace = self
.namespace
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if flags.contains(FuseRenameFlags::RENAME_WHITEOUT) {
reply.error(Errno::EOPNOTSUPP);
return;
@ -302,14 +349,64 @@ impl Filesystem for IgnoredWorkspace {
source_entry.stat.st_mode & libc::S_IFMT == libc::S_IFDIR,
);
if source_entry.backing != destination_backing {
let supported_flags =
flags.is_empty() || flags == FuseRenameFlags::RENAME_NOREPLACE;
let directory = source_entry.stat.st_mode & libc::S_IFMT == libc::S_IFDIR;
if source_entry.backing != Classification::Host
|| destination_backing != Classification::Isolated
{
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
if !supported_flags {
let _ = writeln!(
io::stderr().lock(),
"lazaret ignored-workspace refused host-to-isolated migration: unsupported rename flags"
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
if !directory {
let _ = writeln!(
io::stderr().lock(),
"lazaret ignored-workspace refused host-to-isolated migration: source is not a directory"
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
if self.wait_for_unquiesced_descendants(&source) {
let _ = writeln!(
io::stderr().lock(),
"lazaret ignored-workspace refused host-to-isolated migration: source has an open descendant"
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
let mut provenance = self
.provenance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
crate::migration::migrate(
&self.host,
&self.isolated,
&provenance,
&source,
&destination,
)?;
provenance.remove_tree(&source);
return Ok(());
}
if destination == Path::new(".git/index") {
self.require_index_publication_safe(source_entry.backing, &source, &snapshot)?;
}
let flags = RenameFlags::from_bits_retain(flags.bits());
self.root(source_entry.backing)
.rename(&source, &destination, flags)
.rename(&source, &destination, flags)?;
if source_entry.backing == Classification::Host {
let mut provenance = self
.provenance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
provenance.remove_tree(&destination);
provenance.rename_tree(&source, &destination);
}
Ok(())
})();
reply_result(result, reply);
}
@ -322,6 +419,10 @@ impl Filesystem for IgnoredWorkspace {
new_name: &OsStr,
reply: ReplyEntry,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = (|| {
let source = self.path_for(inode)?;
let destination = self.child_path(new_parent, new_name)?;
@ -342,6 +443,10 @@ impl Filesystem for IgnoredWorkspace {
}
fn open(&self, _request: &Request, inode: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match self.open_file(inode, flags.0) {
Ok(handle) => reply.opened(FileHandle(handle), FopenFlags::empty()),
Err(error) => reply.error(errno(&error)),
@ -359,6 +464,10 @@ impl Filesystem for IgnoredWorkspace {
_lock_owner: Option<LockOwner>,
reply: ReplyData,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = self.with_file(handle, |open| {
let mut bytes = vec![0; size as usize];
let read = open.file.read_at(&mut bytes, offset)?;
@ -384,12 +493,15 @@ impl Filesystem for IgnoredWorkspace {
reply: ReplyWrite,
) {
let result = self.with_file(handle, |open| {
open.migration_quiesced.store(false, Ordering::Release);
let offset = if flags.0 & libc::O_APPEND != 0 {
open.file.metadata()?.len()
} else {
offset
};
open.file.write_at(data, offset)
let written = open.file.write_at(data, offset)?;
self.record_changed(open.backing, &open.path);
Ok(written)
});
match result {
Ok(written) => reply.written(u32::try_from(written).unwrap_or(u32::MAX)),
@ -407,7 +519,9 @@ impl Filesystem for IgnoredWorkspace {
) {
let result = self.with_file(handle, |open| {
self.release_posix_locks(open, owner)?;
open.file.sync_data()
open.file.sync_data()?;
open.migration_quiesced.store(true, Ordering::Release);
Ok(())
});
reply_result(result, reply);
}
@ -528,6 +642,10 @@ impl Filesystem for IgnoredWorkspace {
}
fn opendir(&self, _request: &Request, inode: INodeNo, _flags: OpenFlags, reply: ReplyOpen) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = (|| {
let path = self.path_for(inode)?;
let snapshot = self.classifier.snapshot_for(&path);
@ -671,6 +789,10 @@ impl Filesystem for IgnoredWorkspace {
position: u32,
reply: ReplyEmpty,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if position != 0 || !writable_xattr(name) {
reply.error(Errno::ENOTSUP);
return;
@ -683,6 +805,14 @@ impl Filesystem for IgnoredWorkspace {
XattrFlags::from_bits_retain(u32::from_ne_bytes(flags.to_ne_bytes())),
)?)
});
if result.is_ok()
&& let Ok(path) = self.path_for(inode)
{
let snapshot = self.classifier.snapshot_for(&path);
if let Ok(entry) = self.resolve(&snapshot, &path) {
self.record_changed(entry.backing, &path);
}
}
reply_result(result, reply);
}
@ -714,11 +844,23 @@ impl Filesystem for IgnoredWorkspace {
}
fn removexattr(&self, _request: &Request, inode: INodeNo, name: &OsStr, reply: ReplyEmpty) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !writable_xattr(name) {
reply.error(Errno::ENOTSUP);
return;
}
let result = self.with_temporary_file(inode, |file| Ok(fremovexattr(file.as_fd(), name)?));
if result.is_ok()
&& let Ok(path) = self.path_for(inode)
{
let snapshot = self.classifier.snapshot_for(&path);
if let Ok(entry) = self.resolve(&snapshot, &path) {
self.record_changed(entry.backing, &path);
}
}
reply_result(result, reply);
}
@ -732,18 +874,28 @@ impl Filesystem for IgnoredWorkspace {
flags: i32,
reply: ReplyCreate,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = (|| {
let path = self.child_path(parent, name)?;
let snapshot = self.classifier.snapshot_for(&path);
let backing = self.create_backing(&snapshot, &path, false)?;
let existed = self.root(backing).stat(&path).is_ok();
let file = self.root(backing).open_entry(
&path,
open_flags(flags) | OFlags::CREATE,
Mode::from_raw_mode((mode & !umask) & 0o7777),
)?;
let stat = fstat(file.as_fd())?;
if existed {
self.record_changed(backing, &path);
} else {
self.record_created(backing, &path)?;
}
let attributes = self.attributes(&ResolvedEntry {
path,
path: path.clone(),
backing,
stat,
})?;
@ -756,7 +908,11 @@ impl Filesystem for IgnoredWorkspace {
Arc::new(OpenFile {
file,
backing,
path,
access_mode: flags & libc::O_ACCMODE,
migration_quiesced: std::sync::atomic::AtomicBool::new(
flags & libc::O_ACCMODE == libc::O_RDONLY,
),
}),
);
Ok((attributes, handle))
@ -783,13 +939,20 @@ impl Filesystem for IgnoredWorkspace {
mode: i32,
reply: ReplyEmpty,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = self.with_file(handle, |open| {
Ok(fallocate(
open.migration_quiesced.store(false, Ordering::Release);
fallocate(
open.file.as_fd(),
FallocateFlags::from_bits_retain(u32::from_ne_bytes(mode.to_ne_bytes())),
offset,
length,
)?)
)?;
self.record_changed(open.backing, &open.path);
Ok(())
});
reply_result(result, reply);
}
@ -853,6 +1016,7 @@ impl Filesystem for IgnoredWorkspace {
let output = files
.get(&handle_out.0)
.ok_or_else(|| io::Error::from_raw_os_error(libc::EBADF))?;
output.migration_quiesced.store(false, Ordering::Release);
if input.backing != output.backing {
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}

View file

@ -32,7 +32,9 @@ fn posix_lock_owners_use_distinct_backing_lock_domains() {
.open(path)
.unwrap(),
backing: Classification::Host,
path: std::path::PathBuf::from("ordinary"),
access_mode: libc::O_RDWR,
migration_quiesced: std::sync::atomic::AtomicBool::new(false),
};
let first_owner = LockOwner(101);
let second_owner = LockOwner(202);
@ -218,6 +220,50 @@ fn physical_router_preserves_routes_transitions_and_cross_route_atomicity() {
session.umount_and_join().unwrap();
}
#[test]
#[ignore = "requires a usable host FUSE device"]
fn physical_router_migrates_cargo_target_only_toward_isolated_state() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"target/\n").unwrap();
let filesystem = IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap();
let mut config = Config::default();
config.mount_options.extend([
MountOption::RW,
MountOption::DefaultPermissions,
MountOption::NoDev,
MountOption::NoSuid,
]);
let session = fuser::spawn_mount2(filesystem, &fixture.mountpoint, &config).unwrap();
let temporary = fixture.mountpoint.join("targetAbCd12");
fs::create_dir(&temporary).unwrap();
fs::write(
temporary.join("CACHEDIR.TAG"),
b"Signature: 8a477f597d28d172789f06886806bc55\n",
)
.unwrap();
fs::rename(&temporary, fixture.mountpoint.join("target")).unwrap();
assert!(!fixture.workspace.join("targetAbCd12").exists());
assert!(!fixture.workspace.join("target").exists());
assert_eq!(
fs::read(fixture.isolated.join("target/CACHEDIR.TAG")).unwrap(),
b"Signature: 8a477f597d28d172789f06886806bc55\n"
);
let error = fs::rename(
fixture.mountpoint.join("target"),
fixture.mountpoint.join("exported-target"),
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
assert!(!fixture.workspace.join("exported-target").exists());
session.umount_and_join().unwrap();
}
fn repository(root: &std::path::Path) {
fs::create_dir_all(root.join(".git/info")).unwrap();
fs::create_dir_all(root.join(".git/hooks")).unwrap();

View file

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

View file

@ -0,0 +1,744 @@
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{self, Read, Write};
use std::os::fd::AsFd;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use rustix::fs::{Mode, OFlags, RenameFlags, Timestamps, fchmod, fchown, flistxattr, futimens};
use rustix::process::{Gid, Uid};
use rustix::time::Timespec;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::backing::BackingRoot;
const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions";
const INTENT_SUFFIX: &str = ".json";
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ObjectStamp {
device: u64,
inode: u64,
mode: u32,
size: i64,
modification_seconds: i64,
modification_nanoseconds: u64,
change_seconds: i64,
change_nanoseconds: u64,
}
impl ObjectStamp {
pub(crate) fn from_stat(stat: &rustix::fs::Stat) -> Self {
Self {
device: stat.st_dev,
inode: stat.st_ino,
mode: stat.st_mode,
size: stat.st_size,
modification_seconds: stat.st_mtime,
modification_nanoseconds: stat.st_mtime_nsec,
change_seconds: stat.st_ctime,
change_nanoseconds: stat.st_ctime_nsec,
}
}
}
#[derive(Default)]
pub(crate) struct Provenance {
objects: HashMap<PathBuf, ObjectStamp>,
}
impl Provenance {
pub(crate) fn record(&mut self, path: PathBuf, stat: &rustix::fs::Stat) {
self.objects.insert(path, ObjectStamp::from_stat(stat));
}
pub(crate) fn remove_tree(&mut self, root: &Path) {
self.objects
.retain(|path, _| path != root && !path.starts_with(root));
}
pub(crate) fn refresh(&mut self, path: &Path, stat: &rustix::fs::Stat) {
if let Some(stamp) = self.objects.get_mut(path) {
*stamp = ObjectStamp::from_stat(stat);
}
}
pub(crate) fn rename_tree(&mut self, source: &Path, destination: &Path) {
let moved = self
.objects
.iter()
.filter_map(|(path, stamp)| {
path.strip_prefix(source).ok().map(|suffix| {
let path = if suffix.as_os_str().is_empty() {
destination.to_path_buf()
} else {
destination.join(suffix)
};
(path, stamp.clone())
})
})
.collect::<Vec<_>>();
self.remove_tree(source);
self.objects.extend(moved);
}
fn matches(&self, path: &Path, stamp: &ObjectStamp) -> bool {
self.objects.get(path) == Some(stamp)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct Intent {
schema: u32,
id: String,
source: Vec<u8>,
destination: Vec<u8>,
quarantine: Vec<u8>,
staging: Vec<u8>,
manifest: Vec<ObjectRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct ObjectRecord {
path: Vec<u8>,
kind: ObjectKind,
device: u64,
inode: u64,
mode: u32,
uid: u32,
gid: u32,
size: i64,
modification_seconds: i64,
modification_nanoseconds: u64,
change_seconds: i64,
change_nanoseconds: u64,
digest: Option<[u8; 32]>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
enum ObjectKind {
Directory,
Regular,
}
pub(crate) fn reserved(path: &Path) -> bool {
path.components()
.next()
.is_some_and(|component| component.as_os_str() == OsStr::new(TRANSACTIONS))
}
pub(crate) fn initialize(host: &BackingRoot, isolated: &BackingRoot) -> io::Result<()> {
match isolated.mkdir(Path::new(TRANSACTIONS), Mode::from_raw_mode(0o700)) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
isolated.sync_directory(Path::new(""))?;
recover(host, isolated)
}
pub(crate) fn migrate(
host: &BackingRoot,
isolated: &BackingRoot,
provenance: &Provenance,
source: &Path,
destination: &Path,
) -> io::Result<()> {
if isolated.stat(destination).is_ok() {
return Err(io::Error::from_raw_os_error(libc::EEXIST));
}
let manifest = match scan_tree(host, source) {
Ok(manifest) => manifest,
Err(error) => {
let _ = writeln!(
io::stderr().lock(),
"lazaret ignored-workspace refused host-to-isolated migration: unsupported source tree: {error}"
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
};
if manifest.is_empty() {
let _ = writeln!(
io::stderr().lock(),
"lazaret ignored-workspace refused host-to-isolated migration: empty source manifest"
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
if let Some(record) = manifest.iter().find(|record| {
let path = source.join(path_from_bytes(&record.path));
!provenance.matches(&path, &record.stamp())
}) {
let _ = writeln!(
io::stderr().lock(),
"lazaret ignored-workspace refused host-to-isolated migration: source provenance changed at {}",
path_from_bytes(&record.path).display()
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
let id = format!(
"{}-{}",
std::process::id(),
TRANSACTION_SEQUENCE.fetch_add(1, Ordering::Relaxed)
);
let source_parent = source.parent().unwrap_or_else(|| Path::new(""));
let destination_parent = destination.parent().unwrap_or_else(|| Path::new(""));
let quarantine = source_parent.join(format!(".lazaret-quarantine-{id}"));
let staging = destination_parent.join(format!(".lazaret-staging-{id}"));
let intent = Intent {
schema: 1,
id,
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(&quarantine),
staging: path_bytes(&staging),
manifest,
};
if let Err(error) = persist_intent(isolated, &intent) {
let _ = isolated.unlink(
&Path::new(TRANSACTIONS).join(format!("{}.tmp", intent.id)),
false,
);
return Err(error);
}
let result = (|| {
host.rename(source, &quarantine, RenameFlags::NOREPLACE)?;
host.sync_directory(source_parent)?;
isolated.ensure_parent_directories(&staging)?;
copy_tree(host, &quarantine, isolated, &staging)?;
isolated.sync_directory(destination_parent)?;
require_exact_tree(host, &quarantine, &intent.manifest)?;
require_equivalent_tree(isolated, &staging, &intent.manifest)?;
isolated.rename(&staging, destination, RenameFlags::NOREPLACE)?;
isolated.sync_directory(destination_parent)?;
require_equivalent_tree(isolated, destination, &intent.manifest)?;
require_exact_tree(host, &quarantine, &intent.manifest)?;
remove_tree(host, &quarantine)?;
host.sync_directory(source_parent)?;
clear_intent(isolated, &intent)
})();
if let Err(error) = result {
return match reconcile(host, isolated, &intent)? {
Reconciled::Host => Err(error),
Reconciled::Isolated => Ok(()),
};
}
Ok(())
}
fn recover(host: &BackingRoot, isolated: &BackingRoot) -> io::Result<()> {
for (name, kind) in isolated.typed_entries(Path::new(TRANSACTIONS))? {
if kind == rustix::fs::FileType::RegularFile && name.as_bytes().ends_with(b".tmp") {
isolated.unlink(&Path::new(TRANSACTIONS).join(name), false)?;
continue;
}
if kind != rustix::fs::FileType::RegularFile
|| !name.as_bytes().ends_with(INTENT_SUFFIX.as_bytes())
{
return Err(io::Error::from_raw_os_error(libc::EIO));
}
let path = Path::new(TRANSACTIONS).join(name);
let bytes = isolated.read_file(&path, 16 * 1024 * 1024)?;
let intent: Intent = serde_json::from_slice(&bytes)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
if intent.schema != 1 || intent_path(&intent) != path {
return Err(io::Error::from_raw_os_error(libc::EIO));
}
reconcile(host, isolated, &intent)?;
}
isolated.sync_directory(Path::new(TRANSACTIONS))?;
Ok(())
}
#[derive(Clone, Copy)]
enum Reconciled {
Host,
Isolated,
}
fn reconcile(
host: &BackingRoot,
isolated: &BackingRoot,
intent: &Intent,
) -> io::Result<Reconciled> {
let source = path_from_bytes(&intent.source);
let destination = path_from_bytes(&intent.destination);
let quarantine = path_from_bytes(&intent.quarantine);
let staging = path_from_bytes(&intent.staging);
let source_exists = host.stat(&source).is_ok();
let quarantine_exists = host.stat(&quarantine).is_ok();
let destination_exists = isolated.stat(&destination).is_ok();
if destination_exists {
require_equivalent_tree(isolated, &destination, &intent.manifest)?;
if quarantine_exists {
require_exact_tree(host, &quarantine, &intent.manifest)?;
remove_tree(host, &quarantine)?;
host.sync_directory(quarantine.parent().unwrap_or_else(|| Path::new("")))?;
}
if source_exists {
return Err(io::Error::from_raw_os_error(libc::EIO));
}
if isolated.stat(&staging).is_ok() {
remove_tree(isolated, &staging)?;
isolated.sync_directory(staging.parent().unwrap_or_else(|| Path::new("")))?;
}
clear_intent(isolated, intent)?;
return Ok(Reconciled::Isolated);
}
if isolated.stat(&staging).is_ok() {
remove_tree(isolated, &staging)?;
isolated.sync_directory(staging.parent().unwrap_or_else(|| Path::new("")))?;
}
match (source_exists, quarantine_exists) {
(true, false) => {
require_exact_tree(host, &source, &intent.manifest)?;
}
(false, true) => {
host.rename(&quarantine, &source, RenameFlags::NOREPLACE)?;
host.sync_directory(source.parent().unwrap_or_else(|| Path::new("")))?;
require_exact_tree(host, &source, &intent.manifest)?;
}
_ => return Err(io::Error::from_raw_os_error(libc::EIO)),
}
clear_intent(isolated, intent)?;
Ok(Reconciled::Host)
}
fn persist_intent(isolated: &BackingRoot, intent: &Intent) -> io::Result<()> {
let final_path = intent_path(intent);
let temporary_path = Path::new(TRANSACTIONS).join(format!("{}.tmp", intent.id));
let bytes = serde_json::to_vec(intent).map_err(io::Error::other)?;
isolated.write_file(&temporary_path, &bytes, Mode::from_raw_mode(0o600))?;
isolated.rename(&temporary_path, &final_path, RenameFlags::NOREPLACE)?;
isolated.sync_directory(Path::new(TRANSACTIONS))
}
fn clear_intent(isolated: &BackingRoot, intent: &Intent) -> io::Result<()> {
isolated.unlink(&intent_path(intent), false)?;
isolated.sync_directory(Path::new(TRANSACTIONS))
}
fn intent_path(intent: &Intent) -> PathBuf {
Path::new(TRANSACTIONS).join(format!("{}{INTENT_SUFFIX}", intent.id))
}
fn scan_tree(root: &BackingRoot, tree: &Path) -> io::Result<Vec<ObjectRecord>> {
let mut records = Vec::new();
scan_entry(root, tree, Path::new(""), &mut records)?;
records.sort_by(|left, right| left.path.cmp(&right.path));
Ok(records)
}
fn scan_entry(
root: &BackingRoot,
tree: &Path,
relative: &Path,
records: &mut Vec<ObjectRecord>,
) -> io::Result<()> {
let path = if relative.as_os_str().is_empty() {
tree.to_path_buf()
} else {
tree.join(relative)
};
let stat = root.stat(&path)?;
let kind = match stat.st_mode & libc::S_IFMT {
libc::S_IFDIR => ObjectKind::Directory,
libc::S_IFREG if stat.st_nlink == 1 => ObjectKind::Regular,
_ => return Err(io::Error::from_raw_os_error(libc::EXDEV)),
};
let file = root.open_entry(&path, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty())?;
let mut xattrs = vec![0; 64 * 1024];
let xattr_length = flistxattr(file.as_fd(), &mut xattrs)?;
xattrs.truncate(xattr_length);
if xattrs.split(|byte| *byte == 0).any(|name| {
name.starts_with(b"user.")
|| matches!(
name,
b"system.posix_acl_access" | b"system.posix_acl_default"
)
}) {
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
let digest = if kind == ObjectKind::Regular {
Some(digest(file)?)
} else {
None
};
records.push(ObjectRecord {
path: path_bytes(relative),
kind,
device: stat.st_dev,
inode: stat.st_ino,
mode: stat.st_mode,
uid: stat.st_uid,
gid: stat.st_gid,
size: stat.st_size,
modification_seconds: stat.st_mtime,
modification_nanoseconds: stat.st_mtime_nsec,
change_seconds: stat.st_ctime,
change_nanoseconds: stat.st_ctime_nsec,
digest,
});
if kind == ObjectKind::Directory {
for (name, _) in root.typed_entries(&path)? {
scan_entry(root, tree, &relative.join(name), records)?;
}
}
Ok(())
}
fn digest(mut file: File) -> io::Result<[u8; 32]> {
let mut hasher = Sha256::new();
let mut buffer = vec![0; 64 * 1024];
loop {
let length = file.read(&mut buffer)?;
if length == 0 {
break;
}
hasher.update(&buffer[..length]);
}
Ok(hasher.finalize().into())
}
fn copy_tree(
source_root: &BackingRoot,
source: &Path,
destination_root: &BackingRoot,
destination: &Path,
) -> io::Result<()> {
let stat = source_root.stat(source)?;
match stat.st_mode & libc::S_IFMT {
libc::S_IFDIR => {
destination_root.mkdir(destination, Mode::from_raw_mode(stat.st_mode & 0o7777))?;
for (name, _) in source_root.typed_entries(source)? {
copy_tree(
source_root,
&source.join(&name),
destination_root,
&destination.join(name),
)?;
}
let directory = destination_root.open_directory(destination)?;
set_metadata(&directory, &stat)?;
directory.sync_all()
}
libc::S_IFREG if stat.st_nlink == 1 => {
let mut input = source_root.open_entry(source, OFlags::RDONLY, Mode::empty())?;
let mut output = destination_root
.create_file(destination, Mode::from_raw_mode(stat.st_mode & 0o7777))?;
io::copy(&mut input, &mut output)?;
set_metadata(&output, &stat)?;
output.sync_all()
}
_ => Err(io::Error::from_raw_os_error(libc::EXDEV)),
}
}
fn set_metadata(file: &File, stat: &rustix::fs::Stat) -> io::Result<()> {
fchmod(file.as_fd(), Mode::from_raw_mode(stat.st_mode & 0o7777))?;
fchown(
file.as_fd(),
Some(Uid::from_raw(stat.st_uid)),
Some(Gid::from_raw(stat.st_gid)),
)?;
futimens(
file.as_fd(),
&Timestamps {
last_access: Timespec {
tv_sec: stat.st_atime,
tv_nsec: i64::try_from(stat.st_atime_nsec).unwrap_or(999_999_999),
},
last_modification: Timespec {
tv_sec: stat.st_mtime,
tv_nsec: i64::try_from(stat.st_mtime_nsec).unwrap_or(999_999_999),
},
},
)?;
Ok(())
}
fn remove_tree(root: &BackingRoot, path: &Path) -> io::Result<()> {
let stat = root.stat(path)?;
if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
for (name, _) in root.typed_entries(path)? {
remove_tree(root, &path.join(name))?;
}
root.unlink(path, true)
} else {
root.unlink(path, false)
}
}
fn require_exact_tree(
root: &BackingRoot,
path: &Path,
expected: &[ObjectRecord],
) -> io::Result<()> {
let actual = scan_tree(root, path)?;
let exact = actual.len() == expected.len()
&& actual.iter().zip(expected).all(|(actual, expected)| {
actual.equivalent(expected)
&& actual.device == expected.device
&& actual.inode == expected.inode
&& (actual.path.is_empty()
|| (actual.change_seconds == expected.change_seconds
&& actual.change_nanoseconds == expected.change_nanoseconds))
});
if exact {
Ok(())
} else {
Err(io::Error::from_raw_os_error(libc::EIO))
}
}
fn require_equivalent_tree(
root: &BackingRoot,
path: &Path,
expected: &[ObjectRecord],
) -> io::Result<()> {
let actual = scan_tree(root, path)?;
let equivalent = actual.len() == expected.len()
&& actual
.iter()
.zip(expected)
.all(|(actual, expected)| actual.equivalent(expected));
if equivalent {
Ok(())
} else {
Err(io::Error::from_raw_os_error(libc::EIO))
}
}
impl ObjectRecord {
fn stamp(&self) -> ObjectStamp {
ObjectStamp {
device: self.device,
inode: self.inode,
mode: self.mode,
size: self.size,
modification_seconds: self.modification_seconds,
modification_nanoseconds: self.modification_nanoseconds,
change_seconds: self.change_seconds,
change_nanoseconds: self.change_nanoseconds,
}
}
fn equivalent(&self, expected: &Self) -> bool {
self.path == expected.path
&& self.kind == expected.kind
&& self.mode & 0o7777 == expected.mode & 0o7777
&& self.uid == expected.uid
&& self.gid == expected.gid
&& (self.kind == ObjectKind::Directory || self.size == expected.size)
&& self.modification_seconds == expected.modification_seconds
&& self.modification_nanoseconds == expected.modification_nanoseconds
&& self.digest == expected.digest
}
}
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()))
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::{
BackingRoot, Intent, Mode, Provenance, initialize, migrate, path_bytes, path_from_bytes,
persist_intent, scan_tree,
};
#[test]
fn qualified_directory_migrates_without_host_export() {
let fixture = Fixture::new("qualified");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
host.mkdir(
std::path::Path::new("target-temporary"),
Mode::from_raw_mode(0o755),
)
.unwrap();
let mut tag = host
.create_file(
std::path::Path::new("target-temporary/CACHEDIR.TAG"),
Mode::from_raw_mode(0o644),
)
.unwrap();
tag.write_all(b"Signature: 8a477f597d28d172789f06886806bc55\n")
.unwrap();
tag.sync_all().unwrap();
drop(tag);
let mut provenance = Provenance::default();
for path in ["target-temporary", "target-temporary/CACHEDIR.TAG"] {
let path = std::path::Path::new(path);
provenance.record(path.to_path_buf(), &host.stat(path).unwrap());
}
for record in scan_tree(&host, std::path::Path::new("target-temporary")).unwrap() {
let path = std::path::Path::new("target-temporary").join(path_from_bytes(&record.path));
assert!(provenance.matches(&path, &record.stamp()), "{path:?}");
}
migrate(
&host,
&isolated,
&provenance,
std::path::Path::new("target-temporary"),
std::path::Path::new("target"),
)
.unwrap();
assert!(host.stat(std::path::Path::new("target-temporary")).is_err());
assert!(host.stat(std::path::Path::new("target")).is_err());
assert_eq!(
isolated
.read_file(std::path::Path::new("target/CACHEDIR.TAG"), 1024)
.unwrap(),
b"Signature: 8a477f597d28d172789f06886806bc55\n"
);
assert!(
isolated
.typed_entries(std::path::Path::new(
".lazaret-ignored-workspace-transactions"
))
.unwrap()
.is_empty()
);
}
#[test]
fn host_mutation_after_provenance_capture_is_refused() {
let fixture = Fixture::new("changed");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
host.mkdir(
std::path::Path::new("target-temporary"),
Mode::from_raw_mode(0o755),
)
.unwrap();
host.write_file(
std::path::Path::new("target-temporary/CACHEDIR.TAG"),
b"sandbox",
Mode::from_raw_mode(0o644),
)
.unwrap();
let mut provenance = Provenance::default();
for path in ["target-temporary", "target-temporary/CACHEDIR.TAG"] {
let path = std::path::Path::new(path);
provenance.record(path.to_path_buf(), &host.stat(path).unwrap());
}
std::fs::write(
fixture.host.join("target-temporary/CACHEDIR.TAG"),
b"host mutation",
)
.unwrap();
let error = migrate(
&host,
&isolated,
&provenance,
std::path::Path::new("target-temporary"),
std::path::Path::new("target"),
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
assert_eq!(
std::fs::read(fixture.host.join("target-temporary/CACHEDIR.TAG")).unwrap(),
b"host mutation"
);
assert!(!fixture.isolated.join("target").exists());
}
#[test]
fn startup_recovers_a_durable_quarantine_to_the_host_source() {
let fixture = Fixture::new("recovery");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("target-temporary");
let destination = std::path::Path::new("target");
let quarantine = std::path::Path::new(".lazaret-quarantine-recovery");
let staging = std::path::Path::new(".lazaret-staging-recovery");
host.mkdir(source, Mode::from_raw_mode(0o755)).unwrap();
host.write_file(
&source.join("CACHEDIR.TAG"),
b"recoverable",
Mode::from_raw_mode(0o644),
)
.unwrap();
let intent = Intent {
schema: 1,
id: "recovery".to_owned(),
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
manifest: scan_tree(&host, source).unwrap(),
};
persist_intent(&isolated, &intent).unwrap();
host.rename(source, quarantine, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
host.sync_directory(std::path::Path::new("")).unwrap();
initialize(&host, &isolated).unwrap();
assert_eq!(
host.read_file(&source.join("CACHEDIR.TAG"), 1024).unwrap(),
b"recoverable"
);
assert!(host.stat(quarantine).is_err());
assert!(
isolated
.typed_entries(std::path::Path::new(
".lazaret-ignored-workspace-transactions"
))
.unwrap()
.is_empty()
);
}
struct Fixture {
root: std::path::PathBuf,
host: std::path::PathBuf,
isolated: std::path::PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let root = std::env::current_dir()
.unwrap()
.join("target/ignored-workspace-tests")
.join(format!("migration-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let host = root.join("host");
let isolated = root.join("isolated");
std::fs::create_dir_all(&host).unwrap();
std::fs::create_dir_all(&isolated).unwrap();
Self {
root,
host,
isolated,
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
}

View file

@ -725,12 +725,41 @@ absence and retain the isolated entry hidden. Neither transition imports,
exports, merges, deletes, or otherwise copies content between backings.
A same-backing `rename` or hard link has the backing filesystem's atomicity and
error behavior. If the source and destination route to different backings,
`rename`, `RENAME_NOREPLACE`, `RENAME_EXCHANGE`, and hard link return `EXDEV`
before either backing is changed. `RENAME_WHITEOUT` returns `EOPNOTSUPP` without
mutation. Lazaret never reports a copy-and-unlink sequence as a successful
rename or a copied file as a hard link. Applications may react to `EXDEV` with
their own visible copy and unlink operations.
error behavior. An isolated-to-host rename, cross-backing hard link,
`RENAME_EXCHANGE`, or cross-backing rename outside the qualified
host-to-isolated class returns `EXDEV` before either backing is changed.
`RENAME_WHITEOUT` returns `EOPNOTSUPP` without mutation.
A plain or `RENAME_NOREPLACE` host-to-isolated directory rename is qualified
only when the destination is absent and the complete source tree was created
through the current router instance. Every retained source identity must still
match, and every descendant must be a regular file or directory with supported
metadata. A writable description beneath the source must be read-only or
quiesced by a completed flush; any later mutation clears quiescence. Pinned
read-only file and directory descriptions remain attached to their original
host objects. Host mutation detected before quarantine refuses the rename.
A qualified rename is a durable migration transaction rather than a
cross-filesystem `rename(2)`. The router records and synchronizes intent before
renaming the source to a reserved same-backing host quarantine. It synchronizes
that parent, copies through descriptor-confined traversal into a reserved
isolated staging tree, verifies the staged tree and quarantined source, and
synchronizes the isolated tree before publishing it with a same-backing rename.
Only a qualified isolated destination permits deletion of the exact
quarantined tree. The router synchronizes the host parent before clearing
intent.
The affected paths do not admit new observations while the transaction is
unresolved. Startup reconciles every retained intent before publishing router
readiness. Recovery exposes either the complete host source or the qualified
isolated destination; partial staging and changed quarantine remain
unpublished and fail closed. The transaction never copies isolated bytes into
the host backing. Its success refines the sandbox namespace to one complete
endpoint but does not promise cross-filesystem inode identity or atomic
observation by host processes that bypass FUSE.
All other cross-backing renames retain `EXDEV` behavior. Applications may react
to `EXDEV` with their own visible copy and unlink operations.
An index publication that would make an isolated object tracked is accepted only
when a type-compatible host worktree object already exists independently. The
@ -763,8 +792,9 @@ worktree-local index locking, linked worktrees, and nested repositories. These
operations retain Git's ordinary conflict and concurrent-writer behavior. Git
Sentry still makes host `.git/config` read-only and replaces default
`.git/hooks` with sandbox-local transient storage. Force-adding an isolated-only
path, cross-policy atomic rename, and device-node creation are refused as defined
above and are not compatibility promises.
path, cross-policy rename outside the qualified host-to-isolated class, and
device-node creation are refused as defined above and are not compatibility
promises.
The repository `lazaret-ignored-workspace-contract-v1` corpus is a
non-authoritative executable test inventory derived from these semantics. It

View file

@ -6,6 +6,16 @@
"isolated"
],
"transition_contracts": [
{
"id": "qualified-host-to-isolated-rename",
"operations": [
"rename",
"rename-noreplace"
],
"result": "success",
"mutation": "durable-host-to-isolated-migration",
"copy": "host-to-isolated-only"
},
{
"id": "cross-route-rename",
"operations": [
@ -96,13 +106,13 @@
"operation": "rename",
"host": "supported",
"isolated": "supported",
"cross_route": "EXDEV"
"cross_route": "qualified-host-to-isolated-or-EXDEV"
},
{
"operation": "rename-noreplace",
"host": "supported",
"isolated": "supported",
"cross_route": "EXDEV"
"cross_route": "qualified-host-to-isolated-or-EXDEV"
},
{
"operation": "rename-exchange",

View file

@ -74,6 +74,14 @@
"actions": ["host-publish-ignore", "sandbox-open-after-publication"],
"expect": ["single-classification-epoch", "host-object-hidden", "no-copy"]
},
{
"id": "cargo-target-host-to-isolated-rename",
"tags": ["rename", "cross-policy-rename", "no-copy-back"],
"path_hex": "746172676574",
"setup": ["router-created-host-directory", "router-created-cachedir-tag", "ignored-destination"],
"actions": ["sandbox-rename"],
"expect": ["success", "qualified-isolated-destination", "host-source-absent", "no-isolated-to-host-copy"]
},
{
"id": "cross-route-file-rename",
"tags": ["rename", "cross-policy-rename", "no-silent-loss"],

View file

@ -153,7 +153,7 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
self.assertEqual(contract["mechanism"], "rootless-host-fuse-router")
self.assertEqual(contract["route_classes"], ["host", "isolated"])
def test_cross_route_namespace_operations_refuse_before_mutation(self) -> None:
def test_cross_route_namespace_operations_are_directionally_bounded(self) -> None:
transitions = {
transition["id"]: transition
for transition in load("contract.json")["transition_contracts"]
@ -166,6 +166,16 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
with self.subTest(transition_id=transition_id):
self.assertEqual(transitions[transition_id]["mutation"], "none")
self.assertEqual(transitions["cross-route-rename"]["result"], "EXDEV")
self.assertEqual(
transitions["qualified-host-to-isolated-rename"],
{
"id": "qualified-host-to-isolated-rename",
"operations": ["rename", "rename-noreplace"],
"result": "success",
"mutation": "durable-host-to-isolated-migration",
"copy": "host-to-isolated-only",
},
)
self.assertEqual(transitions["cross-route-hard-link"]["result"], "EXDEV")
self.assertEqual(transitions["rename-whiteout"]["result"], "EOPNOTSUPP")
@ -176,7 +186,7 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
for transition in transitions
if "copy" in transition and transition["copy"] != "none"
]
self.assertEqual(copying, [])
self.assertEqual(copying, ["qualified-host-to-isolated-rename"])
handle_transitions = {
transition["id"]: transition["open_handles"]
for transition in transitions
@ -214,6 +224,13 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
self.assertEqual(
transitions,
[
{
"id": "qualified-host-to-isolated-rename",
"operations": ["rename", "rename-noreplace"],
"result": "success",
"mutation": "durable-host-to-isolated-migration",
"copy": "host-to-isolated-only",
},
{
"id": "cross-route-rename",
"operations": ["rename", "rename-noreplace", "rename-exchange"],
@ -310,7 +327,10 @@ class IgnoredWorkspaceContractTests(unittest.TestCase):
"character-block-device-create",
}
self.assertTrue(required.issubset(operations))
self.assertEqual(operations["rename"]["cross_route"], "EXDEV")
self.assertEqual(
operations["rename"]["cross_route"],
"qualified-host-to-isolated-or-EXDEV",
)
self.assertEqual(operations["hard-link"]["cross_route"], "EXDEV")
self.assertEqual(operations["copy-file-range"]["cross_route"], "EXDEV")
self.assertEqual(operations["character-block-device-create"]["host"], "EPERM")

View file

@ -254,6 +254,25 @@ def atomic_case(case_id, fixture):
require(not (fixture.host / "ignored/destination").exists() and not (fixture.isolated / "ignored/destination").exists(), "destination appeared after EXDEV")
def migration_case(case_id, fixture):
del case_id
write(fixture.host / ".gitignore", b"/target/\n")
source = fixture.mount / "targetAbCd12"
destination = fixture.mount / "target"
payload = b"Signature: 8a477f597d28d172789f06886806bc55\n"
with fixture.start():
source.mkdir()
write(source / "CACHEDIR.TAG", payload)
os.rename(source, destination)
require((destination / "CACHEDIR.TAG").read_bytes() == payload, "published target differs")
expect_errno(errno.EXDEV, lambda: os.rename(destination, fixture.mount / "exported-target"))
require(not (fixture.host / "targetAbCd12").exists(), "host source remained after migration")
require(not (fixture.host / "target").exists(), "isolated target was exported to host")
require((fixture.isolated / "target/CACHEDIR.TAG").read_bytes() == payload, "isolated target missing")
intents = fixture.isolated / ".lazaret-ignored-workspace-transactions"
require(not any(intents.iterdir()), "completed migration retained intent")
def rename_exchange(source, destination):
observed = exchange_paths(source, destination)
if observed == 0:
@ -666,6 +685,7 @@ CASE_GROUPS = {
"live-ignore-publication",
]},
**{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,
**{name: policy_transition_case for name in ["ignored-becomes-non-ignored", "non-ignored-becomes-ignored"]},
**{name: index_case for name in ["guest-force-add-isolated-only", "host-established-tracked-cutover", "unmediated-inconsistent-index"]},
**{name: open_handle_case for name in ["mmap-policy-transition", "locks-policy-transition", "directory-stream-policy-snapshot"]},