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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub(crate) struct PolicyRecord {
|
||||
schema: u32,
|
||||
repositories: Vec<RepositoryRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
struct RepositoryRecord {
|
||||
prefix: Vec<u8>,
|
||||
tracked: Vec<Vec<u8>>,
|
||||
|
|
@ -124,7 +124,7 @@ struct RepositoryRecord {
|
|||
ignore_case: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
struct PatternRecord {
|
||||
bytes: Vec<u8>,
|
||||
source: Vec<u8>,
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ impl IgnoredWorkspace {
|
|||
let classifier =
|
||||
GitClassifier::from_root(workspace_label, &classifier_root, global_excludes)?;
|
||||
if isolate_ignored {
|
||||
crate::migration::admit_policy(workspace_label, &host, &isolated, &classifier)?;
|
||||
crate::routing::admit_policy(workspace_label, &host, &isolated, &classifier)?;
|
||||
}
|
||||
Ok(Self {
|
||||
host,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Seek, Write};
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
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]
|
||||
fn restart_removes_isolated_paths_that_return_to_host_routing() {
|
||||
let fixture = Fixture::new();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod classifier;
|
|||
mod daemon;
|
||||
mod filesystem;
|
||||
mod migration;
|
||||
mod routing;
|
||||
mod sentry;
|
||||
|
||||
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::fs::File;
|
||||
use std::io::{self, Read, Write};
|
||||
|
|
@ -14,12 +14,9 @@ use serde::{Deserialize, Serialize};
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
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_POLICY: &str = "policy.json";
|
||||
const ROUTING_POLICY_TEMPORARY: &str = "policy.tmp";
|
||||
const INTENT_SUFFIX: &str = ".json";
|
||||
static TRANSACTION_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
|
|
@ -158,44 +155,6 @@ pub(crate) fn initialize(host: &BackingRoot, isolated: &BackingRoot) -> io::Resu
|
|||
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(
|
||||
host: &BackingRoot,
|
||||
isolated: &BackingRoot,
|
||||
|
|
@ -303,222 +262,6 @@ pub(crate) fn migrate(
|
|||
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<()> {
|
||||
for (name, kind) in isolated.typed_entries(Path::new(TRANSACTIONS))? {
|
||||
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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "migration/recovery_tests.rs"]
|
||||
mod recovery_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
use super::{
|
||||
BackingRoot, Intent, Mode, ObjectStamp, Provenance, copy_tree, initialize, migrate,
|
||||
path_bytes, path_from_bytes, persist_intent, scan_tree,
|
||||
BackingRoot, Intent, Mode, Provenance, initialize, migrate, path_bytes, path_from_bytes,
|
||||
persist_intent, scan_tree,
|
||||
};
|
||||
|
||||
#[test]
|
||||
|
|
@ -1217,104 +964,6 @@ mod tests {
|
|||
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_restores_replaced_destination_before_publication() {
|
||||
let fixture = Fixture::new("replacement-rollback");
|
||||
let host = BackingRoot::open(&fixture.host).unwrap();
|
||||
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
|
||||
initialize(&host, &isolated).unwrap();
|
||||
let source = std::path::Path::new("temporary.rdb");
|
||||
let destination = std::path::Path::new("dump.rdb");
|
||||
let quarantine = std::path::Path::new(".lazaret-quarantine-rollback");
|
||||
let staging = std::path::Path::new(".lazaret-staging-rollback");
|
||||
let backup = std::path::Path::new(".lazaret-backup-rollback");
|
||||
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
|
||||
.unwrap();
|
||||
isolated
|
||||
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
|
||||
.unwrap();
|
||||
let intent = Intent {
|
||||
schema: 2,
|
||||
id: "rollback".to_owned(),
|
||||
source: path_bytes(source),
|
||||
destination: path_bytes(destination),
|
||||
quarantine: path_bytes(quarantine),
|
||||
staging: path_bytes(staging),
|
||||
backup: path_bytes(backup),
|
||||
destination_existed: true,
|
||||
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
|
||||
manifest: scan_tree(&host, source).unwrap(),
|
||||
};
|
||||
persist_intent(&isolated, &intent).unwrap();
|
||||
host.rename(source, quarantine, rustix::fs::RenameFlags::NOREPLACE)
|
||||
.unwrap();
|
||||
host.sync_directory(std::path::Path::new("")).unwrap();
|
||||
copy_tree(&host, quarantine, &isolated, staging).unwrap();
|
||||
isolated.sync_directory(std::path::Path::new("")).unwrap();
|
||||
isolated
|
||||
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
|
||||
.unwrap();
|
||||
isolated.sync_directory(std::path::Path::new("")).unwrap();
|
||||
|
||||
initialize(&host, &isolated).unwrap();
|
||||
|
||||
assert_eq!(host.read_file(source, 16).unwrap(), b"new");
|
||||
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"old");
|
||||
assert!(isolated.stat(staging).is_err());
|
||||
assert!(isolated.stat(backup).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_completes_published_replacement() {
|
||||
let fixture = Fixture::new("replacement-published");
|
||||
let host = BackingRoot::open(&fixture.host).unwrap();
|
||||
let isolated = BackingRoot::open(&fixture.isolated).unwrap();
|
||||
initialize(&host, &isolated).unwrap();
|
||||
let source = std::path::Path::new("temporary.rdb");
|
||||
let destination = std::path::Path::new("dump.rdb");
|
||||
let quarantine = std::path::Path::new(".lazaret-quarantine-published");
|
||||
let staging = std::path::Path::new(".lazaret-staging-published");
|
||||
let backup = std::path::Path::new(".lazaret-backup-published");
|
||||
host.write_file(source, b"new", Mode::from_raw_mode(0o600))
|
||||
.unwrap();
|
||||
isolated
|
||||
.write_file(destination, b"old", Mode::from_raw_mode(0o600))
|
||||
.unwrap();
|
||||
let intent = Intent {
|
||||
schema: 2,
|
||||
id: "published".to_owned(),
|
||||
source: path_bytes(source),
|
||||
destination: path_bytes(destination),
|
||||
quarantine: path_bytes(quarantine),
|
||||
staging: path_bytes(staging),
|
||||
backup: path_bytes(backup),
|
||||
destination_existed: true,
|
||||
destination_stamp: Some(ObjectStamp::from_stat(&isolated.stat(destination).unwrap())),
|
||||
manifest: scan_tree(&host, source).unwrap(),
|
||||
};
|
||||
persist_intent(&isolated, &intent).unwrap();
|
||||
host.rename(source, quarantine, rustix::fs::RenameFlags::NOREPLACE)
|
||||
.unwrap();
|
||||
host.sync_directory(std::path::Path::new("")).unwrap();
|
||||
copy_tree(&host, quarantine, &isolated, staging).unwrap();
|
||||
isolated.sync_directory(std::path::Path::new("")).unwrap();
|
||||
isolated
|
||||
.rename(destination, backup, rustix::fs::RenameFlags::NOREPLACE)
|
||||
.unwrap();
|
||||
isolated.sync_directory(std::path::Path::new("")).unwrap();
|
||||
isolated
|
||||
.rename(staging, destination, rustix::fs::RenameFlags::NOREPLACE)
|
||||
.unwrap();
|
||||
isolated.sync_directory(std::path::Path::new("")).unwrap();
|
||||
|
||||
initialize(&host, &isolated).unwrap();
|
||||
|
||||
assert!(host.stat(source).is_err());
|
||||
assert!(host.stat(quarantine).is_err());
|
||||
assert!(isolated.stat(backup).is_err());
|
||||
assert_eq!(isolated.read_file(destination, 16).unwrap(), b"new");
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
root: std::path::PathBuf,
|
||||
host: std::path::PathBuf,
|
||||
|
|
|
|||
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