use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::setup_core::error::{Error, ReasonCode, Result};
use crate::setup_core::lock;
pub const POOL_DIRECTORY_NAME: &str = "backups";
pub const SLOT_MARKER_NAME: &str = "slot.json";
pub const SLOT_PAYLOAD_NAME: &str = "payload";
pub const SLOT_HELD_NAME: &str = "HELD";
pub const SLOT_SCHEMA: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct BackupRef(String);
impl BackupRef {
#[must_use]
pub fn from_sequence(sequence: u64) -> Self {
Self(format!("slot-{sequence:012}"))
}
pub fn parse(text: &str) -> Result<Self> {
let Some(digits) = text.strip_prefix("slot-") else {
return Err(Error::new(
ReasonCode::IntegrityMismatch,
format!("{text:?} is not a backup reference"),
));
};
if digits.len() != 12 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(Error::new(
ReasonCode::IntegrityMismatch,
format!("{text:?} is not a backup reference"),
));
}
Ok(Self(text.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
fn sequence(&self) -> u64 {
self.0
.strip_prefix("slot-")
.and_then(|digits| digits.parse().ok())
.unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlotRecord {
pub schema_version: u32,
pub backup_ref: BackupRef,
pub operation: String,
pub operation_id: String,
pub target_identity_digest: String,
pub setup_id: Option<String>,
#[serde(default)]
pub setup_definition_digest: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Pool {
root: PathBuf,
capacity: usize,
}
impl Pool {
pub fn open(control_directory: &Path, capacity: usize) -> Result<Self> {
if capacity == 0 {
return Err(Error::new(
ReasonCode::IntegrityMismatch,
"a backup pool with no slots cannot support restore",
));
}
let root = control_directory.join(POOL_DIRECTORY_NAME);
fs::create_dir_all(&root).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot create the backup pool at {}", root.display()),
)
.with_source(source)
})?;
Ok(Self { root, capacity })
}
pub fn observe(control_directory: &Path, capacity: usize) -> Result<Self> {
if capacity == 0 {
return Err(Error::new(
ReasonCode::IntegrityMismatch,
"a backup pool with no slots cannot support restore",
));
}
Ok(Self {
root: control_directory.join(POOL_DIRECTORY_NAME),
capacity,
})
}
pub fn list(&self) -> Result<Vec<SlotRecord>> {
let mut records = Vec::new();
for slot in self.slot_directories()? {
if let Some(record) = read_record(&slot)? {
records.push(record);
}
}
records.sort_by_key(|record| std::cmp::Reverse(record.backup_ref.sequence()));
Ok(records)
}
pub fn latest(&self) -> Result<Option<SlotRecord>> {
Ok(self.list()?.into_iter().next())
}
pub fn partial_slots(&self) -> Result<Vec<PathBuf>> {
let mut partial = Vec::new();
for slot in self.slot_directories()? {
if !slot.join(SLOT_MARKER_NAME).exists() {
partial.push(slot);
}
}
partial.sort();
Ok(partial)
}
pub fn payload_of(&self, backup_ref: &BackupRef) -> Result<PathBuf> {
let slot = self.root.join(backup_ref.as_str());
if !slot.join(SLOT_MARKER_NAME).exists() {
return Err(Error::new(
ReasonCode::IntegrityMismatch,
format!("backup {} is absent or incomplete", backup_ref.as_str()),
));
}
Ok(slot.join(SLOT_PAYLOAD_NAME))
}
pub fn next_ref(&self) -> Result<BackupRef> {
let highest = self
.slot_directories()?
.iter()
.filter_map(|slot| slot.file_name().and_then(|name| name.to_str()))
.filter_map(|name| BackupRef::parse(name).ok())
.map(|reference| reference.sequence())
.max()
.unwrap_or(0);
Ok(BackupRef::from_sequence(highest.saturating_add(1)))
}
pub fn capture(
&self,
source: &Path,
included: &[&str],
record: impl FnOnce(BackupRef) -> SlotRecord,
) -> Result<SlotRecord> {
let backup_ref = self.next_ref()?;
let slot = self.root.join(backup_ref.as_str());
let payload = slot.join(SLOT_PAYLOAD_NAME);
fs::create_dir_all(&payload).map_err(|source_error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot create backup slot {}", slot.display()),
)
.with_source(source_error)
})?;
for relative in included {
let from = source.join(relative);
if !from.exists() {
continue;
}
let to = payload.join(relative);
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|source_error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot create {}", parent.display()),
)
.with_source(source_error)
})?;
}
if from.is_dir() {
copy_tree(&from, &to, &[])?;
} else {
copy_file(&from, &to)?;
}
}
let record = record(backup_ref);
let value = serde_json::to_value(&record).map_err(|source_error| {
Error::new(
ReasonCode::StateUnavailable,
"cannot encode the backup record",
)
.with_source(source_error)
})?;
let bytes = crate::setup_core::canonical::to_canonical_bytes(&value)?;
lock::atomic_write(&slot.join(SLOT_MARKER_NAME), &bytes)?;
self.prune()?;
Ok(record)
}
pub fn hold(&self, backup_ref: &BackupRef, reason: &str) -> Result<bool> {
let slot = self.root.join(backup_ref.as_str());
if read_record(&slot)?.is_none() {
return Err(Error::new(
ReasonCode::InvalidTarget,
format!(
"{} is not a completed slot in this pool",
backup_ref.as_str()
),
));
}
let already = self.held()?;
if already.iter().any(|(held, _)| held == backup_ref) {
return Ok(false);
}
if already.len() + 1 >= self.capacity {
return Err(Error::new(
ReasonCode::InvalidTarget,
format!(
"holding {} would leave this pool of {} no slot to rotate; release one of \
these first, and the reason each names is who would lose it: {}",
backup_ref.as_str(),
self.capacity,
already
.iter()
.map(|(held, why)| format!("{} ({why})", held.as_str()))
.collect::<Vec<_>>()
.join(", ")
),
));
}
fs::write(slot.join(SLOT_HELD_NAME), reason.as_bytes()).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot hold {}", slot.display()),
)
.with_source(source)
})?;
Ok(true)
}
pub fn release(&self, backup_ref: &BackupRef) -> Result<bool> {
let marker = self.root.join(backup_ref.as_str()).join(SLOT_HELD_NAME);
match fs::remove_file(&marker) {
Ok(()) => Ok(true),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot release {}", marker.display()),
)
.with_source(source)),
}
}
pub fn held(&self) -> Result<Vec<(BackupRef, String)>> {
let mut out = Vec::new();
for record in self.list()? {
let marker = self
.root
.join(record.backup_ref.as_str())
.join(SLOT_HELD_NAME);
if let Ok(reason) = fs::read_to_string(&marker) {
let reason = reason.trim().to_owned();
let reason = if reason.is_empty() {
"no reason recorded".to_owned()
} else {
reason
};
out.push((record.backup_ref, reason));
}
}
Ok(out)
}
pub fn held_reason(&self, backup_ref: &BackupRef) -> Result<Option<String>> {
Ok(self
.held()?
.into_iter()
.find(|(held, _)| held == backup_ref)
.map(|(_, reason)| reason))
}
pub fn is_held(&self, backup_ref: &BackupRef) -> Result<bool> {
Ok(self
.root
.join(backup_ref.as_str())
.join(SLOT_HELD_NAME)
.is_file())
}
pub fn prune(&self) -> Result<()> {
let records: Vec<SlotRecord> = self
.list()?
.into_iter()
.filter(|record| !self.is_held(&record.backup_ref).unwrap_or(false))
.collect();
for record in records.into_iter().skip(self.capacity) {
let slot = self.root.join(record.backup_ref.as_str());
fs::remove_dir_all(&slot).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot prune {}", slot.display()),
)
.with_source(source)
})?;
}
Ok(())
}
fn slot_directories(&self) -> Result<Vec<PathBuf>> {
let read = match fs::read_dir(&self.root) {
Ok(read) => read,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot list {}", self.root.display()),
)
.with_source(source));
}
};
let mut slots = Vec::new();
for entry in read {
let entry = entry.map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot read an entry of {}", self.root.display()),
)
.with_source(source)
})?;
let path = entry.path();
let is_slot = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| BackupRef::parse(name).is_ok());
if is_slot && path.is_dir() {
slots.push(path);
}
}
Ok(slots)
}
}
fn read_record(slot: &Path) -> Result<Option<SlotRecord>> {
let marker = slot.join(SLOT_MARKER_NAME);
let bytes = match fs::read(&marker) {
Ok(bytes) => bytes,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot read {}", marker.display()),
)
.with_source(source));
}
};
let record: SlotRecord = serde_json::from_slice(&bytes).map_err(|source| {
Error::new(
ReasonCode::StateUnavailable,
format!("{} does not parse as a backup record", marker.display()),
)
.with_source(source)
})?;
if record.schema_version != SLOT_SCHEMA {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!(
"backup schema {} is not the {SLOT_SCHEMA} this build writes",
record.schema_version
),
));
}
Ok(Some(record))
}
pub fn copy_tree(source: &Path, destination: &Path, excluded_top_level: &[&str]) -> Result<()> {
copy_inner(source, destination, excluded_top_level, true)
}
pub fn uncapturable(source: &Path, included: &[&str]) -> Result<Vec<String>> {
let mut refused = Vec::new();
for relative in included {
let from = relative
.split('/')
.fold(source.to_path_buf(), |at, part| at.join(part));
collect_uncapturable(&from, relative, &mut refused)?;
}
refused.sort();
Ok(refused)
}
fn collect_uncapturable(path: &Path, relative: &str, out: &mut Vec<String>) -> Result<()> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!("cannot stat {}", path.display()),
)
.with_source(error));
}
};
if metadata.is_symlink() {
out.push(relative.to_owned());
return Ok(());
}
if !metadata.is_dir() {
return Ok(());
}
let entries = fs::read_dir(path).map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot list {}", path.display()),
)
.with_source(error)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot read an entry of {}", path.display()),
)
.with_source(error)
})?;
let name = entry.file_name().to_string_lossy().into_owned();
collect_uncapturable(&entry.path(), &format!("{relative}/{name}"), out)?;
}
Ok(())
}
fn copy_file(from: &Path, to: &Path) -> Result<()> {
let metadata = fs::symlink_metadata(from).map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot stat {}", from.display()),
)
.with_source(error)
})?;
if metadata.is_symlink() {
return Err(Error::new(
ReasonCode::IntegrityMismatch,
format!("{} is a symbolic link and is not captured", from.display()),
));
}
fs::copy(from, to).map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot copy {} to {}", from.display(), to.display()),
)
.with_source(error)
})?;
Ok(())
}
fn copy_inner(
source: &Path,
destination: &Path,
excluded_top_level: &[&str],
at_root: bool,
) -> Result<()> {
fs::create_dir_all(destination).map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot create {}", destination.display()),
)
.with_source(error)
})?;
let read = fs::read_dir(source).map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot list {}", source.display()),
)
.with_source(error)
})?;
for entry in read {
let entry = entry.map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot read an entry of {}", source.display()),
)
.with_source(error)
})?;
let from = entry.path();
let Some(name) = from.file_name().and_then(|name| name.to_str()) else {
return Err(Error::new(
ReasonCode::StateUnavailable,
format!("{} has a name this kernel cannot represent", from.display()),
));
};
if at_root && excluded_top_level.contains(&name) {
continue;
}
let to = destination.join(name);
let metadata = fs::symlink_metadata(&from).map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot stat {}", from.display()),
)
.with_source(error)
})?;
if metadata.is_symlink() {
return Err(Error::new(
ReasonCode::IntegrityMismatch,
format!("{} is a symbolic link and is not captured", from.display()),
));
}
if metadata.is_dir() {
copy_inner(&from, &to, excluded_top_level, false)?;
} else {
fs::copy(&from, &to).map_err(|error| {
Error::new(
ReasonCode::StateUnavailable,
format!("cannot copy {} to {}", from.display(), to.display()),
)
.with_source(error)
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
fn scratch(name: &str) -> PathBuf {
let base =
std::env::temp_dir().join(format!("setup-core-backup-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
fn record_for(backup_ref: BackupRef) -> SlotRecord {
SlotRecord {
schema_version: SLOT_SCHEMA,
backup_ref,
operation: "install".to_owned(),
operation_id: "op_test".to_owned(),
target_identity_digest: "sha256:target".to_owned(),
setup_id: Some("full-auto".to_owned()),
setup_definition_digest: Some("sha256:definition".to_owned()),
}
}
#[test]
fn a_slot_written_before_the_definition_digest_existed_still_reads() {
let older = serde_json::json!({
"schema_version": SLOT_SCHEMA,
"backup_ref": "slot-000000000001",
"operation": "install",
"operation_id": "op_test",
"target_identity_digest": "sha256:target",
"setup_id": "full-auto",
});
let read: SlotRecord = serde_json::from_value(older).unwrap();
assert_eq!(read.setup_id.as_deref(), Some("full-auto"));
assert_eq!(read.setup_definition_digest, None);
}
#[test]
fn a_reference_that_could_escape_the_pool_is_refused() {
for hostile in [
"slot-../../etc",
"slot-1",
"../slot-000000000001",
"slot-00000000000a",
] {
assert!(BackupRef::parse(hostile).is_err(), "accepted {hostile:?}");
}
assert!(BackupRef::parse("slot-000000000001").is_ok());
}
#[test]
fn a_pool_with_no_slots_is_refused_at_construction() {
let control = scratch("zero");
let error = Pool::open(&control, 0).unwrap_err();
assert_eq!(error.reason(), ReasonCode::IntegrityMismatch);
}
#[test]
fn a_held_slot_outlives_far_more_captures_than_the_pool_holds() {
let base = scratch("held-baseline");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("a.txt"), "the baseline").unwrap();
let pool = Pool::open(&base.join("control"), 10).unwrap();
let baseline = pool.capture(&target, &["a.txt"], record_for).unwrap();
assert!(
pool.hold(&baseline.backup_ref, "E00 baseline for the evidence series")
.unwrap()
);
for round in 0..50 {
fs::write(target.join("a.txt"), format!("round {round}")).unwrap();
pool.capture(&target, &["a.txt"], record_for).unwrap();
pool.prune().unwrap();
}
let held = pool.held().unwrap();
assert_eq!(held.len(), 1);
assert_eq!(held[0].0, baseline.backup_ref);
assert_eq!(held[0].1, "E00 baseline for the evidence series");
assert!(
pool.payload_of(&baseline.backup_ref).is_ok(),
"the held baseline was reclaimed by retention"
);
assert_eq!(
fs::read_to_string(pool.payload_of(&baseline.backup_ref).unwrap().join("a.txt"))
.unwrap(),
"the baseline",
"the held slot survived but no longer names what it named"
);
let unheld: Vec<_> = pool
.list()
.unwrap()
.into_iter()
.filter(|r| !pool.is_held(&r.backup_ref).unwrap())
.collect();
assert_eq!(unheld.len(), 10);
assert!(pool.release(&baseline.backup_ref).unwrap());
assert!(!pool.release(&baseline.backup_ref).unwrap());
pool.prune().unwrap();
assert!(pool.payload_of(&baseline.backup_ref).is_err());
}
#[test]
fn a_hold_that_would_leave_nothing_to_rotate_is_refused_naming_what_to_release() {
let base = scratch("held-full");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("a.txt"), "x").unwrap();
let pool = Pool::open(&base.join("control"), 3).unwrap();
let first = pool.capture(&target, &["a.txt"], record_for).unwrap();
let second = pool.capture(&target, &["a.txt"], record_for).unwrap();
let third = pool.capture(&target, &["a.txt"], record_for).unwrap();
assert!(pool.hold(&first.backup_ref, "series A baseline").unwrap());
assert!(pool.hold(&second.backup_ref, "series B baseline").unwrap());
let error = pool.hold(&third.backup_ref, "series C").unwrap_err();
assert!(error.to_string().contains("no slot to rotate"), "{error}");
assert!(
error.to_string().contains(first.backup_ref.as_str()),
"the refusal does not say what to release: {error}"
);
assert!(
error.to_string().contains("series A baseline"),
"the refusal does not say who holds it: {error}"
);
assert!(!pool.hold(&first.backup_ref, "series A again").unwrap());
}
#[test]
fn holding_a_slot_that_is_not_here_is_refused() {
let base = scratch("held-absent");
let pool = Pool::open(&base.join("control"), 3).unwrap();
let absent = BackupRef::parse("slot-000000000009").unwrap();
let error = pool.hold(&absent, "nothing").unwrap_err();
assert!(error.to_string().contains("slot-000000000009"), "{error}");
assert!(!pool.release(&absent).unwrap());
}
#[test]
fn capture_records_the_slot_and_latest_names_it() {
let base = scratch("capture");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("a.txt"), "one").unwrap();
let pool = Pool::open(&base.join("control"), 3).unwrap();
let record = pool.capture(&target, &["a.txt"], record_for).unwrap();
assert_eq!(pool.latest().unwrap().unwrap(), record);
let payload = pool.payload_of(&record.backup_ref).unwrap();
assert_eq!(fs::read_to_string(payload.join("a.txt")).unwrap(), "one");
}
#[test]
fn slots_are_ordered_by_sequence_so_latest_is_the_last_captured() {
let base = scratch("order");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
let pool = Pool::open(&base.join("control"), 5).unwrap();
fs::write(target.join("a.txt"), "one").unwrap();
let first = pool.capture(&target, &["a.txt"], record_for).unwrap();
fs::write(target.join("a.txt"), "two").unwrap();
let second = pool.capture(&target, &["a.txt"], record_for).unwrap();
assert_eq!(
pool.latest().unwrap().unwrap().backup_ref,
second.backup_ref
);
let listed = pool.list().unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[1].backup_ref, first.backup_ref);
let payload = pool.payload_of(&first.backup_ref).unwrap();
assert_eq!(fs::read_to_string(payload.join("a.txt")).unwrap(), "one");
}
#[test]
fn a_slot_without_its_marker_is_partial_and_never_offered_for_restore() {
let base = scratch("partial");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("a.txt"), "one").unwrap();
let control = base.join("control");
let pool = Pool::open(&control, 3).unwrap();
let record = pool.capture(&target, &["a.txt"], record_for).unwrap();
let slot = control
.join(POOL_DIRECTORY_NAME)
.join(record.backup_ref.as_str());
fs::remove_file(slot.join(SLOT_MARKER_NAME)).unwrap();
assert_eq!(pool.partial_slots().unwrap(), vec![slot]);
assert!(pool.list().unwrap().is_empty());
assert_eq!(
pool.payload_of(&record.backup_ref).unwrap_err().reason(),
ReasonCode::IntegrityMismatch
);
}
#[test]
fn pruning_bounds_completed_slots_and_leaves_partial_evidence_alone() {
let base = scratch("prune");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
let control = base.join("control");
let pool = Pool::open(&control, 2).unwrap();
for index in 0..4 {
fs::write(target.join("a.txt"), format!("{index}")).unwrap();
pool.capture(&target, &["a.txt"], record_for).unwrap();
}
let listed = pool.list().unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].backup_ref, BackupRef::from_sequence(4));
assert_eq!(listed[1].backup_ref, BackupRef::from_sequence(3));
let partial = control.join(POOL_DIRECTORY_NAME).join("slot-000000000009");
fs::create_dir_all(partial.join(SLOT_PAYLOAD_NAME)).unwrap();
pool.prune().unwrap();
assert!(partial.exists());
}
#[test]
fn only_the_named_paths_are_captured() {
let base = scratch("included");
let target = base.join("target");
fs::create_dir_all(target.join("skills")).unwrap();
fs::create_dir_all(target.join("downloads")).unwrap();
fs::write(target.join("config.toml"), "kept").unwrap();
fs::write(target.join("skills/a.md"), "kept too").unwrap();
fs::write(target.join("downloads/huge.bin"), "not ours").unwrap();
fs::write(target.join("auth.json"), "secret").unwrap();
let pool = Pool::open(&base.join("ctl"), 3).unwrap();
let record = pool
.capture(&target, &["config.toml", "skills"], record_for)
.unwrap();
let payload = pool.payload_of(&record.backup_ref).unwrap();
assert_eq!(
fs::read_to_string(payload.join("config.toml")).unwrap(),
"kept"
);
assert_eq!(
fs::read_to_string(payload.join("skills/a.md")).unwrap(),
"kept too"
);
assert!(!payload.join("downloads").exists());
assert!(!payload.join("auth.json").exists());
}
#[test]
fn a_path_the_target_does_not_hold_is_skipped_not_fatal() {
let base = scratch("included-absent");
let target = base.join("target");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("config.toml"), "here").unwrap();
let pool = Pool::open(&base.join("ctl"), 3).unwrap();
let record = pool
.capture(
&target,
&["config.toml", "never-created", "skills"],
record_for,
)
.unwrap();
let payload = pool.payload_of(&record.backup_ref).unwrap();
assert_eq!(
fs::read_to_string(payload.join("config.toml")).unwrap(),
"here"
);
assert!(!payload.join("skills").exists());
}
#[cfg(unix)]
#[test]
fn a_symbolic_link_is_refused_rather_than_inlined() {
let base = scratch("symlink");
let target = base.join("target");
fs::create_dir_all(target.join("skills")).unwrap();
fs::write(base.join("outside.txt"), "secret").unwrap();
std::os::unix::fs::symlink(base.join("outside.txt"), target.join("link.txt")).unwrap();
std::os::unix::fs::symlink(base.join("outside.txt"), target.join("skills/deep.md"))
.unwrap();
let pool = Pool::open(&base.join("control"), 2).unwrap();
let named = pool
.capture(&target, &["link.txt"], record_for)
.unwrap_err();
assert_eq!(named.reason(), ReasonCode::IntegrityMismatch);
let walked = pool.capture(&target, &["skills"], record_for).unwrap_err();
assert_eq!(walked.reason(), ReasonCode::IntegrityMismatch);
}
#[cfg(unix)]
#[test]
fn a_symbolic_link_outside_the_captured_paths_is_simply_not_seen() {
let base = scratch("symlink-outside");
let target = base.join("target");
fs::create_dir_all(target.join("bin")).unwrap();
fs::write(base.join("outside.txt"), "runtime").unwrap();
std::os::unix::fs::symlink(base.join("outside.txt"), target.join("bin/grok")).unwrap();
fs::write(target.join("config.toml"), "ours").unwrap();
let pool = Pool::open(&base.join("control"), 2).unwrap();
let record = pool.capture(&target, &["config.toml"], record_for).unwrap();
let payload = pool.payload_of(&record.backup_ref).unwrap();
assert_eq!(
fs::read_to_string(payload.join("config.toml")).unwrap(),
"ours"
);
assert!(!payload.join("bin").exists());
}
}