Generalize safe host-to-isolated rename migration
This commit is contained in:
parent
aaee63a6e3
commit
35ab7f17c0
13 changed files with 589 additions and 81 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1787,6 +1787,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"xattr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ tar = "=0.4.46"
|
||||||
time = "=0.3.53"
|
time = "=0.3.53"
|
||||||
tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] }
|
tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] }
|
||||||
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
|
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
|
||||||
|
xattr = "=1.6.1"
|
||||||
x509-parser = "=0.18.1"
|
x509-parser = "=0.18.1"
|
||||||
zstd = "=0.13.3"
|
zstd = "=0.13.3"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ rustix.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
xattr.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,15 @@ use std::fs::File;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::fd::AsFd;
|
use std::os::fd::{AsFd, AsRawFd};
|
||||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
use std::path::{Component, Path};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use rustix::fs::{
|
use rustix::fs::{
|
||||||
AtFlags, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, Stat, Timestamps, linkat, mkdirat,
|
AtFlags, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, Stat, Timestamps, chownat, linkat,
|
||||||
openat, openat2, readlinkat, renameat_with, statat, symlinkat, unlinkat, utimensat,
|
mkdirat, openat, openat2, readlinkat, renameat_with, statat, symlinkat, unlinkat, utimensat,
|
||||||
};
|
};
|
||||||
|
use rustix::process::{Gid, Uid};
|
||||||
|
|
||||||
const RESOLVE: ResolveFlags = ResolveFlags::BENEATH
|
const RESOLVE: ResolveFlags = ResolveFlags::BENEATH
|
||||||
.union(ResolveFlags::NO_MAGICLINKS)
|
.union(ResolveFlags::NO_MAGICLINKS)
|
||||||
|
|
@ -196,6 +197,14 @@ impl BackingRoot {
|
||||||
Ok(readlinkat(parent.as_fd(), name, Vec::new())?.into_bytes())
|
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(
|
pub(crate) fn set_symlink_times(
|
||||||
&self,
|
&self,
|
||||||
relative: &Path,
|
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>> {
|
pub(crate) fn entries(&self, relative: &Path) -> io::Result<Vec<OsString>> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.typed_entries(relative)?
|
.typed_entries(relative)?
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,6 @@ impl Filesystem for IgnoredWorkspace {
|
||||||
if source_entry.backing != destination_backing {
|
if source_entry.backing != destination_backing {
|
||||||
let supported_flags =
|
let supported_flags =
|
||||||
flags.is_empty() || flags == FuseRenameFlags::RENAME_NOREPLACE;
|
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
|
if source_entry.backing != Classification::Host
|
||||||
|| destination_backing != Classification::Isolated
|
|| destination_backing != Classification::Isolated
|
||||||
{
|
{
|
||||||
|
|
@ -364,10 +363,13 @@ impl Filesystem for IgnoredWorkspace {
|
||||||
);
|
);
|
||||||
return Err(io::Error::from_raw_os_error(libc::EXDEV));
|
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!(
|
let _ = writeln!(
|
||||||
io::stderr().lock(),
|
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));
|
return Err(io::Error::from_raw_os_error(libc::EXDEV));
|
||||||
}
|
}
|
||||||
|
|
@ -388,6 +390,7 @@ impl Filesystem for IgnoredWorkspace {
|
||||||
&provenance,
|
&provenance,
|
||||||
&source,
|
&source,
|
||||||
&destination,
|
&destination,
|
||||||
|
flags.is_empty(),
|
||||||
)?;
|
)?;
|
||||||
provenance.remove_tree(&source);
|
provenance.remove_tree(&source);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -492,6 +495,10 @@ impl Filesystem for IgnoredWorkspace {
|
||||||
_lock_owner: Option<LockOwner>,
|
_lock_owner: Option<LockOwner>,
|
||||||
reply: ReplyWrite,
|
reply: ReplyWrite,
|
||||||
) {
|
) {
|
||||||
|
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| {
|
||||||
open.migration_quiesced.store(false, Ordering::Release);
|
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 {
|
||||||
|
|
@ -1005,6 +1012,10 @@ impl Filesystem for IgnoredWorkspace {
|
||||||
reply.error(Errno::EINVAL);
|
reply.error(Errno::EINVAL);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let _namespace = self
|
||||||
|
.namespace
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let files = self
|
let files = self
|
||||||
.files
|
.files
|
||||||
.lock()
|
.lock()
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,75 @@ fn physical_router_migrates_cargo_target_only_toward_isolated_state() {
|
||||||
session.umount_and_join().unwrap();
|
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) {
|
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();
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions";
|
||||||
const INTENT_SUFFIX: &str = ".json";
|
const INTENT_SUFFIX: &str = ".json";
|
||||||
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub(crate) struct ObjectStamp {
|
pub(crate) struct ObjectStamp {
|
||||||
device: u64,
|
device: u64,
|
||||||
inode: u64,
|
inode: u64,
|
||||||
|
|
@ -44,6 +44,10 @@ impl ObjectStamp {
|
||||||
change_nanoseconds: stat.st_ctime_nsec,
|
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)]
|
#[derive(Default)]
|
||||||
|
|
@ -99,6 +103,12 @@ struct Intent {
|
||||||
destination: Vec<u8>,
|
destination: Vec<u8>,
|
||||||
quarantine: Vec<u8>,
|
quarantine: Vec<u8>,
|
||||||
staging: Vec<u8>,
|
staging: Vec<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
backup: Vec<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
destination_existed: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
destination_stamp: Option<ObjectStamp>,
|
||||||
manifest: Vec<ObjectRecord>,
|
manifest: Vec<ObjectRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,12 +127,15 @@ struct ObjectRecord {
|
||||||
change_seconds: i64,
|
change_seconds: i64,
|
||||||
change_nanoseconds: u64,
|
change_nanoseconds: u64,
|
||||||
digest: Option<[u8; 32]>,
|
digest: Option<[u8; 32]>,
|
||||||
|
#[serde(default)]
|
||||||
|
link_target: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
enum ObjectKind {
|
enum ObjectKind {
|
||||||
Directory,
|
Directory,
|
||||||
Regular,
|
Regular,
|
||||||
|
Symlink,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn reserved(path: &Path) -> bool {
|
pub(crate) fn reserved(path: &Path) -> bool {
|
||||||
|
|
@ -147,8 +160,10 @@ pub(crate) fn migrate(
|
||||||
provenance: &Provenance,
|
provenance: &Provenance,
|
||||||
source: &Path,
|
source: &Path,
|
||||||
destination: &Path,
|
destination: &Path,
|
||||||
|
replace_existing: bool,
|
||||||
) -> io::Result<()> {
|
) -> 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));
|
return Err(io::Error::from_raw_os_error(libc::EEXIST));
|
||||||
}
|
}
|
||||||
let manifest = match scan_tree(host, source) {
|
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));
|
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| {
|
if let Some(record) = manifest.iter().find(|record| {
|
||||||
let path = source.join(path_from_bytes(&record.path));
|
let path = source.join(path_from_bytes(&record.path));
|
||||||
!provenance.matches(&path, &record.stamp())
|
!provenance.matches(&path, &record.stamp())
|
||||||
|
|
@ -189,13 +207,17 @@ pub(crate) fn migrate(
|
||||||
let destination_parent = destination.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 quarantine = source_parent.join(format!(".lazaret-quarantine-{id}"));
|
||||||
let staging = destination_parent.join(format!(".lazaret-staging-{id}"));
|
let staging = destination_parent.join(format!(".lazaret-staging-{id}"));
|
||||||
|
let backup = destination_parent.join(format!(".lazaret-backup-{id}"));
|
||||||
let intent = Intent {
|
let intent = Intent {
|
||||||
schema: 1,
|
schema: 3,
|
||||||
id,
|
id,
|
||||||
source: path_bytes(source),
|
source: path_bytes(source),
|
||||||
destination: path_bytes(destination),
|
destination: path_bytes(destination),
|
||||||
quarantine: path_bytes(&quarantine),
|
quarantine: path_bytes(&quarantine),
|
||||||
staging: path_bytes(&staging),
|
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,
|
manifest,
|
||||||
};
|
};
|
||||||
if let Err(error) = persist_intent(isolated, &intent) {
|
if let Err(error) = persist_intent(isolated, &intent) {
|
||||||
|
|
@ -214,12 +236,20 @@ pub(crate) fn migrate(
|
||||||
isolated.sync_directory(destination_parent)?;
|
isolated.sync_directory(destination_parent)?;
|
||||||
require_exact_tree(host, &quarantine, &intent.manifest)?;
|
require_exact_tree(host, &quarantine, &intent.manifest)?;
|
||||||
require_equivalent_tree(isolated, &staging, &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.rename(&staging, destination, RenameFlags::NOREPLACE)?;
|
||||||
isolated.sync_directory(destination_parent)?;
|
isolated.sync_directory(destination_parent)?;
|
||||||
require_equivalent_tree(isolated, destination, &intent.manifest)?;
|
require_equivalent_tree(isolated, destination, &intent.manifest)?;
|
||||||
require_exact_tree(host, &quarantine, &intent.manifest)?;
|
require_exact_tree(host, &quarantine, &intent.manifest)?;
|
||||||
remove_tree(host, &quarantine)?;
|
remove_tree(host, &quarantine)?;
|
||||||
host.sync_directory(source_parent)?;
|
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)
|
clear_intent(isolated, &intent)
|
||||||
})();
|
})();
|
||||||
if let Err(error) = result {
|
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 bytes = isolated.read_file(&path, 16 * 1024 * 1024)?;
|
||||||
let intent: Intent = serde_json::from_slice(&bytes)
|
let intent: Intent = serde_json::from_slice(&bytes)
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
.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));
|
return Err(io::Error::from_raw_os_error(libc::EIO));
|
||||||
}
|
}
|
||||||
reconcile(host, isolated, &intent)?;
|
reconcile(host, isolated, &intent)?;
|
||||||
|
|
@ -270,11 +304,21 @@ fn reconcile(
|
||||||
let destination = path_from_bytes(&intent.destination);
|
let destination = path_from_bytes(&intent.destination);
|
||||||
let quarantine = path_from_bytes(&intent.quarantine);
|
let quarantine = path_from_bytes(&intent.quarantine);
|
||||||
let staging = path_from_bytes(&intent.staging);
|
let staging = path_from_bytes(&intent.staging);
|
||||||
let source_exists = host.stat(&source).is_ok();
|
let backup = intent_backup(intent, &destination);
|
||||||
let quarantine_exists = host.stat(&quarantine).is_ok();
|
let source_exists = stat_if_exists(host, &source)?.is_some();
|
||||||
let destination_exists = isolated.stat(&destination).is_ok();
|
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 {
|
if destination_exists {
|
||||||
|
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)?;
|
require_equivalent_tree(isolated, &destination, &intent.manifest)?;
|
||||||
if quarantine_exists {
|
if quarantine_exists {
|
||||||
require_exact_tree(host, &quarantine, &intent.manifest)?;
|
require_exact_tree(host, &quarantine, &intent.manifest)?;
|
||||||
|
|
@ -284,18 +328,41 @@ fn reconcile(
|
||||||
if source_exists {
|
if source_exists {
|
||||||
return Err(io::Error::from_raw_os_error(libc::EIO));
|
return Err(io::Error::from_raw_os_error(libc::EIO));
|
||||||
}
|
}
|
||||||
if isolated.stat(&staging).is_ok() {
|
if stat_if_exists(isolated, &staging)?.is_some() {
|
||||||
remove_tree(isolated, &staging)?;
|
remove_tree(isolated, &staging)?;
|
||||||
isolated.sync_directory(staging.parent().unwrap_or_else(|| Path::new("")))?;
|
|
||||||
}
|
}
|
||||||
|
if backup_exists {
|
||||||
|
remove_tree(isolated, &backup)?;
|
||||||
|
}
|
||||||
|
isolated.sync_directory(staging.parent().unwrap_or_else(|| Path::new("")))?;
|
||||||
clear_intent(isolated, intent)?;
|
clear_intent(isolated, intent)?;
|
||||||
return Ok(Reconciled::Isolated);
|
return Ok(Reconciled::Isolated);
|
||||||
}
|
}
|
||||||
|
if !intent.destination_existed || backup_exists {
|
||||||
|
return Err(io::Error::from_raw_os_error(libc::EIO));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isolated.stat(&staging).is_ok() {
|
if stat_if_exists(isolated, &staging)?.is_some() {
|
||||||
remove_tree(isolated, &staging)?;
|
remove_tree(isolated, &staging)?;
|
||||||
isolated.sync_directory(staging.parent().unwrap_or_else(|| Path::new("")))?;
|
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) {
|
match (source_exists, quarantine_exists) {
|
||||||
(true, false) => {
|
(true, false) => {
|
||||||
require_exact_tree(host, &source, &intent.manifest)?;
|
require_exact_tree(host, &source, &intent.manifest)?;
|
||||||
|
|
@ -311,6 +378,43 @@ fn reconcile(
|
||||||
Ok(Reconciled::Host)
|
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<()> {
|
fn persist_intent(isolated: &BackingRoot, intent: &Intent) -> io::Result<()> {
|
||||||
let final_path = intent_path(intent);
|
let final_path = intent_path(intent);
|
||||||
let temporary_path = Path::new(TRANSACTIONS).join(format!("{}.tmp", intent.id));
|
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>> {
|
fn scan_tree(root: &BackingRoot, tree: &Path) -> io::Result<Vec<ObjectRecord>> {
|
||||||
let mut records = Vec::new();
|
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));
|
records.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
@ -340,6 +445,7 @@ fn scan_entry(
|
||||||
root: &BackingRoot,
|
root: &BackingRoot,
|
||||||
tree: &Path,
|
tree: &Path,
|
||||||
relative: &Path,
|
relative: &Path,
|
||||||
|
device: u64,
|
||||||
records: &mut Vec<ObjectRecord>,
|
records: &mut Vec<ObjectRecord>,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let path = if relative.as_os_str().is_empty() {
|
let path = if relative.as_os_str().is_empty() {
|
||||||
|
|
@ -348,26 +454,35 @@ fn scan_entry(
|
||||||
tree.join(relative)
|
tree.join(relative)
|
||||||
};
|
};
|
||||||
let stat = root.stat(&path)?;
|
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 {
|
let kind = match stat.st_mode & libc::S_IFMT {
|
||||||
libc::S_IFDIR => ObjectKind::Directory,
|
libc::S_IFDIR => ObjectKind::Directory,
|
||||||
libc::S_IFREG if stat.st_nlink == 1 => ObjectKind::Regular,
|
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)),
|
_ => return Err(io::Error::from_raw_os_error(libc::EXDEV)),
|
||||||
};
|
};
|
||||||
let file = root.open_entry(&path, OFlags::RDONLY | OFlags::NONBLOCK, Mode::empty())?;
|
let file = if kind == ObjectKind::Symlink {
|
||||||
let mut xattrs = vec![0; 64 * 1024];
|
if root.symlink_xattrs(&path)?.into_iter().any(|name| {
|
||||||
let xattr_length = flistxattr(file.as_fd(), &mut xattrs)?;
|
let name = name.as_bytes();
|
||||||
xattrs.truncate(xattr_length);
|
unsupported_xattr_name(name)
|
||||||
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));
|
return Err(io::Error::from_raw_os_error(libc::EXDEV));
|
||||||
}
|
}
|
||||||
let digest = if kind == ObjectKind::Regular {
|
None
|
||||||
Some(digest(file)?)
|
} 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 = match (kind, file) {
|
||||||
|
(ObjectKind::Regular, Some(file)) => Some(digest(file)?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let link_target = if kind == ObjectKind::Symlink {
|
||||||
|
Some(root.readlink(&path)?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
@ -385,15 +500,34 @@ fn scan_entry(
|
||||||
change_seconds: stat.st_ctime,
|
change_seconds: stat.st_ctime,
|
||||||
change_nanoseconds: stat.st_ctime_nsec,
|
change_nanoseconds: stat.st_ctime_nsec,
|
||||||
digest,
|
digest,
|
||||||
|
link_target,
|
||||||
});
|
});
|
||||||
if kind == ObjectKind::Directory {
|
if kind == ObjectKind::Directory {
|
||||||
for (name, _) in root.typed_entries(&path)? {
|
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(())
|
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]> {
|
fn digest(mut file: File) -> io::Result<[u8; 32]> {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
let mut buffer = vec![0; 64 * 1024];
|
let mut buffer = vec![0; 64 * 1024];
|
||||||
|
|
@ -437,6 +571,16 @@ fn copy_tree(
|
||||||
set_metadata(&output, &stat)?;
|
set_metadata(&output, &stat)?;
|
||||||
output.sync_all()
|
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)),
|
_ => Err(io::Error::from_raw_os_error(libc::EXDEV)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -448,9 +592,12 @@ fn set_metadata(file: &File, stat: &rustix::fs::Stat) -> io::Result<()> {
|
||||||
Some(Uid::from_raw(stat.st_uid)),
|
Some(Uid::from_raw(stat.st_uid)),
|
||||||
Some(Gid::from_raw(stat.st_gid)),
|
Some(Gid::from_raw(stat.st_gid)),
|
||||||
)?;
|
)?;
|
||||||
futimens(
|
futimens(file.as_fd(), ×tamps(stat))?;
|
||||||
file.as_fd(),
|
Ok(())
|
||||||
&Timestamps {
|
}
|
||||||
|
|
||||||
|
fn timestamps(stat: &rustix::fs::Stat) -> Timestamps {
|
||||||
|
Timestamps {
|
||||||
last_access: Timespec {
|
last_access: Timespec {
|
||||||
tv_sec: stat.st_atime,
|
tv_sec: stat.st_atime,
|
||||||
tv_nsec: i64::try_from(stat.st_atime_nsec).unwrap_or(999_999_999),
|
tv_nsec: i64::try_from(stat.st_atime_nsec).unwrap_or(999_999_999),
|
||||||
|
|
@ -459,9 +606,7 @@ fn set_metadata(file: &File, stat: &rustix::fs::Stat) -> io::Result<()> {
|
||||||
tv_sec: stat.st_mtime,
|
tv_sec: stat.st_mtime,
|
||||||
tv_nsec: i64::try_from(stat.st_mtime_nsec).unwrap_or(999_999_999),
|
tv_nsec: i64::try_from(stat.st_mtime_nsec).unwrap_or(999_999_999),
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_tree(root: &BackingRoot, path: &Path) -> io::Result<()> {
|
fn remove_tree(root: &BackingRoot, path: &Path) -> io::Result<()> {
|
||||||
|
|
@ -540,6 +685,7 @@ impl ObjectRecord {
|
||||||
&& self.modification_seconds == expected.modification_seconds
|
&& self.modification_seconds == expected.modification_seconds
|
||||||
&& self.modification_nanoseconds == expected.modification_nanoseconds
|
&& self.modification_nanoseconds == expected.modification_nanoseconds
|
||||||
&& self.digest == expected.digest
|
&& self.digest == expected.digest
|
||||||
|
&& self.link_target == expected.link_target
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,10 +700,11 @@ fn path_from_bytes(bytes: &[u8]) -> PathBuf {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BackingRoot, Intent, Mode, Provenance, initialize, migrate, path_bytes, path_from_bytes,
|
BackingRoot, Intent, Mode, ObjectStamp, Provenance, copy_tree, initialize, migrate,
|
||||||
persist_intent, scan_tree,
|
path_bytes, path_from_bytes, persist_intent, scan_tree,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -597,6 +744,7 @@ mod tests {
|
||||||
&provenance,
|
&provenance,
|
||||||
std::path::Path::new("target-temporary"),
|
std::path::Path::new("target-temporary"),
|
||||||
std::path::Path::new("target"),
|
std::path::Path::new("target"),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -653,6 +801,7 @@ mod tests {
|
||||||
&provenance,
|
&provenance,
|
||||||
std::path::Path::new("target-temporary"),
|
std::path::Path::new("target-temporary"),
|
||||||
std::path::Path::new("target"),
|
std::path::Path::new("target"),
|
||||||
|
true,
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
|
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
|
||||||
|
|
@ -687,6 +836,9 @@ mod tests {
|
||||||
destination: path_bytes(destination),
|
destination: path_bytes(destination),
|
||||||
quarantine: path_bytes(quarantine),
|
quarantine: path_bytes(quarantine),
|
||||||
staging: path_bytes(staging),
|
staging: path_bytes(staging),
|
||||||
|
backup: Vec::new(),
|
||||||
|
destination_existed: false,
|
||||||
|
destination_stamp: None,
|
||||||
manifest: scan_tree(&host, source).unwrap(),
|
manifest: scan_tree(&host, source).unwrap(),
|
||||||
};
|
};
|
||||||
persist_intent(&isolated, &intent).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 {
|
struct Fixture {
|
||||||
root: std::path::PathBuf,
|
root: std::path::PathBuf,
|
||||||
host: std::path::PathBuf,
|
host: std::path::PathBuf,
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,13 @@ use validation::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
||||||
pub const RUNSC_BUILD_ID: &str = "b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971";
|
pub const RUNSC_BUILD_ID: &str = "5b441d02d40792cfe228b294fb688a80cf6161fb6876d49ef03138618ae2562e";
|
||||||
pub const RUNSC_BUILD_ID_BYTES: [u8; 32] = [
|
pub const RUNSC_BUILD_ID_BYTES: [u8; 32] = [
|
||||||
185, 173, 111, 147, 168, 232, 144, 181, 147, 80, 97, 88, 43, 26, 18, 134, 130, 243, 253, 65,
|
91, 68, 29, 2, 212, 7, 146, 207, 226, 40, 178, 148, 251, 104, 138, 128, 207, 97, 97, 251, 104,
|
||||||
200, 5, 60, 84, 208, 228, 79, 244, 93, 128, 153, 113,
|
118, 212, 158, 240, 49, 56, 97, 138, 226, 86, 46,
|
||||||
];
|
];
|
||||||
pub const RUNSC_EXECUTABLE_NAME: &str =
|
pub const RUNSC_EXECUTABLE_NAME: &str =
|
||||||
"lazaret-runsc-b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971";
|
"lazaret-runsc-5b441d02d40792cfe228b294fb688a80cf6161fb6876d49ef03138618ae2562e";
|
||||||
pub const DOMAIN_OPEN_FILE_LIMIT: u64 = 524_288;
|
pub const DOMAIN_OPEN_FILE_LIMIT: u64 = 524_288;
|
||||||
|
|
||||||
const OCI_VERSION: &str = "1.2.1";
|
const OCI_VERSION: &str = "1.2.1";
|
||||||
|
|
|
||||||
|
|
@ -745,14 +745,27 @@ error behavior. An isolated-to-host rename, cross-backing hard link,
|
||||||
host-to-isolated class returns `EXDEV` before either backing is changed.
|
host-to-isolated class returns `EXDEV` before either backing is changed.
|
||||||
`RENAME_WHITEOUT` returns `EOPNOTSUPP` without mutation.
|
`RENAME_WHITEOUT` returns `EOPNOTSUPP` without mutation.
|
||||||
|
|
||||||
A plain or `RENAME_NOREPLACE` host-to-isolated directory rename is qualified
|
A plain or `RENAME_NOREPLACE` host-to-isolated rename is qualified when the
|
||||||
only when the destination is absent and the complete source tree was created
|
complete source was created through the current router instance, every retained
|
||||||
through the current router instance. Every retained source identity must still
|
source identity still matches, and the source has defined copy semantics. The
|
||||||
match, and every descendant must be a regular file or directory with supported
|
copyable set is single-link regular files, symbolic links, and trees containing
|
||||||
metadata. A writable description beneath the source must be read-only or
|
only those objects and directories with supported metadata. FIFOs are not
|
||||||
quiesced by a completed flush; any later mutation clears quiescence. Pinned
|
copyable because their live endpoint state is not observable through the FUSE
|
||||||
read-only file and directory descriptions remain attached to their original
|
protocol. Hard-linked regular files, sockets, devices, and mounted trees are not
|
||||||
host objects. Host mutation detected before quarantine refuses the rename.
|
copyable.
|
||||||
|
The destination may be absent or, for a plain rename, an existing object that
|
||||||
|
the backing filesystem permits native rename to replace. `RENAME_NOREPLACE`
|
||||||
|
returns `EEXIST` for an existing destination. A directory may replace only an
|
||||||
|
empty directory; directory and nondirectory mismatches retain their native
|
||||||
|
errors.
|
||||||
|
|
||||||
|
A writable description beneath the source must be read-only or quiesced by a
|
||||||
|
completed flush; any later mutation clears quiescence. The gVisor composition
|
||||||
|
uses per-description handles for the routed mount, so closing a guest file
|
||||||
|
description releases its router handle even while pathname dentries remain
|
||||||
|
cached. 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
|
A qualified rename is a durable migration transaction rather than a
|
||||||
cross-filesystem `rename(2)`. The router records and synchronizes intent before
|
cross-filesystem `rename(2)`. The router records and synchronizes intent before
|
||||||
|
|
@ -760,18 +773,21 @@ renaming the source to a reserved same-backing host quarantine. It synchronizes
|
||||||
that parent, copies through descriptor-confined traversal into a reserved
|
that parent, copies through descriptor-confined traversal into a reserved
|
||||||
isolated staging tree, verifies the staged tree and quarantined source, and
|
isolated staging tree, verifies the staged tree and quarantined source, and
|
||||||
synchronizes the isolated tree before publishing it with a same-backing rename.
|
synchronizes the isolated tree before publishing it with a same-backing rename.
|
||||||
Only a qualified isolated destination permits deletion of the exact
|
For replacement, it first moves the exact old isolated destination to a
|
||||||
quarantined tree. The router synchronizes the host parent before clearing
|
same-backing backup and synchronizes the destination parent. Only a qualified
|
||||||
intent.
|
isolated destination permits deletion of the exact quarantined tree and backup.
|
||||||
|
The router synchronizes both affected parents before clearing intent.
|
||||||
|
|
||||||
The affected paths do not admit new observations while the transaction is
|
The affected paths do not admit new observations while the transaction is
|
||||||
unresolved. Startup reconciles every retained intent before publishing router
|
unresolved. Startup reconciles every retained intent before publishing router
|
||||||
readiness. Recovery exposes either the complete host source or the qualified
|
readiness. Recovery exposes either the complete host source or the qualified
|
||||||
isolated destination; partial staging and changed quarantine remain
|
isolated destination; partial staging and changed quarantine remain
|
||||||
unpublished and fail closed. The transaction never copies isolated bytes into
|
unpublished and fail closed. Before publication, recovery restores an exact old
|
||||||
the host backing. Its success refines the sandbox namespace to one complete
|
destination from its backup; after publication, it retains only the qualified
|
||||||
endpoint but does not promise cross-filesystem inode identity or atomic
|
new destination. The transaction never copies isolated bytes into the host
|
||||||
observation by host processes that bypass FUSE.
|
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
|
All other cross-backing renames retain `EXDEV` behavior. Applications may react
|
||||||
to `EXDEV` with their own visible copy and unlink operations.
|
to `EXDEV` with their own visible copy and unlink operations.
|
||||||
|
|
|
||||||
28
patches/gvisor-9f8f6c917-per-description-file-handles.patch
Normal file
28
patches/gvisor-9f8f6c917-per-description-file-handles.patch
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
diff --git a/runsc/boot/vfs.go b/runsc/boot/vfs.go
|
||||||
|
index b34854d4c..b250b78c8 100644
|
||||||
|
--- a/runsc/boot/vfs.go
|
||||||
|
+++ b/runsc/boot/vfs.go
|
||||||
|
@@ -330,6 +330,9 @@ func goferMountData(fd int, fa config.FileAccessType, conf *config.Config, suppre
|
||||||
|
if fa == config.FileAccessShared {
|
||||||
|
opts = append(opts, "cache=remote_revalidating")
|
||||||
|
}
|
||||||
|
+ if suppressDirectFS {
|
||||||
|
+ opts = append(opts, "disable_file_handle_sharing")
|
||||||
|
+ }
|
||||||
|
if conf.DirectFS && !suppressDirectFS {
|
||||||
|
opts = append(opts, "directfs")
|
||||||
|
}
|
||||||
|
diff --git a/runsc/boot/vfs_test.go b/runsc/boot/vfs_test.go
|
||||||
|
index 17ec62bdd..31a103d28 100644
|
||||||
|
--- a/runsc/boot/vfs_test.go
|
||||||
|
+++ b/runsc/boot/vfs_test.go
|
||||||
|
@@ -108,6 +108,9 @@ func TestGoferMountDataDirectFS(t *testing.T) {
|
||||||
|
if gotEnabled != tc.wantEnabled {
|
||||||
|
t.Errorf("directfs option present = %t, want %t (opts=%v)", gotEnabled, tc.wantEnabled, opts)
|
||||||
|
}
|
||||||
|
+ if got := slices.Contains(opts, "disable_file_handle_sharing"); got != tc.suppressDirectFS {
|
||||||
|
+ t.Errorf("disable_file_handle_sharing option present = %t, want %t (opts=%v)", got, tc.suppressDirectFS, opts)
|
||||||
|
+ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import importlib.machinery
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
@ -406,7 +407,7 @@ class VerificationTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_runsc_materializer_and_install_identity_are_exact(self) -> None:
|
def test_runsc_materializer_and_install_identity_are_exact(self) -> None:
|
||||||
build_id = "b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971"
|
build_id = "5b441d02d40792cfe228b294fb688a80cf6161fb6876d49ef03138618ae2562e"
|
||||||
materializer = (ROOT / "tools/materialize-runsc").read_text()
|
materializer = (ROOT / "tools/materialize-runsc").read_text()
|
||||||
installer = (ROOT / "tools/install-to-host").read_text()
|
installer = (ROOT / "tools/install-to-host").read_text()
|
||||||
runtime = (ROOT / "crates/lazaret-runtime-supervisor/src/sandbox.rs").read_text()
|
runtime = (ROOT / "crates/lazaret-runtime-supervisor/src/sandbox.rs").read_text()
|
||||||
|
|
@ -416,6 +417,14 @@ class VerificationTests(unittest.TestCase):
|
||||||
self.assertIn(f"build_id={build_id}", materializer)
|
self.assertIn(f"build_id={build_id}", materializer)
|
||||||
self.assertIn(f"runsc_build_id={build_id}", installer)
|
self.assertIn(f"runsc_build_id={build_id}", installer)
|
||||||
self.assertIn(f'"lazaret-runsc-{build_id}"', runtime)
|
self.assertIn(f'"lazaret-runsc-{build_id}"', runtime)
|
||||||
|
encoded = re.search(
|
||||||
|
r"RUNSC_BUILD_ID_BYTES: \[u8; 32\] = \[(.*?)\];", runtime, re.DOTALL
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(encoded)
|
||||||
|
self.assertEqual(
|
||||||
|
bytes(int(value.strip()) for value in encoded.group(1).split(",") if value.strip()),
|
||||||
|
bytes.fromhex(build_id),
|
||||||
|
)
|
||||||
self.assertIn("cmp --silent", installer)
|
self.assertIn("cmp --silent", installer)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
(ROOT / "patches/gvisor-9f8f6c917-directory-handles.patch").is_file()
|
(ROOT / "patches/gvisor-9f8f6c917-directory-handles.patch").is_file()
|
||||||
|
|
@ -423,6 +432,12 @@ class VerificationTests(unittest.TestCase):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
(ROOT / "patches/gvisor-9f8f6c917-private-ownership.patch").is_file()
|
(ROOT / "patches/gvisor-9f8f6c917-private-ownership.patch").is_file()
|
||||||
)
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
(
|
||||||
|
ROOT
|
||||||
|
/ "patches/gvisor-9f8f6c917-per-description-file-handles.patch"
|
||||||
|
).is_file()
|
||||||
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
(ROOT / "patches/gvisor-9f8f6c917-MODULE.bazel.lock").is_file()
|
(ROOT / "patches/gvisor-9f8f6c917-MODULE.bazel.lock").is_file()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ if readelf -l "$sandbox_init" | grep -q 'Requesting program interpreter' ||
|
||||||
fi
|
fi
|
||||||
install -m 755 "$sandbox_init" "$install_dir/lazaret-sandbox-init"
|
install -m 755 "$sandbox_init" "$install_dir/lazaret-sandbox-init"
|
||||||
|
|
||||||
runsc_build_id=b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971
|
runsc_build_id=5b441d02d40792cfe228b294fb688a80cf6161fb6876d49ef03138618ae2562e
|
||||||
runsc_name="lazaret-runsc-$runsc_build_id"
|
runsc_name="lazaret-runsc-$runsc_build_id"
|
||||||
runsc_source="$root/.lazaret/bin/$runsc_name"
|
runsc_source="$root/.lazaret/bin/$runsc_name"
|
||||||
runsc_destination="$install_dir/$runsc_name"
|
runsc_destination="$install_dir/$runsc_name"
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,18 @@ archive_checksum=9d96426115f1bf54ef09ba41964c73dc3c809743519c8442016b8dd3fce0889
|
||||||
patch_files=(
|
patch_files=(
|
||||||
"$root/patches/gvisor-9f8f6c917-directory-handles.patch"
|
"$root/patches/gvisor-9f8f6c917-directory-handles.patch"
|
||||||
"$root/patches/gvisor-9f8f6c917-private-ownership.patch"
|
"$root/patches/gvisor-9f8f6c917-private-ownership.patch"
|
||||||
|
"$root/patches/gvisor-9f8f6c917-per-description-file-handles.patch"
|
||||||
)
|
)
|
||||||
patch_checksums=(
|
patch_checksums=(
|
||||||
541420eece9ec7efbef156bdb726744ead6b4d991d41c5560f4fdfe83fb2357a
|
541420eece9ec7efbef156bdb726744ead6b4d991d41c5560f4fdfe83fb2357a
|
||||||
79a8c98502d3bb7c254ffb0885dbd26afffac227d04a0091ff91383df0842561
|
79a8c98502d3bb7c254ffb0885dbd26afffac227d04a0091ff91383df0842561
|
||||||
|
2403efb81df3185145d18ebdf8d511252fbc2fb414bae4746d7fe471ec4ea9d9
|
||||||
)
|
)
|
||||||
patch_digest=5622a44af2d9e7561f5372844dc21cf6fb93b4fcd3fe70da7f545d403a84c612
|
patch_digest=cb9126927fc45e4b0c4ec82e65b4244472b1427d850477432b62183b40e7443f
|
||||||
bazel_version=8.3.1
|
bazel_version=8.3.1
|
||||||
module_lock="$root/patches/gvisor-9f8f6c917-MODULE.bazel.lock"
|
module_lock="$root/patches/gvisor-9f8f6c917-MODULE.bazel.lock"
|
||||||
module_checksum=8402c7beb4baf2c666f4b78e400ea3f15514b56117598c2cb5411d6a35208d34
|
module_checksum=8402c7beb4baf2c666f4b78e400ea3f15514b56117598c2cb5411d6a35208d34
|
||||||
build_id=b9ad6f93a8e890b5935061582b1a128682f3fd41c8053c54d0e44ff45d809971
|
build_id=5b441d02d40792cfe228b294fb688a80cf6161fb6876d49ef03138618ae2562e
|
||||||
archive="$root/.lazaret/cache/gvisor-$revision.tar.gz"
|
archive="$root/.lazaret/cache/gvisor-$revision.tar.gz"
|
||||||
source="$root/.lazaret/vendor/gvisor-$build_id"
|
source="$root/.lazaret/vendor/gvisor-$build_id"
|
||||||
binary="$root/.lazaret/bin/lazaret-runsc-$build_id"
|
binary="$root/.lazaret/bin/lazaret-runsc-$build_id"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue