use std::collections::{BTreeSet, HashSet};
use std::fs::File;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::contained::Contained;
use crate::create::PAYLOAD_PREFIX;
use crate::error::PackError;
use crate::manifest::{
CacheRecord, Manifest, SkipRecord, SymlinkRecord, WorktreeOrigin, WorktreeRecord,
};
use crate::scan::canonicalize_or;
#[derive(Debug, Clone)]
pub struct RestoreOptions {
pub archive: PathBuf,
pub dest: PathBuf,
pub force: bool,
pub dry_run: bool,
}
impl RestoreOptions {
pub fn new(archive: impl Into<PathBuf>, dest: impl Into<PathBuf>) -> Self {
Self {
archive: archive.into(),
dest: dest.into(),
force: false,
dry_run: false,
}
}
}
#[derive(Debug, Clone)]
pub struct RestoreReport {
pub dest: PathBuf,
pub manifest: Manifest,
pub dry_run: bool,
pub entries_written: u64,
pub destination_exists: bool,
pub would_overwrite: Vec<String>,
pub would_remain: Vec<String>,
pub rewritten_worktrees: Vec<String>,
pub missing_worktrees: Vec<String>,
pub conflicting_worktrees: Vec<WorktreeConflict>,
pub missing_worktree_parent: Option<String>,
pub dangling_symlinks: Vec<SymlinkRecord>,
pub link_reports_suppressed: Vec<String>,
pub regenerable_caches: Vec<CacheRecord>,
pub secrets_not_carried: Vec<SkipRecord>,
pub hard_links_not_created: Vec<HardLinkRecord>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HardLinkRecord {
pub path: String,
pub target: String,
pub command: String,
}
impl HardLinkRecord {
fn new(dest: &Path, rel: &Path, target: &str) -> Self {
let at = dest.join(rel);
Self {
path: rel.display().to_string(),
target: target.to_string(),
command: format!(
"ln {} {}",
shell_quote(&resolve_link_target(dest, target)),
shell_quote(&at.display().to_string())
),
}
}
}
fn resolve_link_target(dest: &Path, target: &str) -> String {
Path::new(target)
.strip_prefix(PAYLOAD_PREFIX)
.ok()
.and_then(|rel| Contained::entry(rel).ok())
.map_or_else(
|| target.to_string(),
|rel| rel.join_onto(dest).display().to_string(),
)
}
fn shell_quote(raw: &str) -> String {
format!("'{}'", raw.replace('\'', r"'\''"))
}
#[derive(Debug, Clone, Serialize)]
pub struct WorktreeConflict {
pub name: String,
pub path: String,
pub found: String,
}
impl RestoreReport {
pub fn needs_attention(&self) -> bool {
!self.dangling_symlinks.is_empty()
|| !self.missing_worktrees.is_empty()
|| !self.conflicting_worktrees.is_empty()
|| self.missing_worktree_parent.is_some()
|| !self.secrets_not_carried.is_empty()
|| !self.would_overwrite.is_empty()
|| !self.would_remain.is_empty()
|| !self.hard_links_not_created.is_empty()
}
}
pub fn restore(opts: &RestoreOptions) -> Result<RestoreReport, PackError> {
let manifest = crate::inspect::verify(&opts.archive)?;
let checked = check_archive_paths(&manifest)?;
let destination_exists = opts.dest.exists();
if opts.dry_run {
return predict(opts, &checked, &manifest, destination_exists);
}
if destination_exists && !opts.force {
return Err(PackError::DestinationExists(opts.dest.clone()));
}
std::fs::create_dir_all(&opts.dest)?;
let dest = resolve_dest(&opts.dest);
let (entries_written, hard_links_not_created) = unpack_payload(&opts.archive, &dest)?;
let plan = plan_worktree_pointers(&dest, &checked, &manifest, true);
let rewritten_worktrees = apply_worktree_plan(&plan)?;
let dangling_symlinks = manifest
.symlinks
.iter()
.filter(|s| is_dangling(&dest, s))
.cloned()
.collect();
let link_reports_suppressed = manifest.no_link_report_applied.clone();
Ok(RestoreReport {
dest,
dry_run: false,
entries_written,
destination_exists,
would_overwrite: Vec::new(),
would_remain: Vec::new(),
rewritten_worktrees,
missing_worktrees: plan.missing,
conflicting_worktrees: plan.conflicted,
missing_worktree_parent: plan.missing_parent,
dangling_symlinks,
link_reports_suppressed,
regenerable_caches: manifest.skipped_cache.clone(),
secrets_not_carried: manifest.skipped_secret.clone(),
hard_links_not_created,
manifest,
})
}
fn predict(
opts: &RestoreOptions,
checked: &Checked<'_>,
manifest: &Manifest,
destination_exists: bool,
) -> Result<RestoreReport, PackError> {
let dest = resolve_dest(&opts.dest);
let payload = crate::inspect::scan_payload(&opts.archive)?;
let payload_set: BTreeSet<&str> = payload.paths.iter().map(|s| s.as_str()).collect();
let hard_links_not_created = payload
.hard_links
.iter()
.map(|(rel, target)| HardLinkRecord::new(&dest, Path::new(rel), target))
.collect();
let (would_overwrite, would_remain) = if destination_exists {
compare_destination(&dest, &payload_set)
} else {
(Vec::new(), Vec::new())
};
let plan = plan_worktree_pointers(&dest, checked, manifest, false);
let rewritten_worktrees = plan.pairs.iter().map(|p| p.name.clone()).collect();
let dangling_symlinks = manifest
.symlinks
.iter()
.filter(|s| would_dangle(&dest, s, &payload_set))
.cloned()
.collect();
let link_reports_suppressed = manifest.no_link_report_applied.clone();
Ok(RestoreReport {
dest,
dry_run: true,
entries_written: payload.paths.len() as u64,
destination_exists,
would_overwrite,
would_remain,
rewritten_worktrees,
missing_worktrees: plan.missing,
conflicting_worktrees: plan.conflicted,
missing_worktree_parent: plan.missing_parent,
dangling_symlinks,
link_reports_suppressed,
regenerable_caches: manifest.skipped_cache.clone(),
secrets_not_carried: manifest.skipped_secret.clone(),
hard_links_not_created,
manifest: manifest.clone(),
})
}
fn resolve_dest(dest: &Path) -> PathBuf {
let absolute = std::path::absolute(dest).unwrap_or_else(|_| dest.to_path_buf());
let mut missing = Vec::new();
let mut cursor = absolute.as_path();
loop {
if let Ok(existing) = std::fs::canonicalize(cursor) {
let mut resolved = existing;
resolved.extend(missing.iter().rev());
return resolved;
}
let (Some(parent), Some(name)) = (cursor.parent(), cursor.file_name()) else {
return absolute;
};
missing.push(name.to_os_string());
cursor = parent;
}
}
fn compare_destination(dest: &Path, incoming: &BTreeSet<&str>) -> (Vec<String>, Vec<String>) {
let mut overwrite = Vec::new();
let mut remain = Vec::new();
let walker = walkdir::WalkDir::new(dest)
.follow_links(false)
.min_depth(1)
.sort_by_file_name();
for entry in walker.into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_dir() {
continue;
}
let Ok(rel) = entry.path().strip_prefix(dest) else {
continue;
};
let rel = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if rel.is_empty() {
continue;
}
if incoming.contains(rel.as_str()) {
overwrite.push(rel);
} else {
remain.push(rel);
}
}
(overwrite, remain)
}
fn would_dangle(dest: &Path, record: &SymlinkRecord, payload: &BTreeSet<&str>) -> bool {
let target = Path::new(&record.target);
if target.is_absolute() {
return !target.exists();
}
let link_parent = Path::new(&record.path).parent().unwrap_or(Path::new(""));
let resolved = crate::scan::normalize(&link_parent.join(target));
let as_key = resolved
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if payload.contains(as_key.as_str()) {
return false;
}
!dest.join(&resolved).exists()
}
pub(crate) enum EntryPlan {
Extract,
HardLink,
Refuse(&'static str),
}
pub(crate) fn entry_plan(kind: tar::EntryType) -> EntryPlan {
use tar::EntryType as T;
match kind {
T::Regular | T::Continuous | T::Directory | T::Symlink => EntryPlan::Extract,
T::Link => EntryPlan::HardLink,
T::Char => EntryPlan::Refuse("character device"),
T::Block => EntryPlan::Refuse("block device"),
T::Fifo => EntryPlan::Refuse("named pipe"),
T::GNUSparse => EntryPlan::Refuse("sparse file"),
T::GNULongName | T::GNULongLink | T::XHeader | T::XGlobalHeader => {
EntryPlan::Refuse("stray extension header")
}
_ => EntryPlan::Refuse("entry of an unrecognized type"),
}
}
fn unpack_payload(archive: &Path, dest: &Path) -> Result<(u64, Vec<HardLinkRecord>), PackError> {
let file = File::open(archive)?;
let decoder = zstd::stream::Decoder::new(file)?;
let mut tar = tar::Archive::new(decoder);
let mut real_dirs: HashSet<PathBuf> = HashSet::new();
let mut written = 0u64;
let mut hard_links = Vec::new();
for entry in tar.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_path_buf();
let Ok(rel) = path.strip_prefix(PAYLOAD_PREFIX) else {
continue;
};
if rel.as_os_str().is_empty() {
continue;
}
let rel = Contained::entry(rel)?;
match entry_plan(entry.header().entry_type()) {
EntryPlan::Extract => {}
EntryPlan::HardLink => {
let target = entry.link_name()?.map(|t| t.display().to_string());
let Some(target) = target.filter(|t| !t.is_empty()) else {
return Err(PackError::UnusableArchiveEntry {
path: rel.as_path().display().to_string(),
kind: "hard link naming no target".to_string(),
});
};
hard_links.push(HardLinkRecord::new(dest, rel.as_path(), &target));
continue;
}
EntryPlan::Refuse(kind) => {
return Err(PackError::UnusableArchiveEntry {
path: rel.as_path().display().to_string(),
kind: kind.to_string(),
});
}
}
ensure_real_ancestors(dest, rel.as_path(), &mut real_dirs)?;
let out = rel.join_onto(dest);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
if out.is_symlink() {
std::fs::remove_file(&out)?;
}
entry.unpack(&out)?;
written += 1;
}
Ok((written, hard_links))
}
fn ensure_real_ancestors(
dest: &Path,
rel: &Path,
real_dirs: &mut HashSet<PathBuf>,
) -> Result<(), PackError> {
let Some(parent) = rel.parent() else {
return Ok(());
};
let mut cur = dest.to_path_buf();
for component in parent.components() {
cur.push(component);
if real_dirs.contains(&cur) {
continue;
}
match std::fs::symlink_metadata(&cur) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(PackError::WriteThroughSymlink {
path: rel.display().to_string(),
via: cur,
});
}
Ok(_) => {
real_dirs.insert(cur.clone());
}
Err(_) => {}
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct PointerPair {
name: String,
admin: PathBuf,
dot_git: PathBuf,
}
#[derive(Debug, Default)]
struct WorktreePlan {
pairs: Vec<PointerPair>,
missing: Vec<String>,
conflicted: Vec<WorktreeConflict>,
missing_parent: Option<String>,
}
struct Checked<'a> {
worktrees: Vec<CheckedWorktree<'a>>,
origin: Option<CheckedOrigin<'a>>,
}
struct CheckedWorktree<'a> {
name: Contained,
path: Option<Contained>,
record: &'a WorktreeRecord,
}
struct CheckedOrigin<'a> {
name: Contained,
origin: &'a WorktreeOrigin,
}
fn check_archive_paths(manifest: &Manifest) -> Result<Checked<'_>, PackError> {
let mut worktrees = Vec::with_capacity(manifest.worktrees.len());
for record in &manifest.worktrees {
worktrees.push(CheckedWorktree {
name: Contained::name("worktrees[].name", &record.name)?,
path: record
.path
.as_deref()
.map(|raw| Contained::path("worktrees[].path", raw))
.transpose()?,
record,
});
}
let origin = match &manifest.worktree_of {
Some(origin) => Some(CheckedOrigin {
name: Contained::name("worktree_of.name", &origin.name)?,
origin,
}),
None => None,
};
for link in &manifest.symlinks {
Contained::path("symlinks[].path", &link.path)?;
}
Ok(Checked { worktrees, origin })
}
enum Wiring {
Wire(PointerPair),
Occupied(WorktreeConflict),
Absent,
Nothing,
}
fn plan_worktree_pointers(
dest: &Path,
checked: &Checked<'_>,
manifest: &Manifest,
unpacked: bool,
) -> WorktreePlan {
let mut plan = WorktreePlan::default();
for worktree in &checked.worktrees {
match decide_worktree(dest, worktree, manifest, unpacked) {
Wiring::Wire(pair) => plan.pairs.push(pair),
Wiring::Occupied(conflict) => plan.conflicted.push(conflict),
Wiring::Absent => plan.missing.push(worktree.record.name.clone()),
Wiring::Nothing => {}
}
}
if let Some(origin) = &checked.origin {
match decide_origin(dest, origin, manifest, unpacked) {
Wiring::Wire(pair) => plan.pairs.push(pair),
Wiring::Occupied(conflict) => plan.conflicted.push(conflict),
Wiring::Absent => plan.missing_parent = Some(origin.origin.parent_root.clone()),
Wiring::Nothing => {}
}
}
plan
}
fn decide_worktree(
dest: &Path,
worktree: &CheckedWorktree<'_>,
manifest: &Manifest,
unpacked: bool,
) -> Wiring {
let admin = worktree
.name
.join_onto(&dest.join(".git").join("worktrees"));
if let Some(rel) = &worktree.path {
let worktree_root = rel.join_onto(dest);
if unpacked && (!admin.is_dir() || !worktree_root.is_dir()) {
return Wiring::Nothing;
}
return Wiring::Wire(PointerPair {
name: worktree.record.name.clone(),
admin,
dot_git: worktree_root.join(".git"),
});
}
let Some(candidate) =
relocate_beside(&manifest.source_root, dest, &worktree.record.source_path)
else {
return Wiring::Absent;
};
let dot_git = candidate.join(".git");
if !dot_git.exists() || (unpacked && !admin.is_dir()) {
return Wiring::Absent;
}
if dot_git.is_dir() {
return Wiring::Occupied(WorktreeConflict {
name: worktree.record.name.clone(),
path: candidate.display().to_string(),
found: "an independent repository — its `.git` is a directory".to_string(),
});
}
let old_admin = worktree.name.join_onto(
&Path::new(&manifest.source_root)
.join(".git")
.join("worktrees"),
);
match pointer_target(&dot_git) {
Some(claimed) if same_place(&claimed, &old_admin) || same_place(&claimed, &admin) => {
Wiring::Wire(PointerPair {
name: worktree.record.name.clone(),
admin,
dot_git,
})
}
Some(claimed) => Wiring::Occupied(WorktreeConflict {
name: worktree.record.name.clone(),
path: candidate.display().to_string(),
found: format!(
"a worktree of a different repository — its `.git` names {}",
claimed.display()
),
}),
None => Wiring::Occupied(WorktreeConflict {
name: worktree.record.name.clone(),
path: candidate.display().to_string(),
found: "an unreadable or unrecognized `.git` file".to_string(),
}),
}
}
fn decide_origin(
dest: &Path,
origin: &CheckedOrigin<'_>,
manifest: &Manifest,
unpacked: bool,
) -> Wiring {
let dot_git = dest.join(".git");
let old_dot_git = Path::new(&manifest.source_root).join(".git");
let admin = relocate_beside(&manifest.source_root, dest, &origin.origin.parent_root)
.map(|root| origin.name.join_onto(&root.join(".git").join("worktrees")))
.filter(|admin| admin.is_dir());
let Some(admin) = admin else {
return Wiring::Absent;
};
match pointer_target(&admin.join("gitdir")) {
Some(claimed) if same_place(&claimed, &old_dot_git) || same_place(&claimed, &dot_git) => {
if !unpacked || dot_git.is_file() {
Wiring::Wire(PointerPair {
name: origin.origin.name.clone(),
admin,
dot_git,
})
} else {
Wiring::Absent
}
}
claimed => Wiring::Occupied(WorktreeConflict {
name: origin.origin.name.clone(),
path: admin.display().to_string(),
found: match claimed {
Some(other) => format!(
"a same-named worktree of a different checkout — its `gitdir` names {}",
other.display()
),
None => "an admin directory with no readable `gitdir`".to_string(),
},
}),
}
}
fn pointer_target(file: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(file).ok()?;
let trimmed = text.trim();
let path = trimmed
.strip_prefix("gitdir:")
.map(str::trim)
.unwrap_or(trimmed);
if path.is_empty() {
None
} else {
Some(PathBuf::from(path))
}
}
fn same_place(claimed: &Path, expected: &Path) -> bool {
canonicalize_or(claimed) == canonicalize_or(expected)
}
fn relocate_beside(source_root: &str, dest: &Path, original: &str) -> Option<PathBuf> {
let source_parent = Path::new(source_root).parent()?;
let rel = Path::new(original).strip_prefix(source_parent).ok()?;
Some(dest.parent()?.join(rel))
}
fn apply_worktree_plan(plan: &WorktreePlan) -> Result<Vec<String>, PackError> {
let mut rewritten = Vec::new();
for pair in &plan.pairs {
std::fs::write(
pair.admin.join("gitdir"),
format!("{}\n", pair.dot_git.display()),
)?;
std::fs::write(&pair.dot_git, format!("gitdir: {}\n", pair.admin.display()))?;
rewritten.push(pair.name.clone());
}
Ok(rewritten)
}
fn is_dangling(dest: &Path, record: &SymlinkRecord) -> bool {
let link = dest.join(&record.path);
if !link.is_symlink() {
return false;
}
!link.exists()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::create::{CreateOptions, create};
use std::fs;
use tempfile::TempDir;
fn touch(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(path, body).expect("write");
}
#[test]
fn test_round_trip_preserves_content() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("src/main.rs"), "fn main() {}");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
touch(&root.join("workspace/journal.md"), "# journal\n");
touch(&root.join("workspace/.journal.db"), "sqlite");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert_eq!(
fs::read_to_string(dest.join("src/main.rs")).expect("read"),
"fn main() {}"
);
assert_eq!(
fs::read_to_string(dest.join(".git/HEAD")).expect("read"),
"ref: refs/heads/main\n"
);
assert_eq!(
fs::read_to_string(dest.join("workspace/.journal.db")).expect("read"),
"sqlite",
"local state must survive the round trip"
);
assert!(report.entries_written > 0);
}
#[test]
fn test_restore_refuses_existing_destination() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("existing");
fs::create_dir_all(&dest).expect("mkdir");
assert!(matches!(
restore(&RestoreOptions::new(&out, &dest)),
Err(PackError::DestinationExists(_))
));
let forced = RestoreOptions {
force: true,
..RestoreOptions::new(&out, &dest)
};
restore(&forced).expect("force should proceed");
assert!(dest.join("a.txt").is_file());
}
#[test]
fn test_restore_rewrites_worktree_pointers() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
let wt = root.join(".worktrees/feature");
touch(&wt.join("file.txt"), "work");
let admin = root.join(".git/worktrees/feature");
fs::create_dir_all(&admin).expect("mkdir");
fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.expect("write");
fs::write(admin.join("commondir"), "../..\n").expect("write");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("moved");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert_eq!(report.rewritten_worktrees, vec!["feature".to_string()]);
let new_admin_gitdir =
fs::read_to_string(dest.join(".git/worktrees/feature/gitdir")).expect("read");
let new_dot_git = fs::read_to_string(dest.join(".worktrees/feature/.git")).expect("read");
let dest_real = fs::canonicalize(&dest).expect("canonicalize");
assert!(
new_admin_gitdir
.trim()
.starts_with(&dest_real.to_string_lossy().to_string()),
"gitdir must point into the new root, got {new_admin_gitdir}"
);
assert!(
new_dot_git
.trim()
.contains(&dest_real.to_string_lossy().to_string()),
"worktree .git must point into the new root, got {new_dot_git}"
);
assert!(
!new_admin_gitdir.contains("/proj/"),
"stale source path must not survive: {new_admin_gitdir}"
);
}
fn sibling_worktree(base: &Path) -> (PathBuf, PathBuf) {
let root = base.join("proj");
let wt = base.join("proj-feature");
let admin = root.join(".git/worktrees/feature");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
fs::create_dir_all(&admin).expect("mkdir");
fs::write(admin.join("commondir"), "../..\n").expect("write");
touch(&wt.join("work.txt"), "w");
fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.expect("write");
fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
(root, wt)
}
fn pack(root: &Path, out: &Path) {
create(&CreateOptions::new(root, out, "0.14.0")).expect("create");
}
fn pointer(path: &Path) -> String {
fs::read_to_string(path).expect("read").trim().to_string()
}
#[test]
fn test_restore_wires_sibling_worktree_into_new_location() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (root, wt) = sibling_worktree(&src);
let root_pack = dir.path().join("proj.pack");
let wt_pack = dir.path().join("proj-feature.pack");
pack(&root, &root_pack);
pack(&wt, &wt_pack);
let moved = dir.path().join("moved");
let new_wt = moved.join("proj-feature");
let new_root = moved.join("proj");
let wt_report = restore(&RestoreOptions::new(&wt_pack, &new_wt)).expect("restore worktree");
assert!(wt_report.rewritten_worktrees.is_empty());
let source_root = fs::canonicalize(&root)
.expect("canonicalize")
.to_string_lossy()
.into_owned();
assert_eq!(
wt_report.missing_worktree_parent.as_deref(),
Some(source_root.as_str()),
"the report must name the repository this checkout belongs to"
);
assert!(wt_report.needs_attention());
let root_report =
restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore root");
assert_eq!(root_report.rewritten_worktrees, vec!["feature".to_string()]);
assert!(root_report.missing_worktrees.is_empty());
let real_root = fs::canonicalize(&new_root).expect("canonicalize");
let real_wt = fs::canonicalize(&new_wt).expect("canonicalize");
assert_eq!(
pointer(&real_root.join(".git/worktrees/feature/gitdir")),
real_wt.join(".git").display().to_string(),
"the repository must name the worktree where it now is"
);
assert_eq!(
pointer(&real_wt.join(".git")),
format!(
"gitdir: {}",
real_root.join(".git/worktrees/feature").display()
),
"and the worktree must name the repository where it now is"
);
}
#[test]
fn test_restore_wiring_is_order_independent() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (root, wt) = sibling_worktree(&src);
let root_pack = dir.path().join("proj.pack");
let wt_pack = dir.path().join("proj-feature.pack");
pack(&root, &root_pack);
pack(&wt, &wt_pack);
let moved = dir.path().join("moved");
let new_root = moved.join("proj");
let new_wt = moved.join("proj-feature");
let first = restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore root");
assert!(first.rewritten_worktrees.is_empty());
assert_eq!(first.missing_worktrees, vec!["feature".to_string()]);
let second = restore(&RestoreOptions::new(&wt_pack, &new_wt)).expect("restore worktree");
assert_eq!(second.rewritten_worktrees, vec!["feature".to_string()]);
assert!(second.missing_worktree_parent.is_none());
let real_root = fs::canonicalize(&new_root).expect("canonicalize");
let real_wt = fs::canonicalize(&new_wt).expect("canonicalize");
assert_eq!(
pointer(&real_root.join(".git/worktrees/feature/gitdir")),
real_wt.join(".git").display().to_string()
);
assert_eq!(
pointer(&real_wt.join(".git")),
format!(
"gitdir: {}",
real_root.join(".git/worktrees/feature").display()
)
);
}
#[test]
fn test_forced_re_restore_repairs_existing_sibling() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (root, wt) = sibling_worktree(&src);
let root_pack = dir.path().join("proj.pack");
let wt_pack = dir.path().join("proj-feature.pack");
pack(&root, &root_pack);
pack(&wt, &wt_pack);
let moved = dir.path().join("moved");
let new_root = moved.join("proj");
restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore root");
restore(&RestoreOptions::new(&wt_pack, moved.join("proj-feature")))
.expect("restore worktree");
let again = restore(&RestoreOptions {
force: true,
..RestoreOptions::new(&root_pack, &new_root)
})
.expect("re-restore");
assert_eq!(again.rewritten_worktrees, vec!["feature".to_string()]);
assert!(
again.missing_worktrees.is_empty(),
"a checkout that is right there must not be reported missing"
);
let real_root = fs::canonicalize(&new_root).expect("canonicalize");
let gitdir = pointer(&real_root.join(".git/worktrees/feature/gitdir"));
assert!(
!gitdir.contains("/projects/"),
"the source machine's path must not survive: {gitdir}"
);
}
#[test]
fn test_restore_reports_sibling_worktree_that_is_absent() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (root, _wt) = sibling_worktree(&src);
let root_pack = dir.path().join("proj.pack");
pack(&root, &root_pack);
let report = restore(&RestoreOptions::new(
&root_pack,
dir.path().join("elsewhere/proj"),
))
.expect("restore");
assert_eq!(report.missing_worktrees, vec!["feature".to_string()]);
assert!(report.rewritten_worktrees.is_empty());
assert!(report.needs_attention());
}
#[test]
fn test_restore_will_not_clobber_a_repository_at_the_sibling_path() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (root, _wt) = sibling_worktree(&src);
let root_pack = dir.path().join("proj.pack");
pack(&root, &root_pack);
let moved = dir.path().join("moved");
let squatter = moved.join("proj-feature");
touch(&squatter.join(".git/HEAD"), "ref: refs/heads/main\n");
let report =
restore(&RestoreOptions::new(&root_pack, moved.join("proj"))).expect("restore");
assert!(report.rewritten_worktrees.is_empty());
assert!(report.missing_worktrees.is_empty());
assert_eq!(report.conflicting_worktrees.len(), 1);
let conflict = &report.conflicting_worktrees[0];
assert_eq!(conflict.name, "feature");
assert!(
conflict.found.contains("independent repository"),
"the report must say what is sitting there, got {:?}",
conflict.found
);
assert!(report.needs_attention());
assert!(
squatter.join(".git").is_dir(),
"the unrelated repository must survive untouched"
);
}
#[test]
fn test_restore_will_not_rewire_a_foreign_worktree_at_the_sibling_path() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (root, _wt) = sibling_worktree(&src);
let root_pack = dir.path().join("proj.pack");
pack(&root, &root_pack);
let other_admin = dir.path().join("other/.git/worktrees/feature");
fs::create_dir_all(&other_admin).expect("mkdir");
let moved = dir.path().join("moved");
let squatter = moved.join("proj-feature");
let original_pointer = format!("gitdir: {}\n", other_admin.display());
touch(&squatter.join(".git"), &original_pointer);
let report =
restore(&RestoreOptions::new(&root_pack, moved.join("proj"))).expect("restore");
assert!(report.rewritten_worktrees.is_empty());
assert_eq!(report.conflicting_worktrees.len(), 1);
assert!(
report.conflicting_worktrees[0]
.found
.contains("different repository"),
"got {:?}",
report.conflicting_worktrees[0].found
);
assert_eq!(
fs::read_to_string(squatter.join(".git")).expect("read"),
original_pointer,
"the foreign worktree's pointer must survive untouched"
);
}
#[test]
fn test_restore_will_not_claim_a_foreign_admin_directory() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (_root, wt) = sibling_worktree(&src);
let wt_pack = dir.path().join("proj-feature.pack");
pack(&wt, &wt_pack);
let moved = dir.path().join("moved");
let foreign_admin = moved.join("proj/.git/worktrees/feature");
fs::create_dir_all(&foreign_admin).expect("mkdir");
let elsewhere = dir.path().join("elsewhere/checkout");
fs::create_dir_all(&elsewhere).expect("mkdir");
let original_gitdir = format!("{}\n", elsewhere.join(".git").display());
fs::write(foreign_admin.join("gitdir"), &original_gitdir).expect("write");
let report =
restore(&RestoreOptions::new(&wt_pack, moved.join("proj-feature"))).expect("restore");
assert!(report.rewritten_worktrees.is_empty());
assert_eq!(report.conflicting_worktrees.len(), 1);
assert!(
report.conflicting_worktrees[0]
.found
.contains("different checkout"),
"got {:?}",
report.conflicting_worktrees[0].found
);
assert!(report.missing_worktree_parent.is_none());
assert_eq!(
fs::read_to_string(foreign_admin.join("gitdir")).expect("read"),
original_gitdir,
"the foreign admin directory must survive untouched"
);
}
#[test]
fn test_restore_does_not_guess_at_a_distant_worktree() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("projects/proj");
let far = dir.path().join("somewhere/else/wt");
let admin = root.join(".git/worktrees/far");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
fs::create_dir_all(&admin).expect("mkdir");
touch(&far.join(".keep"), "");
fs::write(
admin.join("gitdir"),
format!("{}\n", far.join(".git").display()),
)
.expect("write");
fs::write(far.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
let out = dir.path().join("proj.pack");
pack(&root, &out);
let report =
restore(&RestoreOptions::new(&out, dir.path().join("moved/proj"))).expect("restore");
assert_eq!(report.missing_worktrees, vec!["far".to_string()]);
assert!(report.rewritten_worktrees.is_empty());
}
#[test]
fn test_dry_run_predicts_sibling_wiring() {
let dir = TempDir::new().expect("tempdir");
let src = dir.path().join("projects");
let (root, wt) = sibling_worktree(&src);
let root_pack = dir.path().join("proj.pack");
let wt_pack = dir.path().join("proj-feature.pack");
pack(&root, &root_pack);
pack(&wt, &wt_pack);
let moved = dir.path().join("moved");
restore(&RestoreOptions::new(&wt_pack, moved.join("proj-feature")))
.expect("restore worktree");
let new_root = moved.join("proj");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&root_pack, &new_root)
})
.expect("dry run");
assert_eq!(predicted.rewritten_worktrees, vec!["feature".to_string()]);
assert!(!new_root.exists(), "still nothing written");
let actual = restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore");
assert_eq!(predicted.rewritten_worktrees, actual.rewritten_worktrees);
assert_eq!(predicted.missing_worktrees, actual.missing_worktrees);
}
#[cfg(unix)]
#[test]
fn test_restore_reports_dangling_symlink() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
let vanishing = dir.path().join("vanishing");
fs::create_dir_all(&vanishing).expect("mkdir");
touch(&vanishing.join("target.md"), "t");
std::os::unix::fs::symlink(vanishing.join("target.md"), root.join("link.md"))
.expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
fs::remove_dir_all(&vanishing).expect("rm");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert!(dest.join("link.md").is_symlink(), "link itself is restored");
assert_eq!(report.dangling_symlinks.len(), 1);
assert_eq!(report.dangling_symlinks[0].path, "link.md");
assert!(report.needs_attention());
}
#[cfg(unix)]
#[test]
fn test_restore_does_not_report_live_symlink() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
touch(&root.join("real.txt"), "r");
std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert!(report.dangling_symlinks.is_empty());
}
#[test]
fn test_dry_run_writes_nothing() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("nowhere");
let opts = RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
};
let report = restore(&opts).expect("dry run");
assert!(report.dry_run);
assert!(!dest.exists(), "dry run must not create the destination");
assert!(
report.entries_written > 0,
"it still counts what would land"
);
assert!(!report.destination_exists);
assert!(report.would_overwrite.is_empty());
assert!(report.would_remain.is_empty());
}
#[test]
fn test_dry_run_splits_existing_destination() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("shared.txt"), "from pack");
touch(&root.join("only-in-pack.txt"), "new");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("existing");
touch(&dest.join("shared.txt"), "old content");
touch(&dest.join("only-in-dest.txt"), "leftover");
let opts = RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
};
let report = restore(&opts).expect("dry run over existing dest");
assert!(report.destination_exists);
assert_eq!(report.would_overwrite, vec!["shared.txt".to_string()]);
assert_eq!(report.would_remain, vec!["only-in-dest.txt".to_string()]);
assert!(report.needs_attention());
assert_eq!(
fs::read_to_string(dest.join("shared.txt")).expect("read"),
"old content"
);
}
#[test]
fn test_dry_run_agrees_with_real_restore() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
touch(&root.join("sub/b.txt"), "b");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
let actual = restore(&RestoreOptions::new(&out, &dest)).expect("real restore");
assert_eq!(
predicted.entries_written, actual.entries_written,
"a dry run that miscounts is worse than none"
);
assert_eq!(predicted.rewritten_worktrees, actual.rewritten_worktrees);
assert_eq!(
predicted.dangling_symlinks.len(),
actual.dangling_symlinks.len()
);
}
#[cfg(unix)]
#[test]
fn test_dry_run_predicts_dangling_symlink() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
let vanishing = dir.path().join("vanishing");
fs::create_dir_all(&vanishing).expect("mkdir");
touch(&vanishing.join("t.md"), "t");
std::os::unix::fs::symlink(vanishing.join("t.md"), root.join("link.md")).expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
fs::remove_dir_all(&vanishing).expect("rm");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
assert_eq!(predicted.dangling_symlinks.len(), 1);
assert_eq!(predicted.dangling_symlinks[0].path, "link.md");
assert!(!dest.exists(), "still nothing written");
let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert_eq!(actual.dangling_symlinks.len(), 1);
}
#[cfg(unix)]
#[test]
fn test_dry_run_does_not_predict_live_relative_link() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
fs::create_dir_all(&root).expect("mkdir");
touch(&root.join("real.txt"), "r");
std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
assert!(
predicted.dangling_symlinks.is_empty(),
"a link resolving inside the restored tree is fine"
);
}
#[test]
fn test_dry_run_announces_worktree_rewrite() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
let wt = root.join(".worktrees/feature");
touch(&wt.join("f.txt"), "w");
let admin = root.join(".git/worktrees/feature");
fs::create_dir_all(&admin).expect("mkdir");
fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.expect("write");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
assert_eq!(predicted.rewritten_worktrees, vec!["feature".to_string()]);
assert!(!dest.exists());
}
const BARE_MANIFEST: &str = "\
format_version = 2
created_at = \"2026-08-10T00:00:00Z\"
source_root = \"/tmp/proj\"
project_name = \"proj\"
lds_version = \"0.15.0\"
[stats]
file_count = 0
symlink_count = 0
total_bytes = 0
";
fn craft_archive(path: &Path, add_entries: impl FnOnce(&mut tar::Builder<Vec<u8>>)) {
craft_archive_with_manifest(path, BARE_MANIFEST, add_entries);
}
fn craft_archive_with_manifest(
path: &Path,
manifest: &str,
add_entries: impl FnOnce(&mut tar::Builder<Vec<u8>>),
) {
let mut tar = tar::Builder::new(Vec::new());
let mut h = tar::Header::new_gnu();
h.set_size(manifest.len() as u64);
h.set_mode(0o644);
h.set_cksum();
tar.append_data(&mut h, "pack.toml", manifest.as_bytes())
.expect("manifest entry");
add_entries(&mut tar);
let uncompressed = tar.into_inner().expect("finish tar");
let file = File::create(path).expect("create archive");
let mut encoder = zstd::stream::Encoder::new(file, 3).expect("zstd");
std::io::Write::write_all(&mut encoder, &uncompressed).expect("write");
encoder.finish().expect("finish zstd");
}
#[cfg(unix)]
#[test]
fn test_restore_refuses_write_through_planted_symlink() {
let dir = TempDir::new().expect("tempdir");
let outside = dir.path().join("outside");
fs::create_dir_all(&outside).expect("mkdir");
let archive = dir.path().join("evil.pack");
let outside_for_closure = outside.clone();
craft_archive(&archive, |tar| {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Symlink);
h.set_size(0);
h.set_mode(0o777);
h.set_cksum();
tar.append_link(&mut h, "payload/link", &outside_for_closure)
.expect("symlink entry");
let mut h = tar::Header::new_gnu();
h.set_size(4);
h.set_mode(0o644);
h.set_cksum();
tar.append_data(&mut h, "payload/link/evil.txt", &b"pwnd"[..])
.expect("file entry");
});
let dest = dir.path().join("dest");
let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
assert!(
matches!(err, PackError::WriteThroughSymlink { .. }),
"got {err:?}"
);
assert!(
!outside.join("evil.txt").exists(),
"the write must not have escaped through the link"
);
}
#[test]
fn test_restore_refuses_worktree_path_pointing_outside() {
let dir = TempDir::new().expect("tempdir");
let victim = dir.path().join("victim");
fs::create_dir_all(&victim).expect("mkdir");
let victim_git = victim.join(".git");
fs::write(&victim_git, "gitdir: /somewhere/real\n").expect("write");
let manifest = format!(
"{BARE_MANIFEST}
[[worktrees]]
name = \"feature\"
path = \"{}\"
source_path = \"/tmp/proj/.worktrees/feature\"
included = true
",
victim.display()
);
let archive = dir.path().join("evil.pack");
craft_archive_with_manifest(&archive, &manifest, |tar| {
let mut h = tar::Header::new_gnu();
h.set_size(1);
h.set_mode(0o644);
h.set_cksum();
tar.append_data(&mut h, "payload/a.txt", &b"a"[..])
.expect("file entry");
});
let dest = dir.path().join("dest");
let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
assert!(
matches!(err, PackError::EscapingManifestPath { ref field, .. } if field == "worktrees[].path"),
"got {err:?}"
);
assert_eq!(
fs::read_to_string(&victim_git).expect("read"),
"gitdir: /somewhere/real\n",
"the other project's pointer must be exactly as it was"
);
assert!(
!dest.exists(),
"the manifest is checked before the payload is touched"
);
}
#[test]
fn test_restore_refuses_worktree_name_pointing_outside() {
let dir = TempDir::new().expect("tempdir");
let manifest = format!(
"{BARE_MANIFEST}
[[worktrees]]
name = \"../../../escape\"
path = \".worktrees/feature\"
source_path = \"/tmp/proj/.worktrees/feature\"
included = true
"
);
let archive = dir.path().join("evil.pack");
craft_archive_with_manifest(&archive, &manifest, |_| {});
let dest = dir.path().join("dest");
let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
assert!(
matches!(err, PackError::EscapingManifestPath { ref field, .. } if field == "worktrees[].name"),
"got {err:?}"
);
}
#[test]
fn test_dry_run_refuses_what_restore_refuses() {
let dir = TempDir::new().expect("tempdir");
let manifest = format!(
"{BARE_MANIFEST}
[[worktrees]]
name = \"feature\"
path = \"../outside\"
source_path = \"/tmp/proj/.worktrees/feature\"
included = true
"
);
let archive = dir.path().join("evil.pack");
craft_archive_with_manifest(&archive, &manifest, |_| {});
let err = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&archive, dir.path().join("dest"))
})
.expect_err("must refuse");
assert!(
matches!(err, PackError::EscapingManifestPath { .. }),
"got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn test_restore_reports_hard_link_without_creating_it() {
let dir = TempDir::new().expect("tempdir");
let secret = dir.path().join("private.key");
fs::write(&secret, "PRIVATE").expect("write");
let archive = dir.path().join("linky.pack");
let secret_for_closure = secret.clone();
craft_archive(&archive, |tar| {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Link);
h.set_size(0);
h.set_mode(0o644);
h.set_cksum();
tar.append_link(&mut h, "payload/borrowed", &secret_for_closure)
.expect("hard link entry");
});
let dest = dir.path().join("dest");
let report = restore(&RestoreOptions::new(&archive, &dest)).expect("restore");
assert!(
!dest.join("borrowed").exists(),
"the link must not have been created"
);
assert_eq!(report.hard_links_not_created.len(), 1);
let link = &report.hard_links_not_created[0];
assert_eq!(link.path, "borrowed");
assert_eq!(link.target, secret.display().to_string());
assert!(
link.command.starts_with("ln '"),
"the report has to carry a runnable command, got {}",
link.command
);
assert!(link.command.contains(&secret.display().to_string()));
assert!(
report.needs_attention(),
"a path the archive listed and the restore did not create is not silent"
);
}
#[cfg(unix)]
#[test]
fn test_hard_link_into_the_payload_resolves_to_the_restored_file() {
let dir = TempDir::new().expect("tempdir");
let archive = dir.path().join("linky.pack");
craft_archive(&archive, |tar| {
let mut h = tar::Header::new_gnu();
h.set_size(2);
h.set_mode(0o644);
h.set_cksum();
tar.append_data(&mut h, "payload/b.txt", &b"b\n"[..])
.expect("file entry");
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Link);
h.set_size(0);
h.set_mode(0o644);
h.set_cksum();
tar.append_link(&mut h, "payload/a.txt", "payload/b.txt")
.expect("hard link entry");
});
let dest = dir.path().join("dest");
let report = restore(&RestoreOptions::new(&archive, &dest)).expect("restore");
let link = &report.hard_links_not_created[0];
assert_eq!(
link.target, "payload/b.txt",
"the target is reported as the archive wrote it"
);
assert_eq!(
link.command,
format!(
"ln {} {}",
shell_quote(&report.dest.join("b.txt").display().to_string()),
shell_quote(&report.dest.join("a.txt").display().to_string())
),
"but the command has to name the file that actually landed"
);
assert!(dest.join("b.txt").is_file(), "the real entry is restored");
assert!(!dest.join("a.txt").exists());
}
#[cfg(unix)]
#[test]
fn test_hard_link_outside_the_payload_keeps_its_target() {
let dir = TempDir::new().expect("tempdir");
let archive = dir.path().join("linky.pack");
craft_archive(&archive, |tar| {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Link);
h.set_size(0);
h.set_mode(0o644);
h.set_cksum();
tar.append_link(&mut h, "payload/borrowed", "/etc/hosts")
.expect("hard link entry");
});
let dest = dir.path().join("dest");
let report = restore(&RestoreOptions::new(&archive, &dest)).expect("restore");
let link = &report.hard_links_not_created[0];
assert_eq!(link.target, "/etc/hosts");
assert!(
link.command.contains("'/etc/hosts'"),
"got {}",
link.command
);
}
#[cfg(unix)]
#[test]
fn test_dry_run_reports_hard_link() {
let dir = TempDir::new().expect("tempdir");
let target = dir.path().join("elsewhere.txt");
fs::write(&target, "x").expect("write");
let archive = dir.path().join("linky.pack");
let target_for_closure = target.clone();
craft_archive(&archive, |tar| {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Link);
h.set_size(0);
h.set_mode(0o644);
h.set_cksum();
tar.append_link(&mut h, "payload/borrowed", &target_for_closure)
.expect("hard link entry");
});
let dest = dir.path().join("dest");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&archive, &dest)
})
.expect("dry run");
assert_eq!(predicted.hard_links_not_created.len(), 1);
assert_eq!(predicted.hard_links_not_created[0].path, "borrowed");
assert_eq!(
predicted.entries_written, 0,
"a hard link is not an entry that will be written"
);
assert!(!dest.exists());
}
#[test]
fn test_restore_refuses_device_entry() {
let dir = TempDir::new().expect("tempdir");
let archive = dir.path().join("odd.pack");
craft_archive(&archive, |tar| {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Fifo);
h.set_size(0);
h.set_mode(0o644);
h.set_cksum();
tar.append_data(&mut h, "payload/pipe", &b""[..])
.expect("fifo entry");
});
let dest = dir.path().join("dest");
let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
assert!(
matches!(err, PackError::UnusableArchiveEntry { ref kind, .. } if kind == "named pipe"),
"got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn test_dry_run_and_restore_agree_on_the_destination() {
let dir = TempDir::new().expect("tempdir");
let real = dir.path().join("real");
fs::create_dir_all(&real).expect("mkdir");
let via_link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &via_link).expect("symlink");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = via_link.join("restored");
let predicted = restore(&RestoreOptions {
dry_run: true,
..RestoreOptions::new(&out, &dest)
})
.expect("dry run");
assert!(fs::canonicalize(&dest).is_err());
assert_ne!(
predicted.dest, dest,
"the prediction has to resolve past the path as typed"
);
let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert_eq!(
predicted.dest, actual.dest,
"a prediction about another directory is not a prediction"
);
assert_eq!(
actual.dest,
fs::canonicalize(&real)
.expect("canonicalize")
.join("restored"),
"both must resolve through the link"
);
}
#[test]
fn test_inspect_refuses_an_oversized_manifest() {
let dir = TempDir::new().expect("tempdir");
let archive = dir.path().join("bomb.pack");
let bloat = "# ".repeat(40 * 1024 * 1024);
let manifest = format!("{BARE_MANIFEST}{bloat}");
craft_archive_with_manifest(&archive, &manifest, |_| {});
let err =
restore(&RestoreOptions::new(&archive, dir.path().join("dest"))).expect_err("refuse");
assert!(
matches!(err, PackError::ManifestTooLarge { .. }),
"got {err:?}"
);
}
#[test]
fn test_restore_report_carries_skips() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path().join("proj");
touch(&root.join("a.txt"), "a");
touch(&root.join(".env"), "S=1");
touch(&root.join("target/x"), "bin");
let out = dir.path().join("proj.pack");
create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
let dest = dir.path().join("restored");
let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
assert!(report.secrets_not_carried.iter().any(|s| s.path == ".env"));
assert!(report.regenerable_caches.iter().any(|s| s.path == "target"));
assert!(!dest.join(".env").exists());
assert!(report.needs_attention());
}
}