Preserve hardlink topology during startup routing
This commit is contained in:
parent
52c8c56e4f
commit
394fb65c86
7 changed files with 954 additions and 363 deletions
|
|
@ -110,13 +110,13 @@ struct SnapshotInner {
|
||||||
record: PolicyRecord,
|
record: PolicyRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub(crate) struct PolicyRecord {
|
pub(crate) struct PolicyRecord {
|
||||||
schema: u32,
|
schema: u32,
|
||||||
repositories: Vec<RepositoryRecord>,
|
repositories: Vec<RepositoryRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
struct RepositoryRecord {
|
struct RepositoryRecord {
|
||||||
prefix: Vec<u8>,
|
prefix: Vec<u8>,
|
||||||
tracked: Vec<Vec<u8>>,
|
tracked: Vec<Vec<u8>>,
|
||||||
|
|
@ -124,7 +124,7 @@ struct RepositoryRecord {
|
||||||
ignore_case: bool,
|
ignore_case: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
struct PatternRecord {
|
struct PatternRecord {
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
source: Vec<u8>,
|
source: Vec<u8>,
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ impl IgnoredWorkspace {
|
||||||
let classifier =
|
let classifier =
|
||||||
GitClassifier::from_root(workspace_label, &classifier_root, global_excludes)?;
|
GitClassifier::from_root(workspace_label, &classifier_root, global_excludes)?;
|
||||||
if isolate_ignored {
|
if isolate_ignored {
|
||||||
crate::migration::admit_policy(workspace_label, &host, &isolated, &classifier)?;
|
crate::routing::admit_policy(workspace_label, &host, &isolated, &classifier)?;
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
host,
|
host,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
use std::io::{Read, Seek, Write};
|
use std::io::{Read, Seek, Write};
|
||||||
use std::os::fd::{AsFd, AsRawFd};
|
use std::os::fd::{AsFd, AsRawFd};
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use fuser::{Config, LockOwner, MountOption};
|
use fuser::{Config, LockOwner, MountOption};
|
||||||
|
|
@ -131,6 +132,62 @@ fn restart_clones_host_paths_that_become_ignored() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_clone_preserves_admitted_hardlink_topology() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
repository(&fixture.workspace);
|
||||||
|
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
|
||||||
|
|
||||||
|
fs::write(fixture.workspace.join("full-a"), b"shared").unwrap();
|
||||||
|
fs::hard_link(
|
||||||
|
fixture.workspace.join("full-a"),
|
||||||
|
fixture.workspace.join("full-b"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(fixture.workspace.join("split-a"), b"boundary").unwrap();
|
||||||
|
fs::hard_link(
|
||||||
|
fixture.workspace.join("split-a"),
|
||||||
|
fixture.workspace.join("split-b"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(fixture.workspace.join("equal-a"), b"equal").unwrap();
|
||||||
|
fs::write(fixture.workspace.join("equal-b"), b"equal").unwrap();
|
||||||
|
fs::write(
|
||||||
|
fixture.workspace.join(".gitignore"),
|
||||||
|
b"full-a\nfull-b\nsplit-a\nequal-a\nequal-b\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
drop(IgnoredWorkspace::open(&fixture.workspace, &fixture.isolated, None).unwrap());
|
||||||
|
|
||||||
|
let host_full_a = fs::metadata(fixture.workspace.join("full-a")).unwrap();
|
||||||
|
let host_full_b = fs::metadata(fixture.workspace.join("full-b")).unwrap();
|
||||||
|
let isolated_full_a = fs::metadata(fixture.isolated.join("full-a")).unwrap();
|
||||||
|
let isolated_full_b = fs::metadata(fixture.isolated.join("full-b")).unwrap();
|
||||||
|
assert_eq!(host_full_a.ino(), host_full_b.ino());
|
||||||
|
assert_eq!(isolated_full_a.ino(), isolated_full_b.ino());
|
||||||
|
assert_ne!(host_full_a.ino(), isolated_full_a.ino());
|
||||||
|
|
||||||
|
let host_split_a = fs::metadata(fixture.workspace.join("split-a")).unwrap();
|
||||||
|
let host_split_b = fs::metadata(fixture.workspace.join("split-b")).unwrap();
|
||||||
|
let isolated_split_a = fs::metadata(fixture.isolated.join("split-a")).unwrap();
|
||||||
|
assert_eq!(host_split_a.ino(), host_split_b.ino());
|
||||||
|
assert_ne!(host_split_a.ino(), isolated_split_a.ino());
|
||||||
|
assert!(!fixture.isolated.join("split-b").exists());
|
||||||
|
|
||||||
|
let isolated_equal_a = fs::metadata(fixture.isolated.join("equal-a")).unwrap();
|
||||||
|
let isolated_equal_b = fs::metadata(fixture.isolated.join("equal-b")).unwrap();
|
||||||
|
assert_ne!(isolated_equal_a.ino(), isolated_equal_b.ino());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(fixture.isolated.join("full-a")).unwrap(),
|
||||||
|
b"shared"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(fixture.isolated.join("split-a")).unwrap(),
|
||||||
|
b"boundary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restart_removes_isolated_paths_that_return_to_host_routing() {
|
fn restart_removes_isolated_paths_that_return_to_host_routing() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ mod classifier;
|
||||||
mod daemon;
|
mod daemon;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod migration;
|
mod migration;
|
||||||
|
mod routing;
|
||||||
mod sentry;
|
mod sentry;
|
||||||
|
|
||||||
pub use classifier::{Classification, ClassificationSnapshot, GitClassifier, git_common_directory};
|
pub use classifier::{Classification, ClassificationSnapshot, GitClassifier, git_common_directory};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
|
|
@ -14,12 +14,9 @@ use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::backing::BackingRoot;
|
use crate::backing::BackingRoot;
|
||||||
use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier, PolicyRecord};
|
|
||||||
|
|
||||||
const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions";
|
pub(crate) const TRANSACTIONS: &str = ".lazaret-ignored-workspace-transactions";
|
||||||
const ROUTING: &str = "routing";
|
const ROUTING: &str = "routing";
|
||||||
const ROUTING_POLICY: &str = "policy.json";
|
|
||||||
const ROUTING_POLICY_TEMPORARY: &str = "policy.tmp";
|
|
||||||
const INTENT_SUFFIX: &str = ".json";
|
const INTENT_SUFFIX: &str = ".json";
|
||||||
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
|
@ -158,44 +155,6 @@ pub(crate) fn initialize(host: &BackingRoot, isolated: &BackingRoot) -> io::Resu
|
||||||
recover(host, isolated)
|
recover(host, isolated)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn admit_policy(
|
|
||||||
workspace: &Path,
|
|
||||||
host: &BackingRoot,
|
|
||||||
isolated: &BackingRoot,
|
|
||||||
classifier: &GitClassifier,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let routing = Path::new(TRANSACTIONS).join(ROUTING);
|
|
||||||
match isolated.mkdir(&routing, Mode::from_raw_mode(0o700)) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
isolated.sync_directory(Path::new(TRANSACTIONS))?;
|
|
||||||
clean_policy_staging(isolated)?;
|
|
||||||
let previous = read_policy(isolated)?;
|
|
||||||
if let Some(previous) = previous {
|
|
||||||
let previous = GitClassifier::snapshot_from_record(workspace, previous)?;
|
|
||||||
reconcile_policy(host, isolated, &previous, &classifier.snapshot())?;
|
|
||||||
}
|
|
||||||
persist_policy(isolated, &classifier.policy_record())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clean_policy_staging(isolated: &BackingRoot) -> io::Result<()> {
|
|
||||||
let directory = Path::new(TRANSACTIONS).join(ROUTING);
|
|
||||||
for (name, kind) in isolated.typed_entries(&directory)? {
|
|
||||||
let path = directory.join(&name);
|
|
||||||
if name == OsStr::new(ROUTING_POLICY) && kind == rustix::fs::FileType::RegularFile {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if name == OsStr::new(ROUTING_POLICY_TEMPORARY) || name.as_bytes().starts_with(b"stage-") {
|
|
||||||
remove_tree(isolated, &path)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Err(io::Error::from_raw_os_error(libc::EIO));
|
|
||||||
}
|
|
||||||
isolated.sync_directory(&directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn migrate(
|
pub(crate) fn migrate(
|
||||||
host: &BackingRoot,
|
host: &BackingRoot,
|
||||||
isolated: &BackingRoot,
|
isolated: &BackingRoot,
|
||||||
|
|
@ -303,222 +262,6 @@ pub(crate) fn migrate(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_policy(isolated: &BackingRoot) -> io::Result<Option<PolicyRecord>> {
|
|
||||||
let path = Path::new(TRANSACTIONS).join(ROUTING).join(ROUTING_POLICY);
|
|
||||||
match isolated.read_file(&path, 64 * 1024 * 1024) {
|
|
||||||
Ok(bytes) => serde_json::from_slice(&bytes)
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(error) => Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_policy(isolated: &BackingRoot, policy: &PolicyRecord) -> io::Result<()> {
|
|
||||||
let directory = Path::new(TRANSACTIONS).join(ROUTING);
|
|
||||||
let temporary = directory.join(ROUTING_POLICY_TEMPORARY);
|
|
||||||
let destination = directory.join(ROUTING_POLICY);
|
|
||||||
if stat_if_exists(isolated, &temporary)?.is_some() {
|
|
||||||
isolated.unlink(&temporary, false)?;
|
|
||||||
}
|
|
||||||
let bytes = serde_json::to_vec(policy).map_err(io::Error::other)?;
|
|
||||||
isolated.write_file(&temporary, &bytes, Mode::from_raw_mode(0o600))?;
|
|
||||||
isolated.rename(&temporary, &destination, RenameFlags::empty())?;
|
|
||||||
isolated.sync_directory(&directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconcile_policy(
|
|
||||||
host: &BackingRoot,
|
|
||||||
isolated: &BackingRoot,
|
|
||||||
previous: &ClassificationSnapshot,
|
|
||||||
current: &ClassificationSnapshot,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let mut paths = BTreeMap::new();
|
|
||||||
collect_paths(host, Path::new(""), &mut paths)?;
|
|
||||||
collect_paths(isolated, Path::new(""), &mut paths)?;
|
|
||||||
paths.remove(Path::new(TRANSACTIONS));
|
|
||||||
paths.retain(|path, _| !reserved(path));
|
|
||||||
|
|
||||||
let mut transitions = paths
|
|
||||||
.into_iter()
|
|
||||||
.map(|(path, directory)| {
|
|
||||||
let old = previous.classify(&path, directory);
|
|
||||||
let new = current.classify(&path, directory);
|
|
||||||
(path, directory, old, new)
|
|
||||||
})
|
|
||||||
.filter(|(_, _, old, new)| old != new)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let expected_admissions = admission_stamps(host, &transitions)?;
|
|
||||||
|
|
||||||
transitions.sort_by_key(|(path, _, old, new)| {
|
|
||||||
let depth = path.components().count();
|
|
||||||
match (*old, *new) {
|
|
||||||
(Classification::Isolated, Classification::Host) => usize::MAX - depth,
|
|
||||||
_ => depth,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (path, directory, old, new) in transitions {
|
|
||||||
match (old, new) {
|
|
||||||
(Classification::Host, Classification::Isolated) => {
|
|
||||||
clone_admitted_path(host, isolated, &path, directory)?;
|
|
||||||
}
|
|
||||||
(Classification::Isolated, Classification::Host) => {
|
|
||||||
remove_released_path(isolated, current, &path, directory)?;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut current_paths = BTreeMap::new();
|
|
||||||
collect_paths(host, Path::new(""), &mut current_paths)?;
|
|
||||||
collect_paths(isolated, Path::new(""), &mut current_paths)?;
|
|
||||||
current_paths.retain(|path, _| !reserved(path));
|
|
||||||
let current_transitions = current_paths
|
|
||||||
.into_iter()
|
|
||||||
.map(|(path, directory)| {
|
|
||||||
let old = previous.classify(&path, directory);
|
|
||||||
let new = current.classify(&path, directory);
|
|
||||||
(path, directory, old, new)
|
|
||||||
})
|
|
||||||
.filter(|(_, _, old, new)| old != new)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if admission_stamps(host, ¤t_transitions)? != expected_admissions {
|
|
||||||
return Err(io::Error::from_raw_os_error(libc::EAGAIN));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn admission_stamps(
|
|
||||||
host: &BackingRoot,
|
|
||||||
transitions: &[(PathBuf, bool, Classification, Classification)],
|
|
||||||
) -> io::Result<BTreeMap<PathBuf, Option<ObjectStamp>>> {
|
|
||||||
transitions
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, _, old, new)| *old == Classification::Host && *new == Classification::Isolated)
|
|
||||||
.map(|(path, _, _, _)| {
|
|
||||||
stat_if_exists(host, path)
|
|
||||||
.map(|stat| (path.clone(), stat.as_ref().map(ObjectStamp::from_stat)))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_paths(
|
|
||||||
root: &BackingRoot,
|
|
||||||
directory: &Path,
|
|
||||||
paths: &mut BTreeMap<PathBuf, bool>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
for (name, kind) in root.typed_entries(directory)? {
|
|
||||||
let path = directory.join(name);
|
|
||||||
if reserved(&path) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let is_directory = kind == rustix::fs::FileType::Directory
|
|
||||||
|| (kind == rustix::fs::FileType::Unknown
|
|
||||||
&& root
|
|
||||||
.stat(&path)
|
|
||||||
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR));
|
|
||||||
paths
|
|
||||||
.entry(path.clone())
|
|
||||||
.and_modify(|known| *known |= is_directory)
|
|
||||||
.or_insert(is_directory);
|
|
||||||
if is_directory {
|
|
||||||
collect_paths(root, &path, paths)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_admitted_path(
|
|
||||||
host: &BackingRoot,
|
|
||||||
isolated: &BackingRoot,
|
|
||||||
path: &Path,
|
|
||||||
directory: bool,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let Some(host_stat) = stat_if_exists(host, path)? else {
|
|
||||||
if stat_if_exists(isolated, path)?.is_some() {
|
|
||||||
remove_tree(isolated, path)?;
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let host_directory = host_stat.st_mode & libc::S_IFMT == libc::S_IFDIR;
|
|
||||||
if host_directory != directory {
|
|
||||||
return Err(io::Error::from_raw_os_error(libc::EIO));
|
|
||||||
}
|
|
||||||
if directory {
|
|
||||||
if let Some(stat) = stat_if_exists(isolated, path)?
|
|
||||||
&& stat.st_mode & libc::S_IFMT != libc::S_IFDIR
|
|
||||||
{
|
|
||||||
remove_tree(isolated, path)?;
|
|
||||||
}
|
|
||||||
isolated.ensure_parent_directories(path)?;
|
|
||||||
match isolated.mkdir(path, Mode::from_raw_mode(host_stat.st_mode & 0o7777)) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
let output = isolated.open_directory(path)?;
|
|
||||||
set_metadata(&output, &host_stat)?;
|
|
||||||
output.sync_all()?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let sequence = TRANSACTION_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let staging = Path::new(TRANSACTIONS)
|
|
||||||
.join(ROUTING)
|
|
||||||
.join(format!("stage-{}-{sequence}", std::process::id()));
|
|
||||||
if stat_if_exists(isolated, &staging)?.is_some() {
|
|
||||||
remove_tree(isolated, &staging)?;
|
|
||||||
}
|
|
||||||
let manifest = scan_tree(host, path)?;
|
|
||||||
copy_tree(host, path, isolated, &staging)?;
|
|
||||||
require_exact_tree(host, path, &manifest)?;
|
|
||||||
require_equivalent_tree(isolated, &staging, &manifest)?;
|
|
||||||
if stat_if_exists(isolated, path)?.is_some() {
|
|
||||||
remove_tree(isolated, path)?;
|
|
||||||
}
|
|
||||||
isolated.ensure_parent_directories(path)?;
|
|
||||||
isolated.rename(&staging, path, RenameFlags::NOREPLACE)?;
|
|
||||||
isolated.sync_directory(path.parent().unwrap_or_else(|| Path::new("")))?;
|
|
||||||
require_exact_tree(host, path, &manifest)?;
|
|
||||||
require_equivalent_tree(isolated, path, &manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_released_path(
|
|
||||||
isolated: &BackingRoot,
|
|
||||||
current: &ClassificationSnapshot,
|
|
||||||
path: &Path,
|
|
||||||
directory: bool,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
if stat_if_exists(isolated, path)?.is_none() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if directory && has_isolated_descendant(isolated, current, path)? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
remove_tree(isolated, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_isolated_descendant(
|
|
||||||
isolated: &BackingRoot,
|
|
||||||
current: &ClassificationSnapshot,
|
|
||||||
directory: &Path,
|
|
||||||
) -> io::Result<bool> {
|
|
||||||
for (name, kind) in isolated.typed_entries(directory)? {
|
|
||||||
let child = directory.join(name);
|
|
||||||
let child_directory = kind == rustix::fs::FileType::Directory
|
|
||||||
|| (kind == rustix::fs::FileType::Unknown
|
|
||||||
&& isolated
|
|
||||||
.stat(&child)
|
|
||||||
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR));
|
|
||||||
if current.classify(&child, child_directory) == Classification::Isolated
|
|
||||||
|| (child_directory && has_isolated_descendant(isolated, current, &child)?)
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recover(host: &BackingRoot, isolated: &BackingRoot) -> io::Result<()> {
|
fn recover(host: &BackingRoot, isolated: &BackingRoot) -> io::Result<()> {
|
||||||
for (name, kind) in isolated.typed_entries(Path::new(TRANSACTIONS))? {
|
for (name, kind) in isolated.typed_entries(Path::new(TRANSACTIONS))? {
|
||||||
if name == OsStr::new(ROUTING) && kind == rustix::fs::FileType::Directory {
|
if name == OsStr::new(ROUTING) && kind == rustix::fs::FileType::Directory {
|
||||||
|
|
@ -958,14 +701,18 @@ fn path_from_bytes(bytes: &[u8]) -> PathBuf {
|
||||||
PathBuf::from(OsString::from_vec(bytes.to_vec()))
|
PathBuf::from(OsString::from_vec(bytes.to_vec()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "migration/recovery_tests.rs"]
|
||||||
|
mod recovery_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BackingRoot, Intent, Mode, ObjectStamp, Provenance, copy_tree, initialize, migrate,
|
BackingRoot, Intent, Mode, Provenance, initialize, migrate, path_bytes, path_from_bytes,
|
||||||
path_bytes, path_from_bytes, persist_intent, scan_tree,
|
persist_intent, scan_tree,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1217,104 +964,6 @@ mod tests {
|
||||||
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
|
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,
|
||||||
|
|
|
||||||
165
crates/lazaret-ignored-workspace/src/migration/recovery_tests.rs
Normal file
165
crates/lazaret-ignored-workspace/src/migration/recovery_tests.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
use super::{
|
||||||
|
BackingRoot, Intent, Mode, ObjectStamp, Provenance, copy_tree, initialize, migrate, path_bytes,
|
||||||
|
persist_intent, scan_tree,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_migration_refuses_multiply_linked_regular_files() {
|
||||||
|
let fixture = Fixture::new("runtime-hardlink");
|
||||||
|
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.bin");
|
||||||
|
let peer = std::path::Path::new("peer.bin");
|
||||||
|
host.write_file(source, b"shared", Mode::from_raw_mode(0o600))
|
||||||
|
.unwrap();
|
||||||
|
host.link(source, peer).unwrap();
|
||||||
|
let mut provenance = Provenance::default();
|
||||||
|
provenance.record(source.to_path_buf(), &host.stat(source).unwrap());
|
||||||
|
|
||||||
|
let error = migrate(
|
||||||
|
&host,
|
||||||
|
&isolated,
|
||||||
|
&provenance,
|
||||||
|
source,
|
||||||
|
std::path::Path::new("isolated.bin"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error.raw_os_error(), Some(libc::EXDEV));
|
||||||
|
assert_eq!(
|
||||||
|
host.stat(source).unwrap().st_ino,
|
||||||
|
host.stat(peer).unwrap().st_ino
|
||||||
|
);
|
||||||
|
assert!(isolated.stat(std::path::Path::new("isolated.bin")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
isolated: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let root = std::env::current_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join("target/ignored-workspace-tests")
|
||||||
|
.join(format!("migration-recovery-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let host = root.join("host");
|
||||||
|
let isolated = root.join("isolated");
|
||||||
|
std::fs::create_dir_all(&host).unwrap();
|
||||||
|
std::fs::create_dir_all(&isolated).unwrap();
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
host,
|
||||||
|
isolated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
719
crates/lazaret-ignored-workspace/src/routing.rs
Normal file
719
crates/lazaret-ignored-workspace/src/routing.rs
Normal file
|
|
@ -0,0 +1,719 @@
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::os::fd::AsFd;
|
||||||
|
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use rustix::fs::{Mode, OFlags, RenameFlags, Timestamps, fchmod, fchown, flistxattr, futimens};
|
||||||
|
use rustix::process::{Gid, Uid};
|
||||||
|
use rustix::time::Timespec;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::backing::BackingRoot;
|
||||||
|
use crate::classifier::{Classification, ClassificationSnapshot, GitClassifier, PolicyRecord};
|
||||||
|
use crate::migration::{TRANSACTIONS, reserved};
|
||||||
|
|
||||||
|
const ROUTING: &str = "routing";
|
||||||
|
const POLICY: &str = "policy.json";
|
||||||
|
const POLICY_TEMPORARY: &str = "policy.tmp";
|
||||||
|
const CANDIDATE_PREFIX: &[u8] = b"candidate-";
|
||||||
|
const LEGACY_STAGE_PREFIX: &[u8] = b"stage-";
|
||||||
|
static CANDIDATE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
struct ObjectKey {
|
||||||
|
device: u64,
|
||||||
|
inode: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum EntryKind {
|
||||||
|
Directory,
|
||||||
|
Regular,
|
||||||
|
Symlink,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct AdmissionRecord {
|
||||||
|
kind: EntryKind,
|
||||||
|
key: ObjectKey,
|
||||||
|
links: u64,
|
||||||
|
mode: u32,
|
||||||
|
uid: u32,
|
||||||
|
gid: u32,
|
||||||
|
size: i64,
|
||||||
|
access_seconds: i64,
|
||||||
|
access_nanoseconds: u64,
|
||||||
|
modification_seconds: i64,
|
||||||
|
modification_nanoseconds: u64,
|
||||||
|
change_seconds: i64,
|
||||||
|
change_nanoseconds: u64,
|
||||||
|
digest: Option<[u8; 32]>,
|
||||||
|
link_target: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct Transition {
|
||||||
|
path: PathBuf,
|
||||||
|
directory: bool,
|
||||||
|
old: Classification,
|
||||||
|
new: Classification,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn admit_policy(
|
||||||
|
workspace: &Path,
|
||||||
|
host: &BackingRoot,
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
classifier: &GitClassifier,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let routing = routing_directory();
|
||||||
|
match isolated.mkdir(&routing, Mode::from_raw_mode(0o700)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
isolated.sync_directory(Path::new(TRANSACTIONS))?;
|
||||||
|
clean_candidates(isolated)?;
|
||||||
|
|
||||||
|
let previous = read_policy(isolated)?;
|
||||||
|
let current_record = classifier.policy_record();
|
||||||
|
if previous.as_ref() == Some(¤t_record) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
let previous = GitClassifier::snapshot_from_record(workspace, previous)?;
|
||||||
|
reconcile(host, isolated, &previous, &classifier.snapshot())?;
|
||||||
|
}
|
||||||
|
persist_policy(isolated, ¤t_record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconcile(
|
||||||
|
host: &BackingRoot,
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
previous: &ClassificationSnapshot,
|
||||||
|
current: &ClassificationSnapshot,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let transitions = transitions(host, isolated, previous, current)?;
|
||||||
|
let admissions = capture_admissions(host, &transitions)?;
|
||||||
|
let candidate = create_candidate(isolated)?;
|
||||||
|
let result = reconcile_candidate(
|
||||||
|
host,
|
||||||
|
isolated,
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
&transitions,
|
||||||
|
&admissions,
|
||||||
|
&candidate,
|
||||||
|
);
|
||||||
|
if result.is_ok() {
|
||||||
|
remove_tree(isolated, &candidate)?;
|
||||||
|
isolated.sync_directory(&routing_directory())?;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconcile_candidate(
|
||||||
|
host: &BackingRoot,
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
previous: &ClassificationSnapshot,
|
||||||
|
current: &ClassificationSnapshot,
|
||||||
|
expected_transitions: &[Transition],
|
||||||
|
admissions: &BTreeMap<PathBuf, Option<AdmissionRecord>>,
|
||||||
|
candidate: &Path,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let mut releases = expected_transitions
|
||||||
|
.iter()
|
||||||
|
.filter(|transition| {
|
||||||
|
transition.old == Classification::Isolated && transition.new == Classification::Host
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
releases.sort_by_key(|transition| usize::MAX - transition.path.components().count());
|
||||||
|
for transition in releases {
|
||||||
|
remove_released_path(isolated, current, &transition.path, transition.directory)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut directories = admissions
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(path, record)| {
|
||||||
|
record
|
||||||
|
.as_ref()
|
||||||
|
.filter(|record| record.kind == EntryKind::Directory)
|
||||||
|
.map(|record| (path, record))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
directories.sort_by_key(|(path, _)| path.components().count());
|
||||||
|
for (path, record) in &directories {
|
||||||
|
ensure_directory(isolated, path, record)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let anchors = create_regular_anchors(host, isolated, admissions, candidate)?;
|
||||||
|
let names = candidate.join("names");
|
||||||
|
let mut name_sequence = 0_u64;
|
||||||
|
for (path, record) in admissions {
|
||||||
|
let Some(record) = record else {
|
||||||
|
if stat_if_exists(isolated, path)?.is_some() {
|
||||||
|
remove_tree(isolated, path)?;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match record.kind {
|
||||||
|
EntryKind::Directory => {}
|
||||||
|
EntryKind::Regular => {
|
||||||
|
let staging = names.join(format!("name-{name_sequence}"));
|
||||||
|
name_sequence += 1;
|
||||||
|
isolated.link(&anchors[&record.key], &staging)?;
|
||||||
|
install_name(isolated, &staging, path)?;
|
||||||
|
}
|
||||||
|
EntryKind::Symlink => {
|
||||||
|
let staging = names.join(format!("name-{name_sequence}"));
|
||||||
|
name_sequence += 1;
|
||||||
|
let target = PathBuf::from(OsString::from_vec(
|
||||||
|
record.link_target.clone().ok_or_else(invalid_manifest)?,
|
||||||
|
));
|
||||||
|
isolated.symlink(&target, &staging)?;
|
||||||
|
isolated.set_symlink_owner(
|
||||||
|
&staging,
|
||||||
|
Uid::from_raw(record.uid),
|
||||||
|
Gid::from_raw(record.gid),
|
||||||
|
)?;
|
||||||
|
isolated.set_symlink_times(&staging, &record.timestamps())?;
|
||||||
|
install_name(isolated, &staging, path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
directories.sort_by_key(|(path, _)| usize::MAX - path.components().count());
|
||||||
|
for (path, record) in directories {
|
||||||
|
let directory = isolated.open_directory(path)?;
|
||||||
|
set_metadata(&directory, record)?;
|
||||||
|
directory.sync_all()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_admissions(host, admissions)?;
|
||||||
|
verify_isolated(isolated, admissions)?;
|
||||||
|
let current_transitions = transitions(host, isolated, previous, current)?;
|
||||||
|
let current_admissions = capture_admissions(host, ¤t_transitions)?;
|
||||||
|
if !same_admissions(¤t_admissions, admissions) {
|
||||||
|
return Err(io::Error::from_raw_os_error(libc::EAGAIN));
|
||||||
|
}
|
||||||
|
require_admissions(host, admissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_regular_anchors(
|
||||||
|
host: &BackingRoot,
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
admissions: &BTreeMap<PathBuf, Option<AdmissionRecord>>,
|
||||||
|
candidate: &Path,
|
||||||
|
) -> io::Result<HashMap<ObjectKey, PathBuf>> {
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
for (path, record) in admissions {
|
||||||
|
if let Some(record) = record
|
||||||
|
&& record.kind == EntryKind::Regular
|
||||||
|
{
|
||||||
|
sources.entry(record.key).or_insert((path, record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let objects = candidate.join("objects");
|
||||||
|
let mut anchors = HashMap::new();
|
||||||
|
for (sequence, (key, (source, record))) in sources.into_iter().enumerate() {
|
||||||
|
let anchor = objects.join(format!("object-{sequence}"));
|
||||||
|
let mut input = host.open_entry(source, OFlags::RDONLY, Mode::empty())?;
|
||||||
|
let mut output = isolated.create_file(&anchor, Mode::from_raw_mode(0o600))?;
|
||||||
|
io::copy(&mut input, &mut output)?;
|
||||||
|
set_metadata(&output, record)?;
|
||||||
|
output.sync_all()?;
|
||||||
|
anchors.insert(key, anchor);
|
||||||
|
}
|
||||||
|
isolated.sync_directory(&objects)?;
|
||||||
|
Ok(anchors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_directory(
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
path: &Path,
|
||||||
|
record: &AdmissionRecord,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if let Some(stat) = stat_if_exists(isolated, path)?
|
||||||
|
&& stat.st_mode & libc::S_IFMT != libc::S_IFDIR
|
||||||
|
{
|
||||||
|
remove_tree(isolated, path)?;
|
||||||
|
}
|
||||||
|
isolated.ensure_parent_directories(path)?;
|
||||||
|
match isolated.mkdir(path, Mode::from_raw_mode(record.mode & 0o7777)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_name(isolated: &BackingRoot, staging: &Path, destination: &Path) -> io::Result<()> {
|
||||||
|
if stat_if_exists(isolated, destination)?.is_some() {
|
||||||
|
remove_tree(isolated, destination)?;
|
||||||
|
}
|
||||||
|
isolated.ensure_parent_directories(destination)?;
|
||||||
|
isolated.rename(staging, destination, RenameFlags::NOREPLACE)?;
|
||||||
|
isolated.sync_directory(destination.parent().unwrap_or_else(|| Path::new("")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_admissions(
|
||||||
|
host: &BackingRoot,
|
||||||
|
transitions: &[Transition],
|
||||||
|
) -> io::Result<BTreeMap<PathBuf, Option<AdmissionRecord>>> {
|
||||||
|
transitions
|
||||||
|
.iter()
|
||||||
|
.filter(|transition| {
|
||||||
|
transition.old == Classification::Host && transition.new == Classification::Isolated
|
||||||
|
})
|
||||||
|
.map(|transition| {
|
||||||
|
capture_record(host, &transition.path, transition.directory)
|
||||||
|
.map(|record| (transition.path.clone(), record))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_record(
|
||||||
|
root: &BackingRoot,
|
||||||
|
path: &Path,
|
||||||
|
directory: bool,
|
||||||
|
) -> io::Result<Option<AdmissionRecord>> {
|
||||||
|
let Some(before) = stat_if_exists(root, path)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let kind = EntryKind::from_mode(before.st_mode)?;
|
||||||
|
if (kind == EntryKind::Directory) != directory {
|
||||||
|
return Err(invalid_manifest());
|
||||||
|
}
|
||||||
|
let (digest, link_target) = match kind {
|
||||||
|
EntryKind::Directory => (None, None),
|
||||||
|
EntryKind::Regular => {
|
||||||
|
let file = root.open_entry(path, OFlags::RDONLY, Mode::empty())?;
|
||||||
|
require_copyable_xattrs(&file)?;
|
||||||
|
(Some(digest(file)?), None)
|
||||||
|
}
|
||||||
|
EntryKind::Symlink => {
|
||||||
|
if root
|
||||||
|
.symlink_xattrs(path)?
|
||||||
|
.iter()
|
||||||
|
.any(|name| unsupported_xattr_name(name.as_bytes()))
|
||||||
|
{
|
||||||
|
return Err(io::Error::from_raw_os_error(libc::EXDEV));
|
||||||
|
}
|
||||||
|
(None, Some(root.readlink(path)?))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let record = AdmissionRecord::new(&before, kind, digest, link_target);
|
||||||
|
let after = root.stat(path)?;
|
||||||
|
if !record.same_source(&after) {
|
||||||
|
return Err(io::Error::from_raw_os_error(libc::EAGAIN));
|
||||||
|
}
|
||||||
|
Ok(Some(record))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_admissions(
|
||||||
|
host: &BackingRoot,
|
||||||
|
expected: &BTreeMap<PathBuf, Option<AdmissionRecord>>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
for (path, expected) in expected {
|
||||||
|
let actual = capture_record(
|
||||||
|
host,
|
||||||
|
path,
|
||||||
|
expected
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|record| record.kind == EntryKind::Directory),
|
||||||
|
)?;
|
||||||
|
if !same_admission(actual.as_ref(), expected.as_ref()) {
|
||||||
|
return Err(io::Error::from_raw_os_error(libc::EAGAIN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_isolated(
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
expected: &BTreeMap<PathBuf, Option<AdmissionRecord>>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let mut source_to_destination = HashMap::new();
|
||||||
|
let mut destination_to_source = HashMap::new();
|
||||||
|
let mut digests = HashMap::new();
|
||||||
|
for (path, expected) in expected {
|
||||||
|
let Some(expected) = expected else {
|
||||||
|
if stat_if_exists(isolated, path)?.is_some() {
|
||||||
|
return Err(invalid_manifest());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let actual = isolated.stat(path)?;
|
||||||
|
if !expected.equivalent_destination(&actual) {
|
||||||
|
return Err(invalid_manifest());
|
||||||
|
}
|
||||||
|
match expected.kind {
|
||||||
|
EntryKind::Regular => {
|
||||||
|
let destination = ObjectKey::from_stat(&actual);
|
||||||
|
if destination == expected.key {
|
||||||
|
return Err(invalid_manifest());
|
||||||
|
}
|
||||||
|
if source_to_destination
|
||||||
|
.insert(expected.key, destination)
|
||||||
|
.is_some_and(|known| known != destination)
|
||||||
|
|| destination_to_source
|
||||||
|
.insert(destination, expected.key)
|
||||||
|
.is_some_and(|known| known != expected.key)
|
||||||
|
{
|
||||||
|
return Err(invalid_manifest());
|
||||||
|
}
|
||||||
|
let actual_digest = if let Some(digest) = digests.get(&destination) {
|
||||||
|
*digest
|
||||||
|
} else {
|
||||||
|
let digest =
|
||||||
|
digest(isolated.open_entry(path, OFlags::RDONLY, Mode::empty())?)?;
|
||||||
|
digests.insert(destination, digest);
|
||||||
|
digest
|
||||||
|
};
|
||||||
|
if Some(actual_digest) != expected.digest {
|
||||||
|
return Err(invalid_manifest());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EntryKind::Symlink => {
|
||||||
|
if Some(isolated.readlink(path)?) != expected.link_target {
|
||||||
|
return Err(invalid_manifest());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EntryKind::Directory => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transitions(
|
||||||
|
host: &BackingRoot,
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
previous: &ClassificationSnapshot,
|
||||||
|
current: &ClassificationSnapshot,
|
||||||
|
) -> io::Result<Vec<Transition>> {
|
||||||
|
let mut paths = BTreeMap::new();
|
||||||
|
collect_paths(host, Path::new(""), &mut paths)?;
|
||||||
|
collect_paths(isolated, Path::new(""), &mut paths)?;
|
||||||
|
paths.retain(|path, _| !reserved(path));
|
||||||
|
Ok(paths
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(path, directory)| {
|
||||||
|
let old = previous.classify(&path, directory);
|
||||||
|
let new = current.classify(&path, directory);
|
||||||
|
(old != new).then_some(Transition {
|
||||||
|
path,
|
||||||
|
directory,
|
||||||
|
old,
|
||||||
|
new,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_paths(
|
||||||
|
root: &BackingRoot,
|
||||||
|
directory: &Path,
|
||||||
|
paths: &mut BTreeMap<PathBuf, bool>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
for (name, kind) in root.typed_entries(directory)? {
|
||||||
|
let path = directory.join(name);
|
||||||
|
if reserved(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let is_directory = kind == rustix::fs::FileType::Directory
|
||||||
|
|| (kind == rustix::fs::FileType::Unknown
|
||||||
|
&& root
|
||||||
|
.stat(&path)
|
||||||
|
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR));
|
||||||
|
paths
|
||||||
|
.entry(path.clone())
|
||||||
|
.and_modify(|known| *known |= is_directory)
|
||||||
|
.or_insert(is_directory);
|
||||||
|
if is_directory {
|
||||||
|
collect_paths(root, &path, paths)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_released_path(
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
current: &ClassificationSnapshot,
|
||||||
|
path: &Path,
|
||||||
|
directory: bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if stat_if_exists(isolated, path)?.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if directory && has_isolated_descendant(isolated, current, path)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
remove_tree(isolated, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_isolated_descendant(
|
||||||
|
isolated: &BackingRoot,
|
||||||
|
current: &ClassificationSnapshot,
|
||||||
|
directory: &Path,
|
||||||
|
) -> io::Result<bool> {
|
||||||
|
for (name, kind) in isolated.typed_entries(directory)? {
|
||||||
|
let child = directory.join(name);
|
||||||
|
let child_directory = kind == rustix::fs::FileType::Directory
|
||||||
|
|| (kind == rustix::fs::FileType::Unknown
|
||||||
|
&& isolated
|
||||||
|
.stat(&child)
|
||||||
|
.is_ok_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR));
|
||||||
|
if current.classify(&child, child_directory) == Classification::Isolated
|
||||||
|
|| (child_directory && has_isolated_descendant(isolated, current, &child)?)
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_candidate(isolated: &BackingRoot) -> io::Result<PathBuf> {
|
||||||
|
let sequence = CANDIDATE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let candidate =
|
||||||
|
routing_directory().join(format!("candidate-{}-{sequence}", std::process::id()));
|
||||||
|
isolated.mkdir(&candidate, Mode::from_raw_mode(0o700))?;
|
||||||
|
isolated.mkdir(&candidate.join("objects"), Mode::from_raw_mode(0o700))?;
|
||||||
|
isolated.mkdir(&candidate.join("names"), Mode::from_raw_mode(0o700))?;
|
||||||
|
isolated.sync_directory(&candidate)?;
|
||||||
|
isolated.sync_directory(&routing_directory())?;
|
||||||
|
Ok(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_candidates(isolated: &BackingRoot) -> io::Result<()> {
|
||||||
|
let directory = routing_directory();
|
||||||
|
for (name, kind) in isolated.typed_entries(&directory)? {
|
||||||
|
let path = directory.join(&name);
|
||||||
|
if name == OsStr::new(POLICY) && kind == rustix::fs::FileType::RegularFile {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if name == OsStr::new(POLICY_TEMPORARY)
|
||||||
|
|| name.as_bytes().starts_with(CANDIDATE_PREFIX)
|
||||||
|
|| name.as_bytes().starts_with(LEGACY_STAGE_PREFIX)
|
||||||
|
{
|
||||||
|
remove_tree(isolated, &path)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Err(io::Error::from_raw_os_error(libc::EIO));
|
||||||
|
}
|
||||||
|
isolated.sync_directory(&directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_policy(isolated: &BackingRoot) -> io::Result<Option<PolicyRecord>> {
|
||||||
|
match isolated.read_file(&routing_directory().join(POLICY), 64 * 1024 * 1024) {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_policy(isolated: &BackingRoot, policy: &PolicyRecord) -> io::Result<()> {
|
||||||
|
let directory = routing_directory();
|
||||||
|
let temporary = directory.join(POLICY_TEMPORARY);
|
||||||
|
let destination = directory.join(POLICY);
|
||||||
|
if stat_if_exists(isolated, &temporary)?.is_some() {
|
||||||
|
isolated.unlink(&temporary, false)?;
|
||||||
|
}
|
||||||
|
let bytes = serde_json::to_vec(policy).map_err(io::Error::other)?;
|
||||||
|
isolated.write_file(&temporary, &bytes, Mode::from_raw_mode(0o600))?;
|
||||||
|
isolated.rename(&temporary, &destination, RenameFlags::empty())?;
|
||||||
|
isolated.sync_directory(&directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn routing_directory() -> PathBuf {
|
||||||
|
Path::new(TRANSACTIONS).join(ROUTING)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 remove_tree(root: &BackingRoot, path: &Path) -> io::Result<()> {
|
||||||
|
let stat = root.stat(path)?;
|
||||||
|
if stat.st_mode & libc::S_IFMT == libc::S_IFDIR {
|
||||||
|
for (name, _) in root.typed_entries(path)? {
|
||||||
|
remove_tree(root, &path.join(name))?;
|
||||||
|
}
|
||||||
|
root.unlink(path, true)
|
||||||
|
} else {
|
||||||
|
root.unlink(path, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_copyable_xattrs(file: &File) -> io::Result<()> {
|
||||||
|
let mut xattrs = vec![0; 64 * 1024];
|
||||||
|
let length = flistxattr(file.as_fd(), &mut xattrs)?;
|
||||||
|
xattrs.truncate(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];
|
||||||
|
loop {
|
||||||
|
let length = file.read(&mut buffer)?;
|
||||||
|
if length == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..length]);
|
||||||
|
}
|
||||||
|
Ok(hasher.finalize().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_metadata(file: &File, record: &AdmissionRecord) -> io::Result<()> {
|
||||||
|
fchmod(file.as_fd(), Mode::from_raw_mode(record.mode & 0o7777))?;
|
||||||
|
fchown(
|
||||||
|
file.as_fd(),
|
||||||
|
Some(Uid::from_raw(record.uid)),
|
||||||
|
Some(Gid::from_raw(record.gid)),
|
||||||
|
)?;
|
||||||
|
futimens(file.as_fd(), &record.timestamps())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectKey {
|
||||||
|
fn from_stat(stat: &rustix::fs::Stat) -> Self {
|
||||||
|
Self {
|
||||||
|
device: stat.st_dev,
|
||||||
|
inode: stat.st_ino,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntryKind {
|
||||||
|
fn from_mode(mode: u32) -> io::Result<Self> {
|
||||||
|
match mode & libc::S_IFMT {
|
||||||
|
libc::S_IFDIR => Ok(Self::Directory),
|
||||||
|
libc::S_IFREG => Ok(Self::Regular),
|
||||||
|
libc::S_IFLNK => Ok(Self::Symlink),
|
||||||
|
_ => Err(io::Error::from_raw_os_error(libc::EXDEV)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdmissionRecord {
|
||||||
|
fn new(
|
||||||
|
stat: &rustix::fs::Stat,
|
||||||
|
kind: EntryKind,
|
||||||
|
digest: Option<[u8; 32]>,
|
||||||
|
link_target: Option<Vec<u8>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
key: ObjectKey::from_stat(stat),
|
||||||
|
links: stat.st_nlink,
|
||||||
|
mode: stat.st_mode,
|
||||||
|
uid: stat.st_uid,
|
||||||
|
gid: stat.st_gid,
|
||||||
|
size: stat.st_size,
|
||||||
|
access_seconds: stat.st_atime,
|
||||||
|
access_nanoseconds: stat.st_atime_nsec,
|
||||||
|
modification_seconds: stat.st_mtime,
|
||||||
|
modification_nanoseconds: stat.st_mtime_nsec,
|
||||||
|
change_seconds: stat.st_ctime,
|
||||||
|
change_nanoseconds: stat.st_ctime_nsec,
|
||||||
|
digest,
|
||||||
|
link_target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_source(&self, stat: &rustix::fs::Stat) -> bool {
|
||||||
|
self.key == ObjectKey::from_stat(stat)
|
||||||
|
&& self.links == stat.st_nlink
|
||||||
|
&& self.mode == stat.st_mode
|
||||||
|
&& self.uid == stat.st_uid
|
||||||
|
&& self.gid == stat.st_gid
|
||||||
|
&& self.size == stat.st_size
|
||||||
|
&& self.modification_seconds == stat.st_mtime
|
||||||
|
&& self.modification_nanoseconds == stat.st_mtime_nsec
|
||||||
|
&& self.change_seconds == stat.st_ctime
|
||||||
|
&& self.change_nanoseconds == stat.st_ctime_nsec
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_admission(&self, other: &Self) -> bool {
|
||||||
|
self.kind == other.kind
|
||||||
|
&& self.key == other.key
|
||||||
|
&& self.links == other.links
|
||||||
|
&& self.mode == other.mode
|
||||||
|
&& self.uid == other.uid
|
||||||
|
&& self.gid == other.gid
|
||||||
|
&& self.size == other.size
|
||||||
|
&& self.modification_seconds == other.modification_seconds
|
||||||
|
&& self.modification_nanoseconds == other.modification_nanoseconds
|
||||||
|
&& self.change_seconds == other.change_seconds
|
||||||
|
&& self.change_nanoseconds == other.change_nanoseconds
|
||||||
|
&& self.digest == other.digest
|
||||||
|
&& self.link_target == other.link_target
|
||||||
|
}
|
||||||
|
|
||||||
|
fn equivalent_destination(&self, stat: &rustix::fs::Stat) -> bool {
|
||||||
|
EntryKind::from_mode(stat.st_mode).ok() == Some(self.kind)
|
||||||
|
&& self.mode & 0o7777 == stat.st_mode & 0o7777
|
||||||
|
&& self.uid == stat.st_uid
|
||||||
|
&& self.gid == stat.st_gid
|
||||||
|
&& (self.kind == EntryKind::Directory || self.size == stat.st_size)
|
||||||
|
&& self.modification_seconds == stat.st_mtime
|
||||||
|
&& self.modification_nanoseconds == stat.st_mtime_nsec
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamps(&self) -> Timestamps {
|
||||||
|
Timestamps {
|
||||||
|
last_access: Timespec {
|
||||||
|
tv_sec: self.access_seconds,
|
||||||
|
tv_nsec: i64::try_from(self.access_nanoseconds).unwrap_or(999_999_999),
|
||||||
|
},
|
||||||
|
last_modification: Timespec {
|
||||||
|
tv_sec: self.modification_seconds,
|
||||||
|
tv_nsec: i64::try_from(self.modification_nanoseconds).unwrap_or(999_999_999),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_manifest() -> io::Error {
|
||||||
|
io::Error::from_raw_os_error(libc::EIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_admission(left: Option<&AdmissionRecord>, right: Option<&AdmissionRecord>) -> bool {
|
||||||
|
match (left, right) {
|
||||||
|
(Some(left), Some(right)) => left.same_admission(right),
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_admissions(
|
||||||
|
left: &BTreeMap<PathBuf, Option<AdmissionRecord>>,
|
||||||
|
right: &BTreeMap<PathBuf, Option<AdmissionRecord>>,
|
||||||
|
) -> bool {
|
||||||
|
left.len() == right.len()
|
||||||
|
&& left.iter().all(|(path, left)| {
|
||||||
|
right
|
||||||
|
.get(path)
|
||||||
|
.is_some_and(|right| same_admission(left.as_ref(), right.as_ref()))
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue