Generalize safe host-to-isolated rename migration

This commit is contained in:
ms2k 2026-07-29 22:20:04 +09:00
commit 35ab7f17c0
5 changed files with 589 additions and 81 deletions

View file

@ -16,6 +16,7 @@ rustix.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
xattr.workspace = true
[dev-dependencies]

View file

@ -3,14 +3,15 @@ use std::fs::File;
use std::io;
use std::io::Read;
use std::io::Write;
use std::os::fd::AsFd;
use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Component, Path};
use std::path::{Component, Path, PathBuf};
use rustix::fs::{
AtFlags, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, Stat, Timestamps, linkat, mkdirat,
openat, openat2, readlinkat, renameat_with, statat, symlinkat, unlinkat, utimensat,
AtFlags, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, Stat, Timestamps, chownat, linkat,
mkdirat, openat, openat2, readlinkat, renameat_with, statat, symlinkat, unlinkat, utimensat,
};
use rustix::process::{Gid, Uid};
const RESOLVE: ResolveFlags = ResolveFlags::BENEATH
.union(ResolveFlags::NO_MAGICLINKS)
@ -196,6 +197,14 @@ impl BackingRoot {
Ok(readlinkat(parent.as_fd(), name, Vec::new())?.into_bytes())
}
pub(crate) fn symlink_xattrs(&self, relative: &Path) -> io::Result<Vec<OsString>> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
let mut path = format!("/proc/self/fd/{}/", parent.as_raw_fd()).into_bytes();
path.extend_from_slice(name.as_bytes());
xattr::list(PathBuf::from(OsString::from_vec(path))).map(Iterator::collect)
}
pub(crate) fn set_symlink_times(
&self,
relative: &Path,
@ -211,6 +220,18 @@ impl BackingRoot {
)?)
}
pub(crate) fn set_symlink_owner(&self, relative: &Path, uid: Uid, gid: Gid) -> io::Result<()> {
let (parent, name) = split(relative)?;
let parent = self.open_directory(parent)?;
Ok(chownat(
parent.as_fd(),
name,
Some(uid),
Some(gid),
AtFlags::SYMLINK_NOFOLLOW,
)?)
}
pub(crate) fn entries(&self, relative: &Path) -> io::Result<Vec<OsString>> {
Ok(self
.typed_entries(relative)?

View file

@ -351,7 +351,6 @@ impl Filesystem for IgnoredWorkspace {
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
{
@ -364,10 +363,13 @@ impl Filesystem for IgnoredWorkspace {
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
if !directory {
if !matches!(
source_entry.stat.st_mode & libc::S_IFMT,
libc::S_IFREG | libc::S_IFDIR | libc::S_IFLNK
) {
let _ = writeln!(
io::stderr().lock(),
"lazaret ignored-workspace refused host-to-isolated migration: source is not a directory"
"lazaret ignored-workspace refused host-to-isolated migration: source type has no copy semantics"
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
@ -388,6 +390,7 @@ impl Filesystem for IgnoredWorkspace {
&provenance,
&source,
&destination,
flags.is_empty(),
)?;
provenance.remove_tree(&source);
return Ok(());
@ -492,6 +495,10 @@ impl Filesystem for IgnoredWorkspace {
_lock_owner: Option<LockOwner>,
reply: ReplyWrite,
) {
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = self.with_file(handle, |open| {
open.migration_quiesced.store(false, Ordering::Release);
let offset = if flags.0 & libc::O_APPEND != 0 {
@ -1005,6 +1012,10 @@ impl Filesystem for IgnoredWorkspace {
reply.error(Errno::EINVAL);
return;
}
let _namespace = self
.namespace
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let files = self
.files
.lock()

View file

@ -321,6 +321,75 @@ fn physical_router_migrates_cargo_target_only_toward_isolated_state() {
session.umount_and_join().unwrap();
}
#[test]
#[ignore = "requires a usable host FUSE device"]
fn physical_router_replaces_isolated_regular_file_only_toward_isolated_state() {
let fixture = Fixture::new();
repository(&fixture.workspace);
fs::write(fixture.workspace.join(".gitignore"), b"dump.rdb\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();
fs::write(fixture.mountpoint.join("dump.rdb"), b"old").unwrap();
fs::write(fixture.mountpoint.join("temporary.rdb"), b"new").unwrap();
fs::rename(
fixture.mountpoint.join("temporary.rdb"),
fixture.mountpoint.join("dump.rdb"),
)
.unwrap();
assert!(!fixture.workspace.join("temporary.rdb").exists());
assert!(!fixture.workspace.join("dump.rdb").exists());
assert_eq!(fs::read(fixture.isolated.join("dump.rdb")).unwrap(), b"new");
let error = fs::rename(
fixture.mountpoint.join("dump.rdb"),
fixture.mountpoint.join("exported.rdb"),
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
assert!(!fixture.workspace.join("exported.rdb").exists());
fs::write(fixture.mountpoint.join("open-source"), b"open").unwrap();
let open = OpenOptions::new()
.read(true)
.write(true)
.open(fixture.mountpoint.join("open-source"))
.unwrap();
let error = fs::rename(
fixture.mountpoint.join("open-source"),
fixture.mountpoint.join("dump.rdb"),
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
drop(open);
rustix::fs::mknodat(
rustix::fs::CWD,
fixture.mountpoint.join("fifo-source"),
rustix::fs::FileType::Fifo,
rustix::fs::Mode::from_raw_mode(0o600),
0,
)
.unwrap();
let error = fs::rename(
fixture.mountpoint.join("fifo-source"),
fixture.mountpoint.join("dump.rdb"),
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
session.umount_and_join().unwrap();
}
fn repository(root: &std::path::Path) {
fs::create_dir_all(root.join(".git/info")).unwrap();
fs::create_dir_all(root.join(".git/hooks")).unwrap();

View file

@ -19,7 +19,7 @@ const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions";
const INTENT_SUFFIX: &str = ".json";
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) struct ObjectStamp {
device: u64,
inode: u64,
@ -44,6 +44,10 @@ impl ObjectStamp {
change_nanoseconds: stat.st_ctime_nsec,
}
}
fn same_object(&self, stat: &rustix::fs::Stat) -> bool {
self.device == stat.st_dev && self.inode == stat.st_ino && self.mode == stat.st_mode
}
}
#[derive(Default)]
@ -99,6 +103,12 @@ struct Intent {
destination: Vec<u8>,
quarantine: Vec<u8>,
staging: Vec<u8>,
#[serde(default)]
backup: Vec<u8>,
#[serde(default)]
destination_existed: bool,
#[serde(default)]
destination_stamp: Option<ObjectStamp>,
manifest: Vec<ObjectRecord>,
}
@ -117,12 +127,15 @@ struct ObjectRecord {
change_seconds: i64,
change_nanoseconds: u64,
digest: Option<[u8; 32]>,
#[serde(default)]
link_target: Option<Vec<u8>>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
enum ObjectKind {
Directory,
Regular,
Symlink,
}
pub(crate) fn reserved(path: &Path) -> bool {
@ -147,8 +160,10 @@ pub(crate) fn migrate(
provenance: &Provenance,
source: &Path,
destination: &Path,
replace_existing: bool,
) -> io::Result<()> {
if isolated.stat(destination).is_ok() {
let destination_stat = stat_if_exists(isolated, destination)?;
if destination_stat.is_some() && !replace_existing {
return Err(io::Error::from_raw_os_error(libc::EEXIST));
}
let manifest = match scan_tree(host, source) {
@ -168,6 +183,9 @@ pub(crate) fn migrate(
);
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
if let Some(destination_stat) = destination_stat.as_ref() {
require_compatible_destination(isolated, destination, &manifest[0], destination_stat)?;
}
if let Some(record) = manifest.iter().find(|record| {
let path = source.join(path_from_bytes(&record.path));
!provenance.matches(&path, &record.stamp())
@ -189,13 +207,17 @@ pub(crate) fn migrate(
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 backup = destination_parent.join(format!(".lazaret-backup-{id}"));
let intent = Intent {
schema: 1,
schema: 3,
id,
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(&quarantine),
staging: path_bytes(&staging),
backup: path_bytes(&backup),
destination_existed: destination_stat.is_some(),
destination_stamp: destination_stat.as_ref().map(ObjectStamp::from_stat),
manifest,
};
if let Err(error) = persist_intent(isolated, &intent) {
@ -214,12 +236,20 @@ pub(crate) fn migrate(
isolated.sync_directory(destination_parent)?;
require_exact_tree(host, &quarantine, &intent.manifest)?;
require_equivalent_tree(isolated, &staging, &intent.manifest)?;
if intent.destination_existed {
isolated.rename(destination, &backup, RenameFlags::NOREPLACE)?;
isolated.sync_directory(destination_parent)?;
}
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)?;
if stat_if_exists(isolated, &backup)?.is_some() {
remove_tree(isolated, &backup)?;
isolated.sync_directory(destination_parent)?;
}
clear_intent(isolated, &intent)
})();
if let Err(error) = result {
@ -246,7 +276,11 @@ fn recover(host: &BackingRoot, isolated: &BackingRoot) -> io::Result<()> {
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 {
if !matches!(intent.schema, 1..=3)
|| intent_path(&intent) != path
|| (intent.schema >= 2
&& intent.destination_existed != intent.destination_stamp.is_some())
{
return Err(io::Error::from_raw_os_error(libc::EIO));
}
reconcile(host, isolated, &intent)?;
@ -270,32 +304,65 @@ fn reconcile(
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();
let backup = intent_backup(intent, &destination);
let source_exists = stat_if_exists(host, &source)?.is_some();
let quarantine_exists = stat_if_exists(host, &quarantine)?.is_some();
let destination_stat = stat_if_exists(isolated, &destination)?;
let destination_exists = destination_stat.is_some();
let backup_stat = stat_if_exists(isolated, &backup)?;
let backup_exists = backup_stat.is_some();
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("")))?;
let destination_is_previous = intent.destination_stamp.as_ref().is_some_and(|stamp| {
destination_stat
.as_ref()
.is_some_and(|stat| stamp.same_object(stat))
});
if !destination_is_previous {
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 stat_if_exists(isolated, &staging)?.is_some() {
remove_tree(isolated, &staging)?;
}
if backup_exists {
remove_tree(isolated, &backup)?;
}
isolated.sync_directory(staging.parent().unwrap_or_else(|| Path::new("")))?;
clear_intent(isolated, intent)?;
return Ok(Reconciled::Isolated);
}
if source_exists {
if !intent.destination_existed || backup_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() {
if stat_if_exists(isolated, &staging)?.is_some() {
remove_tree(isolated, &staging)?;
isolated.sync_directory(staging.parent().unwrap_or_else(|| Path::new("")))?;
}
if backup_exists {
if destination_exists {
return Err(io::Error::from_raw_os_error(libc::EIO));
}
if !intent.destination_stamp.as_ref().is_some_and(|stamp| {
backup_stat
.as_ref()
.is_some_and(|stat| stamp.same_object(stat))
}) {
return Err(io::Error::from_raw_os_error(libc::EIO));
}
isolated.rename(&backup, &destination, RenameFlags::NOREPLACE)?;
isolated.sync_directory(destination.parent().unwrap_or_else(|| Path::new("")))?;
} else if intent.destination_existed && !destination_exists {
return Err(io::Error::from_raw_os_error(libc::EIO));
}
match (source_exists, quarantine_exists) {
(true, false) => {
require_exact_tree(host, &source, &intent.manifest)?;
@ -311,6 +378,43 @@ fn reconcile(
Ok(Reconciled::Host)
}
fn stat_if_exists(root: &BackingRoot, path: &Path) -> io::Result<Option<rustix::fs::Stat>> {
match root.stat(path) {
Ok(stat) => Ok(Some(stat)),
Err(error) if error.raw_os_error() == Some(libc::ENOENT) => Ok(None),
Err(error) => Err(error),
}
}
fn intent_backup(intent: &Intent, destination: &Path) -> PathBuf {
if intent.backup.is_empty() {
destination
.parent()
.unwrap_or_else(|| Path::new(""))
.join(format!(".lazaret-backup-{}", intent.id))
} else {
path_from_bytes(&intent.backup)
}
}
fn require_compatible_destination(
isolated: &BackingRoot,
destination: &Path,
source: &ObjectRecord,
destination_stat: &rustix::fs::Stat,
) -> io::Result<()> {
let source_directory = source.kind == ObjectKind::Directory;
let destination_directory = destination_stat.st_mode & libc::S_IFMT == libc::S_IFDIR;
match (source_directory, destination_directory) {
(true, false) => Err(io::Error::from_raw_os_error(libc::ENOTDIR)),
(false, true) => Err(io::Error::from_raw_os_error(libc::EISDIR)),
(true, true) if !isolated.typed_entries(destination)?.is_empty() => {
Err(io::Error::from_raw_os_error(libc::ENOTEMPTY))
}
_ => Ok(()),
}
}
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));
@ -331,7 +435,8 @@ fn intent_path(intent: &Intent) -> PathBuf {
fn scan_tree(root: &BackingRoot, tree: &Path) -> io::Result<Vec<ObjectRecord>> {
let mut records = Vec::new();
scan_entry(root, tree, Path::new(""), &mut records)?;
let device = root.stat(tree)?.st_dev;
scan_entry(root, tree, Path::new(""), device, &mut records)?;
records.sort_by(|left, right| left.path.cmp(&right.path));
Ok(records)
}
@ -340,6 +445,7 @@ fn scan_entry(
root: &BackingRoot,
tree: &Path,
relative: &Path,
device: u64,
records: &mut Vec<ObjectRecord>,
) -> io::Result<()> {
let path = if relative.as_os_str().is_empty() {
@ -348,26 +454,35 @@ fn scan_entry(
tree.join(relative)
};
let stat = root.stat(&path)?;
if stat.st_dev != device {
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
let kind = match stat.st_mode & libc::S_IFMT {
libc::S_IFDIR => ObjectKind::Directory,
libc::S_IFREG if stat.st_nlink == 1 => ObjectKind::Regular,
libc::S_IFLNK => ObjectKind::Symlink,
_ => 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 file = if kind == ObjectKind::Symlink {
if root.symlink_xattrs(&path)?.into_iter().any(|name| {
let name = name.as_bytes();
unsupported_xattr_name(name)
}) {
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
None
} else {
Some(root.open_entry(&path, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty())?)
};
if let Some(file) = file.as_ref() {
require_no_unsupported_xattrs(file)?;
}
let digest = if kind == ObjectKind::Regular {
Some(digest(file)?)
let digest = match (kind, file) {
(ObjectKind::Regular, Some(file)) => Some(digest(file)?),
_ => None,
};
let link_target = if kind == ObjectKind::Symlink {
Some(root.readlink(&path)?)
} else {
None
};
@ -385,15 +500,34 @@ fn scan_entry(
change_seconds: stat.st_ctime,
change_nanoseconds: stat.st_ctime_nsec,
digest,
link_target,
});
if kind == ObjectKind::Directory {
for (name, _) in root.typed_entries(&path)? {
scan_entry(root, tree, &relative.join(name), records)?;
scan_entry(root, tree, &relative.join(name), device, records)?;
}
}
Ok(())
}
fn require_no_unsupported_xattrs(file: &File) -> io::Result<()> {
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(unsupported_xattr_name) {
return Err(io::Error::from_raw_os_error(libc::EXDEV));
}
Ok(())
}
fn unsupported_xattr_name(name: &[u8]) -> bool {
name.starts_with(b"user.")
|| matches!(
name,
b"system.posix_acl_access" | b"system.posix_acl_default"
)
}
fn digest(mut file: File) -> io::Result<[u8; 32]> {
let mut hasher = Sha256::new();
let mut buffer = vec![0; 64 * 1024];
@ -437,6 +571,16 @@ fn copy_tree(
set_metadata(&output, &stat)?;
output.sync_all()
}
libc::S_IFLNK => {
let target = PathBuf::from(OsString::from_vec(source_root.readlink(source)?));
destination_root.symlink(&target, destination)?;
destination_root.set_symlink_owner(
destination,
Uid::from_raw(stat.st_uid),
Gid::from_raw(stat.st_gid),
)?;
destination_root.set_symlink_times(destination, &timestamps(&stat))
}
_ => Err(io::Error::from_raw_os_error(libc::EXDEV)),
}
}
@ -448,22 +592,23 @@ fn set_metadata(file: &File, stat: &rustix::fs::Stat) -> io::Result<()> {
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),
},
},
)?;
futimens(file.as_fd(), &timestamps(stat))?;
Ok(())
}
fn timestamps(stat: &rustix::fs::Stat) -> Timestamps {
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),
},
}
}
fn remove_tree(root: &BackingRoot, path: &Path) -> io::Result<()> {
let stat = root.stat(path)?;
if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
@ -540,6 +685,7 @@ impl ObjectRecord {
&& self.modification_seconds == expected.modification_seconds
&& self.modification_nanoseconds == expected.modification_nanoseconds
&& self.digest == expected.digest
&& self.link_target == expected.link_target
}
}
@ -554,10 +700,11 @@ fn path_from_bytes(bytes: &[u8]) -> PathBuf {
#[cfg(test)]
mod tests {
use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use super::{
BackingRoot, Intent, Mode, Provenance, initialize, migrate, path_bytes, path_from_bytes,
persist_intent, scan_tree,
BackingRoot, Intent, Mode, ObjectStamp, Provenance, copy_tree, initialize, migrate,
path_bytes, path_from_bytes, persist_intent, scan_tree,
};
#[test]
@ -597,6 +744,7 @@ mod tests {
&provenance,
std::path::Path::new("target-temporary"),
std::path::Path::new("target"),
true,
)
.unwrap();
@ -653,6 +801,7 @@ mod tests {
&provenance,
std::path::Path::new("target-temporary"),
std::path::Path::new("target"),
true,
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
@ -687,6 +836,9 @@ mod tests {
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
backup: Vec::new(),
destination_existed: false,
destination_stamp: None,
manifest: scan_tree(&host, source).unwrap(),
};
persist_intent(&isolated, &intent).unwrap();
@ -711,6 +863,197 @@ mod tests {
);
}
#[test]
fn qualified_regular_file_replaces_isolated_destination() {
let fixture = Fixture::new("regular-replacement");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
host.write_file(source, b"new", Mode::from_raw_mode(0o640))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let mut provenance = Provenance::default();
provenance.record(source.to_path_buf(), &host.stat(source).unwrap());
migrate(&host, &isolated, &provenance, source, destination, true).unwrap();
assert!(host.stat(source).is_err());
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"new");
assert!(
isolated
.typed_entries(std::path::Path::new(
".lazaret-ignored-workspace-transactions"
))
.unwrap()
.is_empty()
);
assert!(
isolated
.typed_entries(std::path::Path::new(""))
.unwrap()
.into_iter()
.all(|(name, _)| !name.as_bytes().starts_with(b".lazaret-backup-"))
);
}
#[test]
fn qualified_symlink_migrates_without_host_export() {
let fixture = Fixture::new("special");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
host.symlink(
std::path::Path::new("relative-target"),
std::path::Path::new("temporary-link"),
)
.unwrap();
let mut provenance = Provenance::default();
let link = std::path::Path::new("temporary-link");
provenance.record(link.to_path_buf(), &host.stat(link).unwrap());
migrate(
&host,
&isolated,
&provenance,
link,
std::path::Path::new("isolated-link"),
true,
)
.unwrap();
assert!(host.stat(link).is_err());
assert_eq!(
isolated
.readlink(std::path::Path::new("isolated-link"))
.unwrap(),
b"relative-target"
);
}
#[test]
fn noreplace_preserves_regular_source_and_destination() {
let fixture = Fixture::new("regular-noreplace");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let mut provenance = Provenance::default();
provenance.record(source.to_path_buf(), &host.stat(source).unwrap());
let error = migrate(&host, &isolated, &provenance, source, destination, false).unwrap_err();
assert_eq!(error.raw_os_error(), Some(libc::EEXIST));
assert_eq!(host.read_file(source, 16).unwrap(), b"new");
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
}
#[test]
fn startup_restores_replaced_destination_before_publication() {
let fixture = Fixture::new("replacement-rollback");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
let quarantine = std::path::Path::new(".lazaret-quarantine-rollback");
let staging = std::path::Path::new(".lazaret-staging-rollback");
let backup = std::path::Path::new(".lazaret-backup-rollback");
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let intent = Intent {
schema: 2,
id: "rollback".to_owned(),
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
backup: path_bytes(backup),
destination_existed: true,
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
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();
copy_tree(&host, quarantine, &isolated, staging).unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
initialize(&host, &isolated).unwrap();
assert_eq!(host.read_file(source, 16).unwrap(), b"new");
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
assert!(isolated.stat(staging).is_err());
assert!(isolated.stat(backup).is_err());
}
#[test]
fn startup_completes_published_replacement() {
let fixture = Fixture::new("replacement-published");
let host = BackingRoot::open(&fixture.host).unwrap();
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
initialize(&host, &isolated).unwrap();
let source = std::path::Path::new("temporary.rdb");
let destination = std::path::Path::new("dump.rdb");
let quarantine = std::path::Path::new(".lazaret-quarantine-published");
let staging = std::path::Path::new(".lazaret-staging-published");
let backup = std::path::Path::new(".lazaret-backup-published");
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
.unwrap();
isolated
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
.unwrap();
let intent = Intent {
schema: 2,
id: "published".to_owned(),
source: path_bytes(source),
destination: path_bytes(destination),
quarantine: path_bytes(quarantine),
staging: path_bytes(staging),
backup: path_bytes(backup),
destination_existed: true,
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
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();
copy_tree(&host, quarantine, &isolated, staging).unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
isolated
.rename(staging, destination, rustix::fs::RenameFlags::NOREPLACE)
.unwrap();
isolated.sync_directory(std::path::Path::new("")).unwrap();
initialize(&host, &isolated).unwrap();
assert!(host.stat(source).is_err());
assert!(host.stat(quarantine).is_err());
assert!(isolated.stat(backup).is_err());
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"new");
}
struct Fixture {
root: std::path::PathBuf,
host: std::path::PathBuf,