Support durable host-to-isolated workspace migration

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

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 {
return Err(io::Error::from_raw_os_error(libc::EXDEV));
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);
}
}
}