Generalize safe host-to-isolated rename migration
This commit is contained in:
parent
aaee63a6e3
commit
35ab7f17c0
5 changed files with 589 additions and 81 deletions
|
|
@ -16,6 +16,7 @@ rustix.workspace = true
|
|||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
xattr.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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, ×tamps(&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(), ×tamps(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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue