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",
|
"libc",
|
||||||
"nix 0.31.3",
|
"nix 0.31.3",
|
||||||
"rustix",
|
"rustix",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)?;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
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));
|
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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
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.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"]},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue