Support durable host-to-isolated workspace migration
This commit is contained in:
parent
db9a561d0c
commit
0dda65fc2e
14 changed files with 1176 additions and 32 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1752,6 +1752,7 @@ dependencies = [
|
|||
"libc",
|
||||
"nix 0.31.3",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
744
crates/lazaret-ignored-workspace/src/migration.rs
Normal file
744
crates/lazaret-ignored-workspace/src/migration.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"]},
|
||||
|
|
|
|||
Loading…
Reference in a new issue