Support durable host-to-isolated workspace migration

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

1
Cargo.lock generated
View file

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

View file

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

View file

@ -2,6 +2,7 @@ use std::ffi::{OsStr, OsString};
use std::fs::File; use std::fs::File;
use std::io; use std::io;
use std::io::Read; use std::io::Read;
use std::io::Write;
use std::os::fd::AsFd; use std::os::fd::AsFd;
use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Component, Path}; use std::path::{Component, Path};
@ -121,6 +122,24 @@ impl BackingRoot {
Ok(mkdirat(parent.as_fd(), name, mode)?) 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<()> { pub(crate) fn unlink(&self, relative: &Path, directory: bool) -> io::Result<()> {
let (parent, name) = split(relative)?; let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?; 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::FileExt;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use fuser::{ use fuser::{
@ -32,6 +32,7 @@ use rustix::time::Timespec;
use crate::backing::{BackingRoot, validate_name, validate_relative}; use crate::backing::{BackingRoot, validate_name, validate_relative};
use crate::classifier::tracked_paths_from_index; use crate::classifier::tracked_paths_from_index;
use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier}; use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier};
use crate::migration::{Provenance, reserved};
use nodes::{ use nodes::{
DirectoryEntry, DirectoryStream, NodeTable, OpenFile, PosixLockKey, ResolvedEntry, errno, DirectoryEntry, DirectoryStream, NodeTable, OpenFile, PosixLockKey, ResolvedEntry, errno,
file_type, open_flags, system_time, file_type, open_flags, system_time,
@ -67,6 +68,8 @@ pub struct IgnoredWorkspace {
files: Mutex<HashMap<u64, Arc<OpenFile>>>, files: Mutex<HashMap<u64, Arc<OpenFile>>>,
directories: Mutex<HashMap<u64, DirectoryStream>>, directories: Mutex<HashMap<u64, DirectoryStream>>,
posix_lock_files: Mutex<HashMap<PosixLockKey, Arc<File>>>, posix_lock_files: Mutex<HashMap<PosixLockKey, Arc<File>>>,
namespace: RwLock<()>,
provenance: Mutex<Provenance>,
next_handle: AtomicU64, next_handle: AtomicU64,
} }
@ -155,6 +158,7 @@ impl IgnoredWorkspace {
if host.identity()? == isolated.identity()? { if host.identity()? == isolated.identity()? {
return Err(io::Error::other("workspace and isolated authorities alias")); 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_root = BackingRoot::from_file(host.duplicate()?)?;
let classifier = let classifier =
GitClassifier::from_root(workspace_label, classifier_root, global_excludes)?; GitClassifier::from_root(workspace_label, classifier_root, global_excludes)?;
@ -167,6 +171,8 @@ impl IgnoredWorkspace {
files: Mutex::new(HashMap::new()), files: Mutex::new(HashMap::new()),
directories: Mutex::new(HashMap::new()), directories: Mutex::new(HashMap::new()),
posix_lock_files: Mutex::new(HashMap::new()), posix_lock_files: Mutex::new(HashMap::new()),
namespace: RwLock::new(()),
provenance: Mutex::new(Provenance::default()),
next_handle: AtomicU64::new(1), next_handle: AtomicU64::new(1),
}) })
} }
@ -229,6 +235,9 @@ impl IgnoredWorkspace {
validate_name(name)?; validate_name(name)?;
let path = self.path_for(parent)?.join(name); let path = self.path_for(parent)?.join(name);
validate_relative(&path)?; validate_relative(&path)?;
if reserved(&path) {
return Err(io::Error::from_raw_os_error(libc::ENOENT));
}
Ok(path) Ok(path)
} }
@ -243,6 +252,9 @@ impl IgnoredWorkspace {
} }
fn resolve(&self, snapshot: &ClassificationSnapshot, path: &Path) -> io::Result<ResolvedEntry> { 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 host = self.host.stat(path).ok();
let isolated = self.isolated.stat(path).ok(); let isolated = self.isolated.stat(path).ok();
let directory = isolated let directory = isolated
@ -305,6 +317,9 @@ impl IgnoredWorkspace {
let file = self let file = self
.root(entry.backing) .root(entry.backing)
.open_entry(&path, open_flags(flags), Mode::empty())?; .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); let handle = self.next_handle.fetch_add(1, Ordering::Relaxed);
self.files self.files
.lock() .lock()
@ -314,7 +329,9 @@ impl IgnoredWorkspace {
Arc::new(OpenFile { Arc::new(OpenFile {
file, file,
backing: entry.backing, backing: entry.backing,
path,
access_mode: flags & libc::O_ACCMODE, access_mode: flags & libc::O_ACCMODE,
migration_quiesced: AtomicBool::new(flags & libc::O_ACCMODE == libc::O_RDONLY),
}), }),
); );
Ok(handle) Ok(handle)
@ -347,6 +364,60 @@ impl IgnoredWorkspace {
Ok(entries) 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) { fn reply_entry(&self, path: &Path, reply: ReplyEntry) {
let snapshot = self.classifier.snapshot_for(path); let snapshot = self.classifier.snapshot_for(path);
match self match self

View file

@ -11,7 +11,14 @@ impl IgnoredWorkspace {
let path = self.child_path(parent, name)?; let path = self.child_path(parent, name)?;
let snapshot = self.classifier.snapshot_for(&path); let snapshot = self.classifier.snapshot_for(&path);
let entry = self.resolve(&snapshot, &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); reply_result(result, reply);
} }
@ -66,7 +73,9 @@ pub(super) struct ResolvedEntry {
pub(super) struct OpenFile { pub(super) struct OpenFile {
pub(super) file: File, pub(super) file: File,
pub(super) backing: Classification, pub(super) backing: Classification,
pub(super) path: PathBuf,
pub(super) access_mode: i32, pub(super) access_mode: i32,
pub(super) migration_quiesced: std::sync::atomic::AtomicBool,
} }
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]

View file

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

View file

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

View file

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

View file

@ -725,12 +725,41 @@ absence and retain the isolated entry hidden. Neither transition imports,
exports, merges, deletes, or otherwise copies content between backings. exports, merges, deletes, or otherwise copies content between backings.
A same-backing `rename` or hard link has the backing filesystem's atomicity and A same-backing `rename` or hard link has the backing filesystem's atomicity and
error behavior. If the source and destination route to different backings, error behavior. An isolated-to-host rename, cross-backing hard link,
`rename`, `RENAME_NOREPLACE`, `RENAME_EXCHANGE`, and hard link return `EXDEV` `RENAME_EXCHANGE`, or cross-backing rename outside the qualified
before either backing is changed. `RENAME_WHITEOUT` returns `EOPNOTSUPP` without host-to-isolated class returns `EXDEV` before either backing is changed.
mutation. Lazaret never reports a copy-and-unlink sequence as a successful `RENAME_WHITEOUT` returns `EOPNOTSUPP` without mutation.
rename or a copied file as a hard link. Applications may react to `EXDEV` with
their own visible copy and unlink operations. 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 An index publication that would make an isolated object tracked is accepted only
when a type-compatible host worktree object already exists independently. The 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 operations retain Git's ordinary conflict and concurrent-writer behavior. Git
Sentry still makes host `.git/config` read-only and replaces default Sentry still makes host `.git/config` read-only and replaces default
`.git/hooks` with sandbox-local transient storage. Force-adding an isolated-only `.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 path, cross-policy rename outside the qualified host-to-isolated class, and
above and are not compatibility promises. device-node creation are refused as defined above and are not compatibility
promises.
The repository `lazaret-ignored-workspace-contract-v1` corpus is a The repository `lazaret-ignored-workspace-contract-v1` corpus is a
non-authoritative executable test inventory derived from these semantics. It non-authoritative executable test inventory derived from these semantics. It

View file

@ -6,6 +6,16 @@
"isolated" "isolated"
], ],
"transition_contracts": [ "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", "id": "cross-route-rename",
"operations": [ "operations": [
@ -96,13 +106,13 @@
"operation": "rename", "operation": "rename",
"host": "supported", "host": "supported",
"isolated": "supported", "isolated": "supported",
"cross_route": "EXDEV" "cross_route": "qualified-host-to-isolated-or-EXDEV"
}, },
{ {
"operation": "rename-noreplace", "operation": "rename-noreplace",
"host": "supported", "host": "supported",
"isolated": "supported", "isolated": "supported",
"cross_route": "EXDEV" "cross_route": "qualified-host-to-isolated-or-EXDEV"
}, },
{ {
"operation": "rename-exchange", "operation": "rename-exchange",

View file

@ -74,6 +74,14 @@
"actions": ["host-publish-ignore", "sandbox-open-after-publication"], "actions": ["host-publish-ignore", "sandbox-open-after-publication"],
"expect": ["single-classification-epoch", "host-object-hidden", "no-copy"] "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", "id": "cross-route-file-rename",
"tags": ["rename", "cross-policy-rename", "no-silent-loss"], "tags": ["rename", "cross-policy-rename", "no-silent-loss"],

View file

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

View file

@ -254,6 +254,25 @@ def atomic_case(case_id, fixture):
require(not (fixture.host / "ignored/destination").exists() and not (fixture.isolated / "ignored/destination").exists(), "destination appeared after EXDEV") 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): def rename_exchange(source, destination):
observed = exchange_paths(source, destination) observed = exchange_paths(source, destination)
if observed == 0: if observed == 0:
@ -666,6 +685,7 @@ CASE_GROUPS = {
"live-ignore-publication", "live-ignore-publication",
]}, ]},
**{name: atomic_case for name in ["cross-route-file-rename", "cross-route-directory-exchange", "cross-route-hard-link"]}, **{name: atomic_case for name in ["cross-route-file-rename", "cross-route-directory-exchange", "cross-route-hard-link"]},
"cargo-target-host-to-isolated-rename": migration_case,
**{name: policy_transition_case for name in ["ignored-becomes-non-ignored", "non-ignored-becomes-ignored"]}, **{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: 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"]}, **{name: open_handle_case for name in ["mmap-policy-transition", "locks-policy-transition", "directory-stream-policy-snapshot"]},