Skip to main content

lds_pack/
restore.rs

1//! Restore a pack into a directory, then repair what a plain extract leaves
2//! broken and report what the pack could not carry.
3//!
4//! Two things survive extraction only with help:
5//!
6//! - **worktree pointers.** A registered worktree is wired with two absolute
7//!   paths — `.git/worktrees/<name>/gitdir` names the worktree's `.git` file,
8//!   and that file names the admin directory back. Both still point at the
9//!   machine the pack came from, so they are rewritten here. When the worktree
10//!   lives *beside* the project rather than inside it, those two paths are in
11//!   two different packs; the pair is wired up once both have been restored,
12//!   in whichever order that happens.
13//! - **symlinks leaving the project.** They are restored verbatim; the ones
14//!   whose targets do not exist on this machine are reported rather than
15//!   silently left broken.
16//!
17//! Restoring over an existing directory requires `force`, and that flag means
18//! *overwrite*, not *wipe*: files already present that the pack does not carry
19//! survive the restore. Nothing is deleted on the operator's behalf.
20//!
21//! One thing is deliberately *not* done: a hard link entry is reported and left
22//! uncreated, with the command that would create it. See [`HardLinkRecord`].
23//!
24//! Everything the archive says about where things go is checked before any of
25//! it is joined onto the destination. Entry names and manifest paths are the
26//! same kind of claim, and both go through `Contained`, which cannot hold a
27//! path that leaves the destination. What may be written is then decided by one
28//! exhaustive match per question — [`EntryPlan`] for an entry's type, `Wiring`
29//! for a worktree's layout — so a shape nobody has thought about yet is refused
30//! rather than falling through to a write.
31
32use std::collections::{BTreeSet, HashSet};
33use std::fs::File;
34use std::path::{Path, PathBuf};
35
36use serde::Serialize;
37
38use crate::contained::Contained;
39use crate::create::PAYLOAD_PREFIX;
40use crate::error::PackError;
41use crate::manifest::{
42    CacheRecord, Manifest, SkipRecord, SymlinkRecord, WorktreeOrigin, WorktreeRecord,
43};
44use crate::scan::canonicalize_or;
45
46/// Inputs for [`restore`].
47#[derive(Debug, Clone)]
48pub struct RestoreOptions {
49    /// Archive to restore.
50    pub archive: PathBuf,
51    /// Directory to create and unpack into.
52    pub dest: PathBuf,
53    /// Unpack into `dest` even if it already exists.
54    ///
55    /// This overwrites, it does not wipe: entries in the pack replace their
56    /// counterparts in `dest`, and files already there that the pack does not
57    /// contain are left untouched. Restoring over a working copy therefore
58    /// recovers everything the pack holds without deleting anything it does
59    /// not know about — at the cost of leaving unrelated leftovers in place.
60    pub force: bool,
61    /// Predict the restore and report it, writing nothing.
62    ///
63    /// A restore has consequences a listing of the archive cannot show: which
64    /// files in the destination would be overwritten, which would survive
65    /// untouched, which symlinks would land dangling on *this* machine, and
66    /// which worktree pointers would be rewritten. A dry run answers those
67    /// before the first byte is written.
68    ///
69    /// An existing destination is not an error during a dry run — reporting
70    /// what would collide is precisely the point — so `force` is not required
71    /// to preview one.
72    pub dry_run: bool,
73}
74
75impl RestoreOptions {
76    /// Build options that refuse to touch an existing destination.
77    pub fn new(archive: impl Into<PathBuf>, dest: impl Into<PathBuf>) -> Self {
78        Self {
79            archive: archive.into(),
80            dest: dest.into(),
81            force: false,
82            dry_run: false,
83        }
84    }
85}
86
87/// What [`restore`] did — or, on a dry run, what it would do.
88#[derive(Debug, Clone)]
89pub struct RestoreReport {
90    /// Where the project was restored, or would be.
91    pub dest: PathBuf,
92    /// Manifest read from the archive.
93    pub manifest: Manifest,
94    /// Whether this was a prediction rather than a restore.
95    pub dry_run: bool,
96    /// Number of entries written, or that would be written.
97    pub entries_written: u64,
98    /// Whether the destination already exists.
99    ///
100    /// Only meaningful on a dry run; a real restore either refused or was
101    /// given `force`.
102    pub destination_exists: bool,
103    /// Existing files the restore would replace.
104    ///
105    /// Populated on a dry run only. Empty when the destination is new.
106    pub would_overwrite: Vec<String>,
107    /// Existing files the pack does not carry, which would survive the restore.
108    ///
109    /// This is the concrete form of "`--force` overwrites, it does not wipe":
110    /// everything listed here is still there afterwards. Populated on a dry
111    /// run only.
112    pub would_remain: Vec<String>,
113    /// Worktrees whose pointer files were rewritten for the new location.
114    ///
115    /// Includes worktrees that live *beside* the root rather than inside it:
116    /// their contents travel in their own pack, but the wiring is repaired here
117    /// as soon as both halves are on this machine.
118    pub rewritten_worktrees: Vec<String>,
119    /// Worktrees registered at pack time whose checkout is not on this machine.
120    ///
121    /// Their contents are not in this pack — restore the pack that holds them
122    /// and the wiring completes itself, in either order.
123    pub missing_worktrees: Vec<String>,
124    /// Worktree pairings left unwired because the counterpart's place is
125    /// occupied by something that is not this worktree's other half.
126    ///
127    /// Nothing was written: wiring writes both pointer files, and one of them
128    /// belongs to whatever is sitting there — an unrelated repository, or a
129    /// worktree of a different one. Overwriting it would break that project to
130    /// repair this one, so the collision is reported and the decision is the
131    /// operator's.
132    pub conflicting_worktrees: Vec<WorktreeConflict>,
133    /// Set when this pack is a worktree checkout and its repository was not
134    /// found beside the restored root, leaving the checkout with no repository.
135    ///
136    /// Holds the repository's path as of pack time, as a hint for where its
137    /// pack belongs.
138    pub missing_worktree_parent: Option<String>,
139    /// Restored symlinks whose targets do not exist on this machine.
140    ///
141    /// Covers every link the pack reported. Links the pack's author declared
142    /// under `no_link_report` are restored but never checked here, which is
143    /// what [`Self::link_reports_suppressed`] exists to say.
144    pub dangling_symlinks: Vec<SymlinkRecord>,
145    /// `no_link_report` rules that were in force when this pack was written.
146    ///
147    /// Informational, and deliberately not part of [`Self::needs_attention`]:
148    /// the author declared those links expected, so there is nothing to act on.
149    /// It is reported anyway because otherwise an empty `dangling_symlinks`
150    /// reads as "every link here is fine" when it means "every link I was
151    /// allowed to look at is fine".
152    pub link_reports_suppressed: Vec<String>,
153    /// Cache directories the pack deliberately dropped; regenerate as needed.
154    ///
155    /// Each carries the file count and size that went with it, so a directory
156    /// that was named a cache by mistake is visible as one whose figures do not
157    /// look like a build tree's.
158    pub regenerable_caches: Vec<CacheRecord>,
159    /// Secrets the pack deliberately did not carry; move them out of band.
160    pub secrets_not_carried: Vec<SkipRecord>,
161    /// Hard links the archive carried, which this restore did not create.
162    ///
163    /// Each carries the command that would create it. Deciding whether to run
164    /// it is the operator's, which is the whole reason the entry is listed
165    /// here rather than acted on — see [`HardLinkRecord`].
166    pub hard_links_not_created: Vec<HardLinkRecord>,
167}
168
169/// A hard link the archive carried and the restore did not create.
170///
171/// A hard link is a second name for a file that already exists, and the archive
172/// says which one. That target is not carried in the pack and not checked
173/// against it — it names something on the machine the restore is landing on. A
174/// crafted archive can therefore name any file the operator can read, and
175/// creating the link would publish that file's contents into the restored tree
176/// under a name of the archive's choosing.
177///
178/// Nothing this crate writes produces one: the scan stores every file as its
179/// own regular entry, so two names for one inode come back as two files. A hard
180/// link entry is thus always from another producer, and there is no reading of
181/// it that makes creating it automatically the right thing to do.
182///
183/// It is also not passed over in silence. Skipping quietly would leave the
184/// restored tree missing a path the archive listed, with nothing to say why.
185/// The record names the link, the target as the archive wrote it, and a command
186/// that creates it — to be run once the operator has looked at what the target
187/// actually is.
188#[derive(Debug, Clone, Serialize)]
189pub struct HardLinkRecord {
190    /// Path of the link, relative to the restored root.
191    pub path: String,
192    /// The file the archive says the link should point at, exactly as written.
193    ///
194    /// Two shapes occur. A target inside the payload is a link to another file
195    /// in this same pack, which is what a tar writer produces for a project
196    /// that contains hard links; it reads as an archive-relative path like
197    /// `payload/b.txt`. Anything else names a file on the machine doing the
198    /// restore, and is absolute and pointing anywhere if the archive says so.
199    pub target: String,
200    /// A POSIX `ln` invocation that would create the link, both paths quoted.
201    ///
202    /// A target inside the payload is resolved to where that entry actually
203    /// landed, so the command works from any directory. One outside it is used
204    /// as written, because that is the only thing it can mean.
205    pub command: String,
206}
207
208impl HardLinkRecord {
209    /// Describe one hard link entry against the destination it would land in.
210    fn new(dest: &Path, rel: &Path, target: &str) -> Self {
211        let at = dest.join(rel);
212        Self {
213            path: rel.display().to_string(),
214            target: target.to_string(),
215            command: format!(
216                "ln {} {}",
217                shell_quote(&resolve_link_target(dest, target)),
218                shell_quote(&at.display().to_string())
219            ),
220        }
221    }
222}
223
224/// Where a hard link's target actually is, once the restore has run.
225///
226/// A target naming another payload entry is archive-relative, so quoting it
227/// into a command would aim it at whatever the operator's working directory
228/// happens to hold. It is resolved against the destination instead. A target
229/// that is not in the payload, or one that would leave the destination, is
230/// returned as written: it refers to something outside this pack, and rewriting
231/// it would be inventing a claim the archive did not make.
232fn resolve_link_target(dest: &Path, target: &str) -> String {
233    Path::new(target)
234        .strip_prefix(PAYLOAD_PREFIX)
235        .ok()
236        .and_then(|rel| Contained::entry(rel).ok())
237        .map_or_else(
238            || target.to_string(),
239            |rel| rel.join_onto(dest).display().to_string(),
240        )
241}
242
243/// Wrap a path for a POSIX shell, so a name with a space or a quote in it
244/// survives being pasted into one.
245fn shell_quote(raw: &str) -> String {
246    format!("'{}'", raw.replace('\'', r"'\''"))
247}
248
249/// A worktree pairing that was found occupied by something other than this
250/// worktree's counterpart.
251///
252/// Wiring is a write to both halves, and one of the two files belongs to
253/// whatever sits at the counterpart's place. When that occupant cannot be
254/// confirmed as this worktree's other half — its pointer names a different
255/// repository, or it is an independent repository outright — nothing is
256/// written and the collision is reported instead.
257#[derive(Debug, Clone, Serialize)]
258pub struct WorktreeConflict {
259    /// Worktree name under `.git/worktrees/`.
260    pub name: String,
261    /// The occupied path the wiring stopped at.
262    pub path: String,
263    /// What was found there.
264    pub found: String,
265}
266
267impl RestoreReport {
268    /// Whether anything needs operator attention after the restore.
269    ///
270    /// On a dry run this reads as "would need attention", and additionally
271    /// counts a destination that already holds files: proceeding there needs
272    /// `--force`, and whatever the pack does not carry stays behind.
273    pub fn needs_attention(&self) -> bool {
274        !self.dangling_symlinks.is_empty()
275            || !self.missing_worktrees.is_empty()
276            || !self.conflicting_worktrees.is_empty()
277            || self.missing_worktree_parent.is_some()
278            || !self.secrets_not_carried.is_empty()
279            || !self.would_overwrite.is_empty()
280            || !self.would_remain.is_empty()
281            || !self.hard_links_not_created.is_empty()
282    }
283}
284
285/// Restore a pack.
286///
287/// # Arguments
288///
289/// * `opts` — Archive path, destination, and whether to reuse an existing
290///   destination directory.
291///
292/// # Returns
293///
294/// A [`RestoreReport`] describing the repairs made and the follow-up the
295/// operator still owns.
296///
297/// # Errors
298///
299/// - [`PackError::DestinationExists`] if `dest` exists and `force` is unset.
300/// - [`PackError::UnsupportedFormat`] if the pack is newer than this build.
301/// - [`PackError::EscapingArchivePath`], [`PackError::EscapingManifestPath`],
302///   [`PackError::WriteThroughSymlink`] or [`PackError::UnusableArchiveEntry`]
303///   if the archive is crafted. The manifest is checked before the first byte
304///   is written, so an archive that lies about where its worktrees go is
305///   refused with the destination still empty.
306/// - [`PackError::Io`] on read or write failure.
307pub fn restore(opts: &RestoreOptions) -> Result<RestoreReport, PackError> {
308    let manifest = crate::inspect::verify(&opts.archive)?;
309    let checked = check_archive_paths(&manifest)?;
310    let destination_exists = opts.dest.exists();
311
312    if opts.dry_run {
313        return predict(opts, &checked, &manifest, destination_exists);
314    }
315
316    if destination_exists && !opts.force {
317        return Err(PackError::DestinationExists(opts.dest.clone()));
318    }
319    std::fs::create_dir_all(&opts.dest)?;
320    let dest = resolve_dest(&opts.dest);
321
322    let (entries_written, hard_links_not_created) = unpack_payload(&opts.archive, &dest)?;
323    let plan = plan_worktree_pointers(&dest, &checked, &manifest, true);
324    let rewritten_worktrees = apply_worktree_plan(&plan)?;
325
326    let dangling_symlinks = manifest
327        .symlinks
328        .iter()
329        .filter(|s| is_dangling(&dest, s))
330        .cloned()
331        .collect();
332
333    let link_reports_suppressed = manifest.no_link_report_applied.clone();
334
335    Ok(RestoreReport {
336        dest,
337        dry_run: false,
338        entries_written,
339        destination_exists,
340        would_overwrite: Vec::new(),
341        would_remain: Vec::new(),
342        rewritten_worktrees,
343        missing_worktrees: plan.missing,
344        conflicting_worktrees: plan.conflicted,
345        missing_worktree_parent: plan.missing_parent,
346        dangling_symlinks,
347        link_reports_suppressed,
348        regenerable_caches: manifest.skipped_cache.clone(),
349        secrets_not_carried: manifest.skipped_secret.clone(),
350        hard_links_not_created,
351        manifest,
352    })
353}
354
355/// Work out what a restore would do, touching nothing.
356///
357/// Everything here is derived from the archive plus the current state of the
358/// destination, so the prediction is about *this* machine — a symlink is
359/// reported as dangling because its target is absent here, not because it was
360/// absent where the pack was made.
361///
362/// The archive's paths were checked by the caller, before this and before a
363/// real restore alike, so a dry run refuses exactly the archives a restore
364/// would refuse rather than describing an operation that would abort.
365fn predict(
366    opts: &RestoreOptions,
367    checked: &Checked<'_>,
368    manifest: &Manifest,
369    destination_exists: bool,
370) -> Result<RestoreReport, PackError> {
371    let dest = resolve_dest(&opts.dest);
372    let payload = crate::inspect::scan_payload(&opts.archive)?;
373    let payload_set: BTreeSet<&str> = payload.paths.iter().map(|s| s.as_str()).collect();
374    let hard_links_not_created = payload
375        .hard_links
376        .iter()
377        .map(|(rel, target)| HardLinkRecord::new(&dest, Path::new(rel), target))
378        .collect();
379
380    let (would_overwrite, would_remain) = if destination_exists {
381        compare_destination(&dest, &payload_set)
382    } else {
383        (Vec::new(), Vec::new())
384    };
385
386    let plan = plan_worktree_pointers(&dest, checked, manifest, false);
387    let rewritten_worktrees = plan.pairs.iter().map(|p| p.name.clone()).collect();
388
389    let dangling_symlinks = manifest
390        .symlinks
391        .iter()
392        .filter(|s| would_dangle(&dest, s, &payload_set))
393        .cloned()
394        .collect();
395
396    let link_reports_suppressed = manifest.no_link_report_applied.clone();
397
398    Ok(RestoreReport {
399        dest,
400        dry_run: true,
401        entries_written: payload.paths.len() as u64,
402        destination_exists,
403        would_overwrite,
404        would_remain,
405        rewritten_worktrees,
406        missing_worktrees: plan.missing,
407        conflicting_worktrees: plan.conflicted,
408        missing_worktree_parent: plan.missing_parent,
409        dangling_symlinks,
410        link_reports_suppressed,
411        regenerable_caches: manifest.skipped_cache.clone(),
412        secrets_not_carried: manifest.skipped_secret.clone(),
413        hard_links_not_created,
414        manifest: manifest.clone(),
415    })
416}
417
418/// Settle on the destination path, whether or not it exists yet.
419///
420/// A restore creates the directory and then canonicalizes it, which resolves
421/// every symlink on the way and makes the path absolute. A dry run must not
422/// create anything, so it used to canonicalize a directory that was not there,
423/// fail, and keep the path as the caller typed it. The two then answered
424/// different questions: a relative destination, or one under a symlinked
425/// parent, put the prediction's idea of "beside the root" somewhere the restore
426/// would never look, and the worktree wiring it forecast was not the wiring that
427/// would happen.
428///
429/// So resolve as far as the filesystem allows and no further: canonicalize the
430/// nearest ancestor that exists, then re-attach the part that does not. Those
431/// missing components can only become real directories, never symlinks, so this
432/// is the same path a restore arrives at after creating them — reached without
433/// creating anything.
434fn resolve_dest(dest: &Path) -> PathBuf {
435    let absolute = std::path::absolute(dest).unwrap_or_else(|_| dest.to_path_buf());
436
437    let mut missing = Vec::new();
438    let mut cursor = absolute.as_path();
439    loop {
440        if let Ok(existing) = std::fs::canonicalize(cursor) {
441            let mut resolved = existing;
442            resolved.extend(missing.iter().rev());
443            return resolved;
444        }
445        // Nothing exists all the way up, or the path ran out of names to
446        // strip: the lexically absolute form is the best answer available.
447        let (Some(parent), Some(name)) = (cursor.parent(), cursor.file_name()) else {
448            return absolute;
449        };
450        missing.push(name.to_os_string());
451        cursor = parent;
452    }
453}
454
455/// Split the destination's existing files into "would be replaced" and
456/// "would survive".
457///
458/// Directories are ignored on both sides: only file contents can be lost, and
459/// a directory that exists in both places is not a collision worth reporting.
460fn compare_destination(dest: &Path, incoming: &BTreeSet<&str>) -> (Vec<String>, Vec<String>) {
461    let mut overwrite = Vec::new();
462    let mut remain = Vec::new();
463
464    let walker = walkdir::WalkDir::new(dest)
465        .follow_links(false)
466        .min_depth(1)
467        .sort_by_file_name();
468
469    for entry in walker.into_iter().filter_map(|e| e.ok()) {
470        if entry.file_type().is_dir() {
471            continue;
472        }
473        let Ok(rel) = entry.path().strip_prefix(dest) else {
474            continue;
475        };
476        let rel = rel
477            .components()
478            .map(|c| c.as_os_str().to_string_lossy())
479            .collect::<Vec<_>>()
480            .join("/");
481        if rel.is_empty() {
482            continue;
483        }
484        if incoming.contains(rel.as_str()) {
485            overwrite.push(rel);
486        } else {
487            remain.push(rel);
488        }
489    }
490
491    (overwrite, remain)
492}
493
494/// Whether a symlink from the archive would land dangling here.
495///
496/// The link does not exist yet, so its target is resolved as it *would* be.
497/// An absolute target is checked as written — it keeps pointing at the machine
498/// it named, which is exactly why moving a project can break it. A relative
499/// target is resolved inside the restored tree, and may well be satisfied by
500/// the pack itself: the target file does not exist on disk yet, but it is in
501/// the payload and will be there by the time the link is. Checking the
502/// filesystem alone would report every such link as dangling.
503fn would_dangle(dest: &Path, record: &SymlinkRecord, payload: &BTreeSet<&str>) -> bool {
504    let target = Path::new(&record.target);
505
506    if target.is_absolute() {
507        return !target.exists();
508    }
509
510    let link_parent = Path::new(&record.path).parent().unwrap_or(Path::new(""));
511    let resolved = crate::scan::normalize(&link_parent.join(target));
512
513    let as_key = resolved
514        .components()
515        .map(|c| c.as_os_str().to_string_lossy())
516        .collect::<Vec<_>>()
517        .join("/");
518    if payload.contains(as_key.as_str()) {
519        // The pack supplies it; it will exist alongside the link.
520        return false;
521    }
522
523    !dest.join(&resolved).exists()
524}
525
526/// What a restore does with an archive entry, decided by its type alone.
527///
528/// This crate's writer emits three shapes — directories, regular files,
529/// symlinks — so every other type in `tar::EntryType` came from another
530/// producer. Naming the decision as an enum puts it in one exhaustive match:
531/// the type is `#[non_exhaustive]`, and tar's own default for a type it does
532/// not recognize is to write it out as a regular file, which turns "I do not
533/// know what this is" into a write. Here an unrecognized type has to be given
534/// a case before it can reach the filesystem.
535pub(crate) enum EntryPlan {
536    /// Written out as it stands.
537    Extract,
538    /// Reported and not created — see [`HardLinkRecord`].
539    HardLink,
540    /// Refused, carrying the words the error and the report use for it.
541    Refuse(&'static str),
542}
543
544/// Classify one archive entry.
545pub(crate) fn entry_plan(kind: tar::EntryType) -> EntryPlan {
546    use tar::EntryType as T;
547
548    match kind {
549        // `Continuous` is a high-performance variant of a regular file, and
550        // every reader treats it as one.
551        T::Regular | T::Continuous | T::Directory | T::Symlink => EntryPlan::Extract,
552        T::Link => EntryPlan::HardLink,
553        T::Char => EntryPlan::Refuse("character device"),
554        T::Block => EntryPlan::Refuse("block device"),
555        T::Fifo => EntryPlan::Refuse("named pipe"),
556        T::GNUSparse => EntryPlan::Refuse("sparse file"),
557        // tar folds these into the entry they describe and never yields one on
558        // its own, so reaching here means a malformed header.
559        T::GNULongName | T::GNULongLink | T::XHeader | T::XGlobalHeader => {
560            EntryPlan::Refuse("stray extension header")
561        }
562        _ => EntryPlan::Refuse("entry of an unrecognized type"),
563    }
564}
565
566/// Extract every `payload/` entry into `dest`, stripping the prefix.
567///
568/// Returns the number of entries written and the hard links that were not.
569fn unpack_payload(archive: &Path, dest: &Path) -> Result<(u64, Vec<HardLinkRecord>), PackError> {
570    let file = File::open(archive)?;
571    let decoder = zstd::stream::Decoder::new(file)?;
572    let mut tar = tar::Archive::new(decoder);
573
574    // Directories confirmed to be real (not symlinks), so each ancestor is
575    // checked once rather than once per entry underneath it.
576    let mut real_dirs: HashSet<PathBuf> = HashSet::new();
577
578    let mut written = 0u64;
579    let mut hard_links = Vec::new();
580    for entry in tar.entries()? {
581        let mut entry = entry?;
582        let path = entry.path()?.to_path_buf();
583        let Ok(rel) = path.strip_prefix(PAYLOAD_PREFIX) else {
584            // The manifest entry, and anything else outside the payload.
585            continue;
586        };
587        if rel.as_os_str().is_empty() {
588            continue;
589        }
590        // Refuse anything that would escape the destination, and stop: a pack
591        // is normally produced by this same crate, but an archive is an
592        // untrusted input the moment it arrives from elsewhere, and one that
593        // carries such an entry is not trustworthy in what remains either.
594        let rel = Contained::entry(rel)?;
595
596        match entry_plan(entry.header().entry_type()) {
597            EntryPlan::Extract => {}
598            EntryPlan::HardLink => {
599                // The target names a file on *this* machine, not one the pack
600                // carries, so there is nothing to check it against and no
601                // reading of it that makes linking automatically right.
602                let target = entry.link_name()?.map(|t| t.display().to_string());
603                let Some(target) = target.filter(|t| !t.is_empty()) else {
604                    return Err(PackError::UnusableArchiveEntry {
605                        path: rel.as_path().display().to_string(),
606                        kind: "hard link naming no target".to_string(),
607                    });
608                };
609                hard_links.push(HardLinkRecord::new(dest, rel.as_path(), &target));
610                continue;
611            }
612            EntryPlan::Refuse(kind) => {
613                return Err(PackError::UnusableArchiveEntry {
614                    path: rel.as_path().display().to_string(),
615                    kind: kind.to_string(),
616                });
617            }
618        }
619
620        // The same escape, routed indirectly: an earlier entry plants a
621        // symlink and a later one names a path through it, which would land
622        // the write wherever the link points. Legitimate packs cannot produce
623        // this shape — the scan never descends into a symlinked directory —
624        // so it too refuses the archive.
625        ensure_real_ancestors(dest, rel.as_path(), &mut real_dirs)?;
626
627        let out = rel.join_onto(dest);
628        if let Some(parent) = out.parent() {
629            std::fs::create_dir_all(parent)?;
630        }
631        // Re-extracting over an existing link fails on some platforms; clear it.
632        if out.is_symlink() {
633            std::fs::remove_file(&out)?;
634        }
635        entry.unpack(&out)?;
636        written += 1;
637    }
638
639    Ok((written, hard_links))
640}
641
642/// Refuse an entry whose ancestors include a symlink.
643///
644/// Writing `a/b/x` when `a/b` is a link puts `x` wherever the link points —
645/// outside the destination, if the archive planted the link there first. Every
646/// existing ancestor must therefore be a real directory before the entry is
647/// written; a missing one is fine, since `create_dir_all` will create it as a
648/// real directory.
649///
650/// Only ancestors confirmed to exist as non-links are cached: a path that did
651/// not exist at check time may be a symlink planted by a later entry, so it is
652/// re-examined when next seen.
653///
654/// # Errors
655///
656/// [`PackError::WriteThroughSymlink`] naming the entry and the link.
657fn ensure_real_ancestors(
658    dest: &Path,
659    rel: &Path,
660    real_dirs: &mut HashSet<PathBuf>,
661) -> Result<(), PackError> {
662    let Some(parent) = rel.parent() else {
663        return Ok(());
664    };
665    let mut cur = dest.to_path_buf();
666    for component in parent.components() {
667        cur.push(component);
668        if real_dirs.contains(&cur) {
669            continue;
670        }
671        match std::fs::symlink_metadata(&cur) {
672            Ok(meta) if meta.file_type().is_symlink() => {
673                return Err(PackError::WriteThroughSymlink {
674                    path: rel.display().to_string(),
675                    via: cur,
676                });
677            }
678            Ok(_) => {
679                real_dirs.insert(cur.clone());
680            }
681            // Not there yet; created as a real directory just below.
682            Err(_) => {}
683        }
684    }
685    Ok(())
686}
687
688/// One worktree's wiring, named on both sides.
689///
690/// The two paths are always written as a pair: writing one without the other
691/// leaves the worktree half-attached, which git reports as a broken worktree
692/// rather than as no worktree at all.
693#[derive(Debug, Clone)]
694struct PointerPair {
695    /// Worktree name under `.git/worktrees/`.
696    name: String,
697    /// The repository's admin directory for this worktree.
698    admin: PathBuf,
699    /// The worktree checkout's own `.git` file.
700    dot_git: PathBuf,
701}
702
703/// What the restore intends to do about worktree wiring.
704#[derive(Debug, Default)]
705struct WorktreePlan {
706    /// Pairs that can be wired up, in report order.
707    pairs: Vec<PointerPair>,
708    /// Registered worktrees whose checkout could not be found here.
709    missing: Vec<String>,
710    /// Pairings stopped because the counterpart's place is occupied by
711    /// something that is not this worktree's other half.
712    conflicted: Vec<WorktreeConflict>,
713    /// Set when this pack is a worktree whose repository is not here.
714    missing_parent: Option<String>,
715}
716
717/// The archive's claims about where things go, checked before any of them is
718/// joined onto the destination.
719///
720/// Built once, at the top of both a restore and a dry run, so the two cannot
721/// disagree about which archives are acceptable — and built *before* the first
722/// byte is written, so an archive that lies is refused with the destination
723/// still untouched.
724struct Checked<'a> {
725    /// Registered worktrees, names and locations checked.
726    worktrees: Vec<CheckedWorktree<'a>>,
727    /// The repository this pack belongs to, when the pack is a worktree.
728    origin: Option<CheckedOrigin<'a>>,
729}
730
731/// A registered worktree whose archive-supplied paths have been checked.
732struct CheckedWorktree<'a> {
733    /// Directory name under `.git/worktrees/`.
734    name: Contained,
735    /// Location under the root, when the checkout travels in this pack.
736    ///
737    /// `None` means the checkout is elsewhere on the machine; nothing is joined
738    /// onto the destination for it, and it is looked for beside the new root
739    /// instead.
740    path: Option<Contained>,
741    /// The record as written, for the fields that are not destination paths.
742    record: &'a WorktreeRecord,
743}
744
745/// The repository a packed worktree belongs to, its name checked.
746struct CheckedOrigin<'a> {
747    /// This worktree's name under the repository's `.git/worktrees/`.
748    name: Contained,
749    /// The record as written.
750    origin: &'a WorktreeOrigin,
751}
752
753/// Check every path the archive supplies for a place under the destination.
754///
755/// The payload's entry names were always checked; the manifest's were not, and
756/// they are the same kind of claim — `dest.join(record.path)` writes wherever
757/// `record.path` says, and `Path::join` hands the whole result to an absolute
758/// one. Checking them here rather than at each use is what makes the check
759/// impossible to skip: the wiring code below is handed `Contained` values and
760/// has no access to the raw strings.
761///
762/// # Errors
763///
764/// [`PackError::EscapingManifestPath`] naming the field that carried the path.
765fn check_archive_paths(manifest: &Manifest) -> Result<Checked<'_>, PackError> {
766    let mut worktrees = Vec::with_capacity(manifest.worktrees.len());
767    for record in &manifest.worktrees {
768        worktrees.push(CheckedWorktree {
769            name: Contained::name("worktrees[].name", &record.name)?,
770            path: record
771                .path
772                .as_deref()
773                .map(|raw| Contained::path("worktrees[].path", raw))
774                .transpose()?,
775            record,
776        });
777    }
778
779    let origin = match &manifest.worktree_of {
780        Some(origin) => Some(CheckedOrigin {
781            name: Contained::name("worktree_of.name", &origin.name)?,
782            origin,
783        }),
784        None => None,
785    };
786
787    // Symlink paths are joined onto the destination too — the report asks of
788    // each one whether it landed dangling, which is a question about a path
789    // inside the restored tree and nowhere else.
790    for link in &manifest.symlinks {
791        Contained::path("symlinks[].path", &link.path)?;
792    }
793
794    Ok(Checked { worktrees, origin })
795}
796
797/// What the wiring decision came to for one worktree.
798///
799/// The three layouts used to be three inline branches, and the one that
800/// believed both halves were already inside the destination wrote without
801/// checking anything — an assumption nothing enforced, since the location came
802/// from the manifest. Naming the outcomes makes every branch answer the same
803/// question and makes both callers match exhaustively, so a layout added later
804/// cannot reach [`apply_worktree_plan`] without a case being written for it.
805enum Wiring {
806    /// Both pointer files may be written.
807    Wire(PointerPair),
808    /// The counterpart's place holds something that is not this worktree's
809    /// other half. Nothing is written.
810    Occupied(WorktreeConflict),
811    /// The counterpart is not on this machine.
812    Absent,
813    /// Nothing to repair here.
814    Nothing,
815}
816
817/// Decide what worktree wiring this restore can repair, writing nothing.
818///
819/// Three layouts occur, and all three are decided from the manifest plus what
820/// is on disk *outside* the destination — never from the payload, which is why
821/// a dry run can answer the same question as the real restore:
822///
823/// 1. **worktree inside the root** (`.worktrees/<name>`). Both halves travel in
824///    this one pack, so the pair is always wireable.
825/// 2. **worktree beside the root** — what `git worktree add ../<name>` makes.
826///    The halves live in two packs, so this one can only be wired once the
827///    other has been restored. The counterpart is looked for at the same offset
828///    from the new root that it had from the old one, which is where restoring
829///    a set of sibling projects puts it.
830/// 3. **the root is itself a worktree**, and its repository is the counterpart.
831///    Same lookup, mirrored.
832///
833/// Cases 2 and 3 make the operation order-independent: whichever pack is
834/// restored second finds the first and completes the wiring, and re-restoring
835/// with `force` repairs a pair that was incomplete at the time.
836fn plan_worktree_pointers(
837    dest: &Path,
838    checked: &Checked<'_>,
839    manifest: &Manifest,
840    unpacked: bool,
841) -> WorktreePlan {
842    let mut plan = WorktreePlan::default();
843
844    for worktree in &checked.worktrees {
845        match decide_worktree(dest, worktree, manifest, unpacked) {
846            Wiring::Wire(pair) => plan.pairs.push(pair),
847            Wiring::Occupied(conflict) => plan.conflicted.push(conflict),
848            Wiring::Absent => plan.missing.push(worktree.record.name.clone()),
849            Wiring::Nothing => {}
850        }
851    }
852
853    if let Some(origin) = &checked.origin {
854        match decide_origin(dest, origin, manifest, unpacked) {
855            Wiring::Wire(pair) => plan.pairs.push(pair),
856            Wiring::Occupied(conflict) => plan.conflicted.push(conflict),
857            // A repository that is not beside the restored root leaves the
858            // checkout with no repository at all, which is worth more than a
859            // line in a list of names.
860            Wiring::Absent => plan.missing_parent = Some(origin.origin.parent_root.clone()),
861            Wiring::Nothing => {}
862        }
863    }
864
865    plan
866}
867
868/// Decide one registered worktree's wiring — layouts 1 and 2.
869fn decide_worktree(
870    dest: &Path,
871    worktree: &CheckedWorktree<'_>,
872    manifest: &Manifest,
873    unpacked: bool,
874) -> Wiring {
875    let admin = worktree
876        .name
877        .join_onto(&dest.join(".git").join("worktrees"));
878
879    if let Some(rel) = &worktree.path {
880        // Case 1: both halves are in this pack, so both pointer files are ones
881        // this restore just wrote. That is only true of a location that stays
882        // inside the destination, which is what `rel` being a `Contained`
883        // establishes — an absolute or `..`-bearing location was refused
884        // before the payload was touched, rather than aiming these writes at
885        // whatever occupies it.
886        let worktree_root = rel.join_onto(dest);
887        if unpacked && (!admin.is_dir() || !worktree_root.is_dir()) {
888            // Neither made it into the payload after all; nothing to repair.
889            return Wiring::Nothing;
890        }
891        return Wiring::Wire(PointerPair {
892            name: worktree.record.name.clone(),
893            admin,
894            dot_git: worktree_root.join(".git"),
895        });
896    }
897
898    // Case 2: the checkout is not in this pack. It may still be on this
899    // machine — restored from its own pack, or never moved at all.
900    let Some(candidate) =
901        relocate_beside(&manifest.source_root, dest, &worktree.record.source_path)
902    else {
903        return Wiring::Absent;
904    };
905    let dot_git = candidate.join(".git");
906    if !dot_git.exists() || (unpacked && !admin.is_dir()) {
907        return Wiring::Absent;
908    }
909    // Occupancy alone is not identity: whatever sits at the candidate path
910    // has to be confirmed as *this* worktree's checkout before either
911    // pointer is written, or the wiring would break someone else's project
912    // to repair this one.
913    //
914    // A `.git` *directory* there is an independent repository. A `.git`
915    // file is a worktree checkout of *some* repository — ours only if its
916    // pointer names this worktree's admin directory, at the old location
917    // (not yet wired) or the new one (already wired; re-wiring is
918    // idempotent).
919    if dot_git.is_dir() {
920        return Wiring::Occupied(WorktreeConflict {
921            name: worktree.record.name.clone(),
922            path: candidate.display().to_string(),
923            found: "an independent repository — its `.git` is a directory".to_string(),
924        });
925    }
926    let old_admin = worktree.name.join_onto(
927        &Path::new(&manifest.source_root)
928            .join(".git")
929            .join("worktrees"),
930    );
931    match pointer_target(&dot_git) {
932        Some(claimed) if same_place(&claimed, &old_admin) || same_place(&claimed, &admin) => {
933            Wiring::Wire(PointerPair {
934                name: worktree.record.name.clone(),
935                admin,
936                dot_git,
937            })
938        }
939        Some(claimed) => Wiring::Occupied(WorktreeConflict {
940            name: worktree.record.name.clone(),
941            path: candidate.display().to_string(),
942            found: format!(
943                "a worktree of a different repository — its `.git` names {}",
944                claimed.display()
945            ),
946        }),
947        None => Wiring::Occupied(WorktreeConflict {
948            name: worktree.record.name.clone(),
949            path: candidate.display().to_string(),
950            found: "an unreadable or unrecognized `.git` file".to_string(),
951        }),
952    }
953}
954
955/// Decide the wiring when this pack *is* a worktree — layout 3.
956///
957/// The same identity rule as case 2, mirrored: the admin directory found beside
958/// the restored root is ours only if its `gitdir` names this checkout, at the
959/// old location or the new one.
960fn decide_origin(
961    dest: &Path,
962    origin: &CheckedOrigin<'_>,
963    manifest: &Manifest,
964    unpacked: bool,
965) -> Wiring {
966    let dot_git = dest.join(".git");
967    let old_dot_git = Path::new(&manifest.source_root).join(".git");
968    let admin = relocate_beside(&manifest.source_root, dest, &origin.origin.parent_root)
969        .map(|root| origin.name.join_onto(&root.join(".git").join("worktrees")))
970        .filter(|admin| admin.is_dir());
971
972    let Some(admin) = admin else {
973        return Wiring::Absent;
974    };
975
976    match pointer_target(&admin.join("gitdir")) {
977        Some(claimed) if same_place(&claimed, &old_dot_git) || same_place(&claimed, &dot_git) => {
978            if !unpacked || dot_git.is_file() {
979                Wiring::Wire(PointerPair {
980                    name: origin.origin.name.clone(),
981                    admin,
982                    dot_git,
983                })
984            } else {
985                Wiring::Absent
986            }
987        }
988        claimed => Wiring::Occupied(WorktreeConflict {
989            name: origin.origin.name.clone(),
990            path: admin.display().to_string(),
991            found: match claimed {
992                Some(other) => format!(
993                    "a same-named worktree of a different checkout — its `gitdir` names {}",
994                    other.display()
995                ),
996                None => "an admin directory with no readable `gitdir`".to_string(),
997            },
998        }),
999    }
1000}
1001
1002/// Read a worktree pointer file and return the path it names.
1003///
1004/// Handles both halves of the wiring: a checkout's `.git` file
1005/// (`gitdir: <path>`) and an admin directory's `gitdir` file (the bare path).
1006fn pointer_target(file: &Path) -> Option<PathBuf> {
1007    let text = std::fs::read_to_string(file).ok()?;
1008    let trimmed = text.trim();
1009    let path = trimmed
1010        .strip_prefix("gitdir:")
1011        .map(str::trim)
1012        .unwrap_or(trimmed);
1013    if path.is_empty() {
1014        None
1015    } else {
1016        Some(PathBuf::from(path))
1017    }
1018}
1019
1020/// Whether two recorded paths name the same place.
1021///
1022/// Resolved against the filesystem where possible, so a path git recorded
1023/// through a symlinked prefix still matches the canonical form the manifest
1024/// carries. A path that no longer exists falls back to lexical comparison,
1025/// which is the best that can be done for a pointer aimed at another machine.
1026fn same_place(claimed: &Path, expected: &Path) -> bool {
1027    canonicalize_or(claimed) == canonicalize_or(expected)
1028}
1029
1030/// Re-aim an absolute path that sat beside the source root at the restored root.
1031///
1032/// `~/projects/proj` restored to `/backup/proj` puts its sibling
1033/// `~/projects/proj-feature` at `/backup/proj-feature`. Restoring in place is
1034/// the same computation with a zero delta, so no special case is needed for it.
1035///
1036/// Yields `None` for a path that was not under the source root's parent: a
1037/// worktree kept somewhere unrelated moves independently of the project, and
1038/// this has no way to know where it went.
1039fn relocate_beside(source_root: &str, dest: &Path, original: &str) -> Option<PathBuf> {
1040    let source_parent = Path::new(source_root).parent()?;
1041    let rel = Path::new(original).strip_prefix(source_parent).ok()?;
1042    Some(dest.parent()?.join(rel))
1043}
1044
1045/// Write both halves of every planned pair.
1046///
1047/// Returns the names wired up, which is every pair: a pair that could not be
1048/// wired was already excluded during planning.
1049fn apply_worktree_plan(plan: &WorktreePlan) -> Result<Vec<String>, PackError> {
1050    let mut rewritten = Vec::new();
1051    for pair in &plan.pairs {
1052        std::fs::write(
1053            pair.admin.join("gitdir"),
1054            format!("{}\n", pair.dot_git.display()),
1055        )?;
1056        std::fs::write(&pair.dot_git, format!("gitdir: {}\n", pair.admin.display()))?;
1057        rewritten.push(pair.name.clone());
1058    }
1059    Ok(rewritten)
1060}
1061
1062/// Whether a restored symlink points at something that does not exist here.
1063fn is_dangling(dest: &Path, record: &SymlinkRecord) -> bool {
1064    let link = dest.join(&record.path);
1065    if !link.is_symlink() {
1066        // Not restored at all; not this check's concern.
1067        return false;
1068    }
1069    !link.exists()
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use crate::create::{CreateOptions, create};
1076    use std::fs;
1077    use tempfile::TempDir;
1078
1079    fn touch(path: &Path, body: &str) {
1080        if let Some(parent) = path.parent() {
1081            fs::create_dir_all(parent).expect("mkdir");
1082        }
1083        fs::write(path, body).expect("write");
1084    }
1085
1086    /// A pack round-trips: every packed file comes back with its contents.
1087    #[test]
1088    fn test_round_trip_preserves_content() {
1089        let dir = TempDir::new().expect("tempdir");
1090        let root = dir.path().join("proj");
1091        touch(&root.join("src/main.rs"), "fn main() {}");
1092        touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
1093        touch(&root.join("workspace/journal.md"), "# journal\n");
1094        touch(&root.join("workspace/.journal.db"), "sqlite");
1095
1096        let out = dir.path().join("proj.pack");
1097        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1098
1099        let dest = dir.path().join("restored");
1100        let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
1101
1102        assert_eq!(
1103            fs::read_to_string(dest.join("src/main.rs")).expect("read"),
1104            "fn main() {}"
1105        );
1106        assert_eq!(
1107            fs::read_to_string(dest.join(".git/HEAD")).expect("read"),
1108            "ref: refs/heads/main\n"
1109        );
1110        assert_eq!(
1111            fs::read_to_string(dest.join("workspace/.journal.db")).expect("read"),
1112            "sqlite",
1113            "local state must survive the round trip"
1114        );
1115        assert!(report.entries_written > 0);
1116    }
1117
1118    /// An existing destination is refused unless `force` is set.
1119    #[test]
1120    fn test_restore_refuses_existing_destination() {
1121        let dir = TempDir::new().expect("tempdir");
1122        let root = dir.path().join("proj");
1123        touch(&root.join("a.txt"), "a");
1124        let out = dir.path().join("proj.pack");
1125        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1126
1127        let dest = dir.path().join("existing");
1128        fs::create_dir_all(&dest).expect("mkdir");
1129
1130        assert!(matches!(
1131            restore(&RestoreOptions::new(&out, &dest)),
1132            Err(PackError::DestinationExists(_))
1133        ));
1134
1135        let forced = RestoreOptions {
1136            force: true,
1137            ..RestoreOptions::new(&out, &dest)
1138        };
1139        restore(&forced).expect("force should proceed");
1140        assert!(dest.join("a.txt").is_file());
1141    }
1142
1143    /// Worktree pointers are rewritten to the new root, not left aimed at the
1144    /// machine the pack came from.
1145    #[test]
1146    fn test_restore_rewrites_worktree_pointers() {
1147        let dir = TempDir::new().expect("tempdir");
1148        let root = dir.path().join("proj");
1149        touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
1150
1151        let wt = root.join(".worktrees/feature");
1152        touch(&wt.join("file.txt"), "work");
1153        let admin = root.join(".git/worktrees/feature");
1154        fs::create_dir_all(&admin).expect("mkdir");
1155        fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
1156        fs::write(
1157            admin.join("gitdir"),
1158            format!("{}\n", wt.join(".git").display()),
1159        )
1160        .expect("write");
1161        fs::write(admin.join("commondir"), "../..\n").expect("write");
1162
1163        let out = dir.path().join("proj.pack");
1164        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1165
1166        let dest = dir.path().join("moved");
1167        let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
1168
1169        assert_eq!(report.rewritten_worktrees, vec!["feature".to_string()]);
1170
1171        let new_admin_gitdir =
1172            fs::read_to_string(dest.join(".git/worktrees/feature/gitdir")).expect("read");
1173        let new_dot_git = fs::read_to_string(dest.join(".worktrees/feature/.git")).expect("read");
1174
1175        let dest_real = fs::canonicalize(&dest).expect("canonicalize");
1176        assert!(
1177            new_admin_gitdir
1178                .trim()
1179                .starts_with(&dest_real.to_string_lossy().to_string()),
1180            "gitdir must point into the new root, got {new_admin_gitdir}"
1181        );
1182        assert!(
1183            new_dot_git
1184                .trim()
1185                .contains(&dest_real.to_string_lossy().to_string()),
1186            "worktree .git must point into the new root, got {new_dot_git}"
1187        );
1188        assert!(
1189            !new_admin_gitdir.contains("/proj/"),
1190            "stale source path must not survive: {new_admin_gitdir}"
1191        );
1192    }
1193
1194    // ------------------------------------------------------------------
1195    // worktrees living beside the root
1196    //
1197    // `git worktree add ../name` is the layout in the field, and it splits a
1198    // project across two packs. Neither pack can be restored into a working
1199    // state alone; the pair has to find each other.
1200    // ------------------------------------------------------------------
1201
1202    /// Build `<base>/proj` with a worktree registered at `<base>/proj-feature`,
1203    /// wired the way git wires it: two absolute paths pointing at each other.
1204    fn sibling_worktree(base: &Path) -> (PathBuf, PathBuf) {
1205        let root = base.join("proj");
1206        let wt = base.join("proj-feature");
1207        let admin = root.join(".git/worktrees/feature");
1208
1209        touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
1210        fs::create_dir_all(&admin).expect("mkdir");
1211        fs::write(admin.join("commondir"), "../..\n").expect("write");
1212        touch(&wt.join("work.txt"), "w");
1213
1214        // Both halves name absolute paths on this machine.
1215        fs::write(
1216            admin.join("gitdir"),
1217            format!("{}\n", wt.join(".git").display()),
1218        )
1219        .expect("write");
1220        fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
1221
1222        (root, wt)
1223    }
1224
1225    fn pack(root: &Path, out: &Path) {
1226        create(&CreateOptions::new(root, out, "0.14.0")).expect("create");
1227    }
1228
1229    /// Reading a pointer file back with its trailing newline removed.
1230    fn pointer(path: &Path) -> String {
1231        fs::read_to_string(path).expect("read").trim().to_string()
1232    }
1233
1234    /// The headline case: a project and its sibling worktree are packed
1235    /// separately, restored side by side somewhere new, and end up wired to
1236    /// each other there — not to the machine they came from.
1237    #[test]
1238    fn test_restore_wires_sibling_worktree_into_new_location() {
1239        let dir = TempDir::new().expect("tempdir");
1240        let src = dir.path().join("projects");
1241        let (root, wt) = sibling_worktree(&src);
1242
1243        let root_pack = dir.path().join("proj.pack");
1244        let wt_pack = dir.path().join("proj-feature.pack");
1245        pack(&root, &root_pack);
1246        pack(&wt, &wt_pack);
1247
1248        // Somewhere entirely new, worktree first.
1249        let moved = dir.path().join("moved");
1250        let new_wt = moved.join("proj-feature");
1251        let new_root = moved.join("proj");
1252
1253        let wt_report = restore(&RestoreOptions::new(&wt_pack, &new_wt)).expect("restore worktree");
1254        // Its repository is not here yet, and saying so is the point.
1255        assert!(wt_report.rewritten_worktrees.is_empty());
1256        let source_root = fs::canonicalize(&root)
1257            .expect("canonicalize")
1258            .to_string_lossy()
1259            .into_owned();
1260        assert_eq!(
1261            wt_report.missing_worktree_parent.as_deref(),
1262            Some(source_root.as_str()),
1263            "the report must name the repository this checkout belongs to"
1264        );
1265        assert!(wt_report.needs_attention());
1266
1267        // The repository lands beside it, and the pair is wired up.
1268        let root_report =
1269            restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore root");
1270        assert_eq!(root_report.rewritten_worktrees, vec!["feature".to_string()]);
1271        assert!(root_report.missing_worktrees.is_empty());
1272
1273        let real_root = fs::canonicalize(&new_root).expect("canonicalize");
1274        let real_wt = fs::canonicalize(&new_wt).expect("canonicalize");
1275        assert_eq!(
1276            pointer(&real_root.join(".git/worktrees/feature/gitdir")),
1277            real_wt.join(".git").display().to_string(),
1278            "the repository must name the worktree where it now is"
1279        );
1280        assert_eq!(
1281            pointer(&real_wt.join(".git")),
1282            format!(
1283                "gitdir: {}",
1284                real_root.join(".git/worktrees/feature").display()
1285            ),
1286            "and the worktree must name the repository where it now is"
1287        );
1288    }
1289
1290    /// The same pair, restored repository-first. Whichever pack lands second
1291    /// completes the wiring, so the operator does not have to know an order.
1292    #[test]
1293    fn test_restore_wiring_is_order_independent() {
1294        let dir = TempDir::new().expect("tempdir");
1295        let src = dir.path().join("projects");
1296        let (root, wt) = sibling_worktree(&src);
1297
1298        let root_pack = dir.path().join("proj.pack");
1299        let wt_pack = dir.path().join("proj-feature.pack");
1300        pack(&root, &root_pack);
1301        pack(&wt, &wt_pack);
1302
1303        let moved = dir.path().join("moved");
1304        let new_root = moved.join("proj");
1305        let new_wt = moved.join("proj-feature");
1306
1307        // Repository first: the checkout is not here, so it is reported.
1308        let first = restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore root");
1309        assert!(first.rewritten_worktrees.is_empty());
1310        assert_eq!(first.missing_worktrees, vec!["feature".to_string()]);
1311
1312        // Checkout second: it finds the repository beside it.
1313        let second = restore(&RestoreOptions::new(&wt_pack, &new_wt)).expect("restore worktree");
1314        assert_eq!(second.rewritten_worktrees, vec!["feature".to_string()]);
1315        assert!(second.missing_worktree_parent.is_none());
1316
1317        let real_root = fs::canonicalize(&new_root).expect("canonicalize");
1318        let real_wt = fs::canonicalize(&new_wt).expect("canonicalize");
1319        assert_eq!(
1320            pointer(&real_root.join(".git/worktrees/feature/gitdir")),
1321            real_wt.join(".git").display().to_string()
1322        );
1323        assert_eq!(
1324            pointer(&real_wt.join(".git")),
1325            format!(
1326                "gitdir: {}",
1327                real_root.join(".git/worktrees/feature").display()
1328            )
1329        );
1330    }
1331
1332    /// Re-restoring the repository over itself once the checkout is in place
1333    /// repairs the wiring — the operator's way out of having restored in an
1334    /// order that left it half-attached.
1335    #[test]
1336    fn test_forced_re_restore_repairs_existing_sibling() {
1337        let dir = TempDir::new().expect("tempdir");
1338        let src = dir.path().join("projects");
1339        let (root, wt) = sibling_worktree(&src);
1340
1341        let root_pack = dir.path().join("proj.pack");
1342        let wt_pack = dir.path().join("proj-feature.pack");
1343        pack(&root, &root_pack);
1344        pack(&wt, &wt_pack);
1345
1346        let moved = dir.path().join("moved");
1347        let new_root = moved.join("proj");
1348        restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore root");
1349        restore(&RestoreOptions::new(&wt_pack, moved.join("proj-feature")))
1350            .expect("restore worktree");
1351
1352        // The repository's own pointer is now stale — it was written before the
1353        // checkout existed. A forced re-restore is what fixes it.
1354        let again = restore(&RestoreOptions {
1355            force: true,
1356            ..RestoreOptions::new(&root_pack, &new_root)
1357        })
1358        .expect("re-restore");
1359
1360        assert_eq!(again.rewritten_worktrees, vec!["feature".to_string()]);
1361        assert!(
1362            again.missing_worktrees.is_empty(),
1363            "a checkout that is right there must not be reported missing"
1364        );
1365
1366        let real_root = fs::canonicalize(&new_root).expect("canonicalize");
1367        let gitdir = pointer(&real_root.join(".git/worktrees/feature/gitdir"));
1368        assert!(
1369            !gitdir.contains("/projects/"),
1370            "the source machine's path must not survive: {gitdir}"
1371        );
1372    }
1373
1374    /// A checkout that is nowhere on this machine stays reported, not silently
1375    /// counted as repaired.
1376    #[test]
1377    fn test_restore_reports_sibling_worktree_that_is_absent() {
1378        let dir = TempDir::new().expect("tempdir");
1379        let src = dir.path().join("projects");
1380        let (root, _wt) = sibling_worktree(&src);
1381
1382        let root_pack = dir.path().join("proj.pack");
1383        pack(&root, &root_pack);
1384
1385        let report = restore(&RestoreOptions::new(
1386            &root_pack,
1387            dir.path().join("elsewhere/proj"),
1388        ))
1389        .expect("restore");
1390
1391        assert_eq!(report.missing_worktrees, vec!["feature".to_string()]);
1392        assert!(report.rewritten_worktrees.is_empty());
1393        assert!(report.needs_attention());
1394    }
1395
1396    /// An unrelated repository sitting at the path the worktree would occupy is
1397    /// left alone and reported as a conflict — not as missing, which would
1398    /// advise restoring a pack on top of it. Its `.git` is a directory, and
1399    /// overwriting it with a pointer file would destroy a repository to repair
1400    /// a different one.
1401    #[test]
1402    fn test_restore_will_not_clobber_a_repository_at_the_sibling_path() {
1403        let dir = TempDir::new().expect("tempdir");
1404        let src = dir.path().join("projects");
1405        let (root, _wt) = sibling_worktree(&src);
1406
1407        let root_pack = dir.path().join("proj.pack");
1408        pack(&root, &root_pack);
1409
1410        // A real repository, not a worktree, where the worktree used to be.
1411        let moved = dir.path().join("moved");
1412        let squatter = moved.join("proj-feature");
1413        touch(&squatter.join(".git/HEAD"), "ref: refs/heads/main\n");
1414
1415        let report =
1416            restore(&RestoreOptions::new(&root_pack, moved.join("proj"))).expect("restore");
1417
1418        assert!(report.rewritten_worktrees.is_empty());
1419        assert!(report.missing_worktrees.is_empty());
1420        assert_eq!(report.conflicting_worktrees.len(), 1);
1421        let conflict = &report.conflicting_worktrees[0];
1422        assert_eq!(conflict.name, "feature");
1423        assert!(
1424            conflict.found.contains("independent repository"),
1425            "the report must say what is sitting there, got {:?}",
1426            conflict.found
1427        );
1428        assert!(report.needs_attention());
1429        assert!(
1430            squatter.join(".git").is_dir(),
1431            "the unrelated repository must survive untouched"
1432        );
1433    }
1434
1435    /// A same-named worktree belonging to a *different* repository at the
1436    /// counterpart path is not wired: its `.git` names someone else's admin
1437    /// directory, and rewriting it would hijack that repository's worktree.
1438    #[test]
1439    fn test_restore_will_not_rewire_a_foreign_worktree_at_the_sibling_path() {
1440        let dir = TempDir::new().expect("tempdir");
1441        let src = dir.path().join("projects");
1442        let (root, _wt) = sibling_worktree(&src);
1443
1444        let root_pack = dir.path().join("proj.pack");
1445        pack(&root, &root_pack);
1446
1447        // A worktree checkout of another repository, where ours would be.
1448        let other_admin = dir.path().join("other/.git/worktrees/feature");
1449        fs::create_dir_all(&other_admin).expect("mkdir");
1450        let moved = dir.path().join("moved");
1451        let squatter = moved.join("proj-feature");
1452        let original_pointer = format!("gitdir: {}\n", other_admin.display());
1453        touch(&squatter.join(".git"), &original_pointer);
1454
1455        let report =
1456            restore(&RestoreOptions::new(&root_pack, moved.join("proj"))).expect("restore");
1457
1458        assert!(report.rewritten_worktrees.is_empty());
1459        assert_eq!(report.conflicting_worktrees.len(), 1);
1460        assert!(
1461            report.conflicting_worktrees[0]
1462                .found
1463                .contains("different repository"),
1464            "got {:?}",
1465            report.conflicting_worktrees[0].found
1466        );
1467        assert_eq!(
1468            fs::read_to_string(squatter.join(".git")).expect("read"),
1469            original_pointer,
1470            "the foreign worktree's pointer must survive untouched"
1471        );
1472    }
1473
1474    /// The mirrored collision: this pack is a worktree, and the repository
1475    /// found beside it has a same-named worktree that belongs to a different
1476    /// checkout. Its admin `gitdir` is not overwritten.
1477    #[test]
1478    fn test_restore_will_not_claim_a_foreign_admin_directory() {
1479        let dir = TempDir::new().expect("tempdir");
1480        let src = dir.path().join("projects");
1481        let (_root, wt) = sibling_worktree(&src);
1482
1483        let wt_pack = dir.path().join("proj-feature.pack");
1484        pack(&wt, &wt_pack);
1485
1486        // At the destination, `proj` is a different repository that happens to
1487        // have its own worktree named `feature`, checked out somewhere else.
1488        let moved = dir.path().join("moved");
1489        let foreign_admin = moved.join("proj/.git/worktrees/feature");
1490        fs::create_dir_all(&foreign_admin).expect("mkdir");
1491        let elsewhere = dir.path().join("elsewhere/checkout");
1492        fs::create_dir_all(&elsewhere).expect("mkdir");
1493        let original_gitdir = format!("{}\n", elsewhere.join(".git").display());
1494        fs::write(foreign_admin.join("gitdir"), &original_gitdir).expect("write");
1495
1496        let report =
1497            restore(&RestoreOptions::new(&wt_pack, moved.join("proj-feature"))).expect("restore");
1498
1499        assert!(report.rewritten_worktrees.is_empty());
1500        assert_eq!(report.conflicting_worktrees.len(), 1);
1501        assert!(
1502            report.conflicting_worktrees[0]
1503                .found
1504                .contains("different checkout"),
1505            "got {:?}",
1506            report.conflicting_worktrees[0].found
1507        );
1508        assert!(report.missing_worktree_parent.is_none());
1509        assert_eq!(
1510            fs::read_to_string(foreign_admin.join("gitdir")).expect("read"),
1511            original_gitdir,
1512            "the foreign admin directory must survive untouched"
1513        );
1514    }
1515
1516    /// A worktree kept somewhere unrelated to the project moves independently,
1517    /// and this has no way to know where. It is reported, never guessed at.
1518    #[test]
1519    fn test_restore_does_not_guess_at_a_distant_worktree() {
1520        let dir = TempDir::new().expect("tempdir");
1521        let root = dir.path().join("projects/proj");
1522        let far = dir.path().join("somewhere/else/wt");
1523        let admin = root.join(".git/worktrees/far");
1524
1525        touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
1526        fs::create_dir_all(&admin).expect("mkdir");
1527        touch(&far.join(".keep"), "");
1528        fs::write(
1529            admin.join("gitdir"),
1530            format!("{}\n", far.join(".git").display()),
1531        )
1532        .expect("write");
1533        fs::write(far.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
1534
1535        let out = dir.path().join("proj.pack");
1536        pack(&root, &out);
1537
1538        let report =
1539            restore(&RestoreOptions::new(&out, dir.path().join("moved/proj"))).expect("restore");
1540
1541        assert_eq!(report.missing_worktrees, vec!["far".to_string()]);
1542        assert!(report.rewritten_worktrees.is_empty());
1543    }
1544
1545    /// The dry run predicts the sibling wiring, and the real restore agrees.
1546    #[test]
1547    fn test_dry_run_predicts_sibling_wiring() {
1548        let dir = TempDir::new().expect("tempdir");
1549        let src = dir.path().join("projects");
1550        let (root, wt) = sibling_worktree(&src);
1551
1552        let root_pack = dir.path().join("proj.pack");
1553        let wt_pack = dir.path().join("proj-feature.pack");
1554        pack(&root, &root_pack);
1555        pack(&wt, &wt_pack);
1556
1557        let moved = dir.path().join("moved");
1558        restore(&RestoreOptions::new(&wt_pack, moved.join("proj-feature")))
1559            .expect("restore worktree");
1560
1561        let new_root = moved.join("proj");
1562        let predicted = restore(&RestoreOptions {
1563            dry_run: true,
1564            ..RestoreOptions::new(&root_pack, &new_root)
1565        })
1566        .expect("dry run");
1567
1568        assert_eq!(predicted.rewritten_worktrees, vec!["feature".to_string()]);
1569        assert!(!new_root.exists(), "still nothing written");
1570
1571        let actual = restore(&RestoreOptions::new(&root_pack, &new_root)).expect("restore");
1572        assert_eq!(predicted.rewritten_worktrees, actual.rewritten_worktrees);
1573        assert_eq!(predicted.missing_worktrees, actual.missing_worktrees);
1574    }
1575
1576    /// A symlink whose target is gone is restored and reported, not hidden.
1577    #[cfg(unix)]
1578    #[test]
1579    fn test_restore_reports_dangling_symlink() {
1580        let dir = TempDir::new().expect("tempdir");
1581        let root = dir.path().join("proj");
1582        fs::create_dir_all(&root).expect("mkdir");
1583        let vanishing = dir.path().join("vanishing");
1584        fs::create_dir_all(&vanishing).expect("mkdir");
1585        touch(&vanishing.join("target.md"), "t");
1586        std::os::unix::fs::symlink(vanishing.join("target.md"), root.join("link.md"))
1587            .expect("symlink");
1588
1589        let out = dir.path().join("proj.pack");
1590        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1591
1592        // The link target disappears before the restore.
1593        fs::remove_dir_all(&vanishing).expect("rm");
1594
1595        let dest = dir.path().join("restored");
1596        let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
1597
1598        assert!(dest.join("link.md").is_symlink(), "link itself is restored");
1599        assert_eq!(report.dangling_symlinks.len(), 1);
1600        assert_eq!(report.dangling_symlinks[0].path, "link.md");
1601        assert!(report.needs_attention());
1602    }
1603
1604    /// A symlink whose target still exists is not reported as dangling.
1605    #[cfg(unix)]
1606    #[test]
1607    fn test_restore_does_not_report_live_symlink() {
1608        let dir = TempDir::new().expect("tempdir");
1609        let root = dir.path().join("proj");
1610        fs::create_dir_all(&root).expect("mkdir");
1611        touch(&root.join("real.txt"), "r");
1612        std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
1613
1614        let out = dir.path().join("proj.pack");
1615        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1616
1617        let dest = dir.path().join("restored");
1618        let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
1619
1620        assert!(report.dangling_symlinks.is_empty());
1621    }
1622
1623    // ------------------------------------------------------------------
1624    // dry run
1625    // ------------------------------------------------------------------
1626
1627    /// A dry run creates nothing at all, not even the destination directory.
1628    #[test]
1629    fn test_dry_run_writes_nothing() {
1630        let dir = TempDir::new().expect("tempdir");
1631        let root = dir.path().join("proj");
1632        touch(&root.join("a.txt"), "a");
1633        let out = dir.path().join("proj.pack");
1634        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1635
1636        let dest = dir.path().join("nowhere");
1637        let opts = RestoreOptions {
1638            dry_run: true,
1639            ..RestoreOptions::new(&out, &dest)
1640        };
1641        let report = restore(&opts).expect("dry run");
1642
1643        assert!(report.dry_run);
1644        assert!(!dest.exists(), "dry run must not create the destination");
1645        assert!(
1646            report.entries_written > 0,
1647            "it still counts what would land"
1648        );
1649        assert!(!report.destination_exists);
1650        assert!(report.would_overwrite.is_empty());
1651        assert!(report.would_remain.is_empty());
1652    }
1653
1654    /// An existing destination is previewable without `--force`, and the split
1655    /// between "replaced" and "remains" is what makes the overwrite semantics
1656    /// legible before committing to them.
1657    #[test]
1658    fn test_dry_run_splits_existing_destination() {
1659        let dir = TempDir::new().expect("tempdir");
1660        let root = dir.path().join("proj");
1661        touch(&root.join("shared.txt"), "from pack");
1662        touch(&root.join("only-in-pack.txt"), "new");
1663        let out = dir.path().join("proj.pack");
1664        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1665
1666        let dest = dir.path().join("existing");
1667        touch(&dest.join("shared.txt"), "old content");
1668        touch(&dest.join("only-in-dest.txt"), "leftover");
1669
1670        // No force, and yet the preview succeeds — refusing here would defeat
1671        // the purpose of asking what would happen.
1672        let opts = RestoreOptions {
1673            dry_run: true,
1674            ..RestoreOptions::new(&out, &dest)
1675        };
1676        let report = restore(&opts).expect("dry run over existing dest");
1677
1678        assert!(report.destination_exists);
1679        assert_eq!(report.would_overwrite, vec!["shared.txt".to_string()]);
1680        assert_eq!(report.would_remain, vec!["only-in-dest.txt".to_string()]);
1681        assert!(report.needs_attention());
1682
1683        // And the destination is untouched by the preview itself.
1684        assert_eq!(
1685            fs::read_to_string(dest.join("shared.txt")).expect("read"),
1686            "old content"
1687        );
1688    }
1689
1690    /// The prediction matches what the real restore then does.
1691    #[test]
1692    fn test_dry_run_agrees_with_real_restore() {
1693        let dir = TempDir::new().expect("tempdir");
1694        let root = dir.path().join("proj");
1695        touch(&root.join("a.txt"), "a");
1696        touch(&root.join("sub/b.txt"), "b");
1697        let out = dir.path().join("proj.pack");
1698        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1699
1700        let dest = dir.path().join("dest");
1701        let predicted = restore(&RestoreOptions {
1702            dry_run: true,
1703            ..RestoreOptions::new(&out, &dest)
1704        })
1705        .expect("dry run");
1706
1707        let actual = restore(&RestoreOptions::new(&out, &dest)).expect("real restore");
1708
1709        assert_eq!(
1710            predicted.entries_written, actual.entries_written,
1711            "a dry run that miscounts is worse than none"
1712        );
1713        assert_eq!(predicted.rewritten_worktrees, actual.rewritten_worktrees);
1714        assert_eq!(
1715            predicted.dangling_symlinks.len(),
1716            actual.dangling_symlinks.len()
1717        );
1718    }
1719
1720    /// A dangling symlink is predicted before the link exists.
1721    #[cfg(unix)]
1722    #[test]
1723    fn test_dry_run_predicts_dangling_symlink() {
1724        let dir = TempDir::new().expect("tempdir");
1725        let root = dir.path().join("proj");
1726        fs::create_dir_all(&root).expect("mkdir");
1727        let vanishing = dir.path().join("vanishing");
1728        fs::create_dir_all(&vanishing).expect("mkdir");
1729        touch(&vanishing.join("t.md"), "t");
1730        std::os::unix::fs::symlink(vanishing.join("t.md"), root.join("link.md")).expect("symlink");
1731
1732        let out = dir.path().join("proj.pack");
1733        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1734        fs::remove_dir_all(&vanishing).expect("rm");
1735
1736        let dest = dir.path().join("dest");
1737        let predicted = restore(&RestoreOptions {
1738            dry_run: true,
1739            ..RestoreOptions::new(&out, &dest)
1740        })
1741        .expect("dry run");
1742
1743        assert_eq!(predicted.dangling_symlinks.len(), 1);
1744        assert_eq!(predicted.dangling_symlinks[0].path, "link.md");
1745        assert!(!dest.exists(), "still nothing written");
1746
1747        // The real restore then agrees.
1748        let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
1749        assert_eq!(actual.dangling_symlinks.len(), 1);
1750    }
1751
1752    /// A relative link that stays inside the project is not predicted dangling.
1753    #[cfg(unix)]
1754    #[test]
1755    fn test_dry_run_does_not_predict_live_relative_link() {
1756        let dir = TempDir::new().expect("tempdir");
1757        let root = dir.path().join("proj");
1758        fs::create_dir_all(&root).expect("mkdir");
1759        touch(&root.join("real.txt"), "r");
1760        std::os::unix::fs::symlink("real.txt", root.join("rel-link")).expect("symlink");
1761
1762        let out = dir.path().join("proj.pack");
1763        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1764
1765        let dest = dir.path().join("dest");
1766        let predicted = restore(&RestoreOptions {
1767            dry_run: true,
1768            ..RestoreOptions::new(&out, &dest)
1769        })
1770        .expect("dry run");
1771
1772        assert!(
1773            predicted.dangling_symlinks.is_empty(),
1774            "a link resolving inside the restored tree is fine"
1775        );
1776    }
1777
1778    /// Worktree pointer rewrites are announced ahead of time.
1779    #[test]
1780    fn test_dry_run_announces_worktree_rewrite() {
1781        let dir = TempDir::new().expect("tempdir");
1782        let root = dir.path().join("proj");
1783        touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
1784        let wt = root.join(".worktrees/feature");
1785        touch(&wt.join("f.txt"), "w");
1786        let admin = root.join(".git/worktrees/feature");
1787        fs::create_dir_all(&admin).expect("mkdir");
1788        fs::write(wt.join(".git"), format!("gitdir: {}\n", admin.display())).expect("write");
1789        fs::write(
1790            admin.join("gitdir"),
1791            format!("{}\n", wt.join(".git").display()),
1792        )
1793        .expect("write");
1794
1795        let out = dir.path().join("proj.pack");
1796        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
1797
1798        let dest = dir.path().join("dest");
1799        let predicted = restore(&RestoreOptions {
1800            dry_run: true,
1801            ..RestoreOptions::new(&out, &dest)
1802        })
1803        .expect("dry run");
1804
1805        assert_eq!(predicted.rewritten_worktrees, vec!["feature".to_string()]);
1806        assert!(!dest.exists());
1807    }
1808
1809    // ------------------------------------------------------------------
1810    // crafted archives
1811    //
1812    // A pack is normally produced by this crate, but an archive is an untrusted
1813    // input the moment it arrives from elsewhere. These build the bytes by hand.
1814    // ------------------------------------------------------------------
1815
1816    /// The smallest manifest that parses, for archives built by hand.
1817    const BARE_MANIFEST: &str = "\
1818format_version = 2
1819created_at = \"2026-08-10T00:00:00Z\"
1820source_root = \"/tmp/proj\"
1821project_name = \"proj\"
1822lds_version = \"0.15.0\"
1823
1824[stats]
1825file_count = 0
1826symlink_count = 0
1827total_bytes = 0
1828";
1829
1830    /// Write a hand-built archive: a v2 manifest followed by the given
1831    /// `(builder)` entries, all under the payload prefix already.
1832    fn craft_archive(path: &Path, add_entries: impl FnOnce(&mut tar::Builder<Vec<u8>>)) {
1833        craft_archive_with_manifest(path, BARE_MANIFEST, add_entries);
1834    }
1835
1836    /// The same, with the manifest written out by the caller — which is how an
1837    /// archive lies about where its worktrees go.
1838    fn craft_archive_with_manifest(
1839        path: &Path,
1840        manifest: &str,
1841        add_entries: impl FnOnce(&mut tar::Builder<Vec<u8>>),
1842    ) {
1843        let mut tar = tar::Builder::new(Vec::new());
1844        let mut h = tar::Header::new_gnu();
1845        h.set_size(manifest.len() as u64);
1846        h.set_mode(0o644);
1847        h.set_cksum();
1848        tar.append_data(&mut h, "pack.toml", manifest.as_bytes())
1849            .expect("manifest entry");
1850        add_entries(&mut tar);
1851        let uncompressed = tar.into_inner().expect("finish tar");
1852
1853        let file = File::create(path).expect("create archive");
1854        let mut encoder = zstd::stream::Encoder::new(file, 3).expect("zstd");
1855        std::io::Write::write_all(&mut encoder, &uncompressed).expect("write");
1856        encoder.finish().expect("finish zstd");
1857    }
1858
1859    // NB: a `..` entry cannot be produced here — `tar::Builder` refuses to
1860    // write one — so the `PackError::EscapingArchivePath` guard is exercised
1861    // only against archives from other producers. The guard itself is a plain
1862    // component scan over `rel`; the symlink route below is the one a crafted
1863    // archive can actually reach through this crate's own writer.
1864
1865    /// A file entry routed through an archive-planted symlink is refused, and
1866    /// nothing is written outside the destination.
1867    #[cfg(unix)]
1868    #[test]
1869    fn test_restore_refuses_write_through_planted_symlink() {
1870        let dir = TempDir::new().expect("tempdir");
1871        let outside = dir.path().join("outside");
1872        fs::create_dir_all(&outside).expect("mkdir");
1873
1874        let archive = dir.path().join("evil.pack");
1875        let outside_for_closure = outside.clone();
1876        craft_archive(&archive, |tar| {
1877            // payload/link -> <outside>
1878            let mut h = tar::Header::new_gnu();
1879            h.set_entry_type(tar::EntryType::Symlink);
1880            h.set_size(0);
1881            h.set_mode(0o777);
1882            h.set_cksum();
1883            tar.append_link(&mut h, "payload/link", &outside_for_closure)
1884                .expect("symlink entry");
1885            // payload/link/evil.txt — through the link
1886            let mut h = tar::Header::new_gnu();
1887            h.set_size(4);
1888            h.set_mode(0o644);
1889            h.set_cksum();
1890            tar.append_data(&mut h, "payload/link/evil.txt", &b"pwnd"[..])
1891                .expect("file entry");
1892        });
1893
1894        let dest = dir.path().join("dest");
1895        let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
1896        assert!(
1897            matches!(err, PackError::WriteThroughSymlink { .. }),
1898            "got {err:?}"
1899        );
1900        assert!(
1901            !outside.join("evil.txt").exists(),
1902            "the write must not have escaped through the link"
1903        );
1904    }
1905
1906    /// A manifest that puts a worktree outside the destination is refused, and
1907    /// the file it aimed at is untouched.
1908    ///
1909    /// The entry names in the payload were always checked; this is the other
1910    /// half of the archive making the same claim. `dest.join(path)` with an
1911    /// absolute `path` discards the destination entirely, so the wiring would
1912    /// have written `gitdir: …` over another project's `.git`.
1913    #[test]
1914    fn test_restore_refuses_worktree_path_pointing_outside() {
1915        let dir = TempDir::new().expect("tempdir");
1916        let victim = dir.path().join("victim");
1917        fs::create_dir_all(&victim).expect("mkdir");
1918        let victim_git = victim.join(".git");
1919        fs::write(&victim_git, "gitdir: /somewhere/real\n").expect("write");
1920
1921        let manifest = format!(
1922            "{BARE_MANIFEST}
1923[[worktrees]]
1924name = \"feature\"
1925path = \"{}\"
1926source_path = \"/tmp/proj/.worktrees/feature\"
1927included = true
1928",
1929            victim.display()
1930        );
1931
1932        let archive = dir.path().join("evil.pack");
1933        craft_archive_with_manifest(&archive, &manifest, |tar| {
1934            let mut h = tar::Header::new_gnu();
1935            h.set_size(1);
1936            h.set_mode(0o644);
1937            h.set_cksum();
1938            tar.append_data(&mut h, "payload/a.txt", &b"a"[..])
1939                .expect("file entry");
1940        });
1941
1942        let dest = dir.path().join("dest");
1943        let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
1944        assert!(
1945            matches!(err, PackError::EscapingManifestPath { ref field, .. } if field == "worktrees[].path"),
1946            "got {err:?}"
1947        );
1948        assert_eq!(
1949            fs::read_to_string(&victim_git).expect("read"),
1950            "gitdir: /somewhere/real\n",
1951            "the other project's pointer must be exactly as it was"
1952        );
1953        assert!(
1954            !dest.exists(),
1955            "the manifest is checked before the payload is touched"
1956        );
1957    }
1958
1959    /// A worktree name is one directory under `.git/worktrees/`; one that walks
1960    /// out of there is refused.
1961    #[test]
1962    fn test_restore_refuses_worktree_name_pointing_outside() {
1963        let dir = TempDir::new().expect("tempdir");
1964        let manifest = format!(
1965            "{BARE_MANIFEST}
1966[[worktrees]]
1967name = \"../../../escape\"
1968path = \".worktrees/feature\"
1969source_path = \"/tmp/proj/.worktrees/feature\"
1970included = true
1971"
1972        );
1973
1974        let archive = dir.path().join("evil.pack");
1975        craft_archive_with_manifest(&archive, &manifest, |_| {});
1976
1977        let dest = dir.path().join("dest");
1978        let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
1979        assert!(
1980            matches!(err, PackError::EscapingManifestPath { ref field, .. } if field == "worktrees[].name"),
1981            "got {err:?}"
1982        );
1983    }
1984
1985    /// A dry run refuses the same archive a restore would, rather than
1986    /// describing an operation that is going to abort.
1987    #[test]
1988    fn test_dry_run_refuses_what_restore_refuses() {
1989        let dir = TempDir::new().expect("tempdir");
1990        let manifest = format!(
1991            "{BARE_MANIFEST}
1992[[worktrees]]
1993name = \"feature\"
1994path = \"../outside\"
1995source_path = \"/tmp/proj/.worktrees/feature\"
1996included = true
1997"
1998        );
1999
2000        let archive = dir.path().join("evil.pack");
2001        craft_archive_with_manifest(&archive, &manifest, |_| {});
2002
2003        let err = restore(&RestoreOptions {
2004            dry_run: true,
2005            ..RestoreOptions::new(&archive, dir.path().join("dest"))
2006        })
2007        .expect_err("must refuse");
2008        assert!(
2009            matches!(err, PackError::EscapingManifestPath { .. }),
2010            "got {err:?}"
2011        );
2012    }
2013
2014    /// A hard link is reported with the command that would create it, and is
2015    /// not created.
2016    ///
2017    /// The target names a file on this machine that the pack does not carry, so
2018    /// there is nothing to check it against; creating the link would publish
2019    /// that file into the restored tree under a name the archive chose.
2020    #[cfg(unix)]
2021    #[test]
2022    fn test_restore_reports_hard_link_without_creating_it() {
2023        let dir = TempDir::new().expect("tempdir");
2024        let secret = dir.path().join("private.key");
2025        fs::write(&secret, "PRIVATE").expect("write");
2026
2027        let archive = dir.path().join("linky.pack");
2028        let secret_for_closure = secret.clone();
2029        craft_archive(&archive, |tar| {
2030            let mut h = tar::Header::new_gnu();
2031            h.set_entry_type(tar::EntryType::Link);
2032            h.set_size(0);
2033            h.set_mode(0o644);
2034            h.set_cksum();
2035            tar.append_link(&mut h, "payload/borrowed", &secret_for_closure)
2036                .expect("hard link entry");
2037        });
2038
2039        let dest = dir.path().join("dest");
2040        let report = restore(&RestoreOptions::new(&archive, &dest)).expect("restore");
2041
2042        assert!(
2043            !dest.join("borrowed").exists(),
2044            "the link must not have been created"
2045        );
2046        assert_eq!(report.hard_links_not_created.len(), 1);
2047        let link = &report.hard_links_not_created[0];
2048        assert_eq!(link.path, "borrowed");
2049        assert_eq!(link.target, secret.display().to_string());
2050        assert!(
2051            link.command.starts_with("ln '"),
2052            "the report has to carry a runnable command, got {}",
2053            link.command
2054        );
2055        assert!(link.command.contains(&secret.display().to_string()));
2056        assert!(
2057            report.needs_attention(),
2058            "a path the archive listed and the restore did not create is not silent"
2059        );
2060    }
2061
2062    /// A hard link naming another entry in the same pack — what a tar writer
2063    /// produces for a project that contains hard links — gets a command that
2064    /// points at where that entry landed, not at the archive-relative path.
2065    ///
2066    /// Left as written it would read `ln 'payload/b.txt' …`, which resolves
2067    /// against whatever directory the operator happens to be in.
2068    #[cfg(unix)]
2069    #[test]
2070    fn test_hard_link_into_the_payload_resolves_to_the_restored_file() {
2071        let dir = TempDir::new().expect("tempdir");
2072        let archive = dir.path().join("linky.pack");
2073        craft_archive(&archive, |tar| {
2074            let mut h = tar::Header::new_gnu();
2075            h.set_size(2);
2076            h.set_mode(0o644);
2077            h.set_cksum();
2078            tar.append_data(&mut h, "payload/b.txt", &b"b\n"[..])
2079                .expect("file entry");
2080
2081            let mut h = tar::Header::new_gnu();
2082            h.set_entry_type(tar::EntryType::Link);
2083            h.set_size(0);
2084            h.set_mode(0o644);
2085            h.set_cksum();
2086            tar.append_link(&mut h, "payload/a.txt", "payload/b.txt")
2087                .expect("hard link entry");
2088        });
2089
2090        let dest = dir.path().join("dest");
2091        let report = restore(&RestoreOptions::new(&archive, &dest)).expect("restore");
2092
2093        let link = &report.hard_links_not_created[0];
2094        assert_eq!(
2095            link.target, "payload/b.txt",
2096            "the target is reported as the archive wrote it"
2097        );
2098        assert_eq!(
2099            link.command,
2100            format!(
2101                "ln {} {}",
2102                shell_quote(&report.dest.join("b.txt").display().to_string()),
2103                shell_quote(&report.dest.join("a.txt").display().to_string())
2104            ),
2105            "but the command has to name the file that actually landed"
2106        );
2107        assert!(dest.join("b.txt").is_file(), "the real entry is restored");
2108        assert!(!dest.join("a.txt").exists());
2109    }
2110
2111    /// A target that is not in the payload is left exactly as the archive wrote
2112    /// it — rewriting it would invent a claim the archive did not make.
2113    #[cfg(unix)]
2114    #[test]
2115    fn test_hard_link_outside_the_payload_keeps_its_target() {
2116        let dir = TempDir::new().expect("tempdir");
2117        let archive = dir.path().join("linky.pack");
2118        craft_archive(&archive, |tar| {
2119            let mut h = tar::Header::new_gnu();
2120            h.set_entry_type(tar::EntryType::Link);
2121            h.set_size(0);
2122            h.set_mode(0o644);
2123            h.set_cksum();
2124            tar.append_link(&mut h, "payload/borrowed", "/etc/hosts")
2125                .expect("hard link entry");
2126        });
2127
2128        let dest = dir.path().join("dest");
2129        let report = restore(&RestoreOptions::new(&archive, &dest)).expect("restore");
2130
2131        let link = &report.hard_links_not_created[0];
2132        assert_eq!(link.target, "/etc/hosts");
2133        assert!(
2134            link.command.contains("'/etc/hosts'"),
2135            "got {}",
2136            link.command
2137        );
2138    }
2139
2140    /// The dry run names hard links too — a prediction that omitted them would
2141    /// promise a file the restore is not going to create.
2142    #[cfg(unix)]
2143    #[test]
2144    fn test_dry_run_reports_hard_link() {
2145        let dir = TempDir::new().expect("tempdir");
2146        let target = dir.path().join("elsewhere.txt");
2147        fs::write(&target, "x").expect("write");
2148
2149        let archive = dir.path().join("linky.pack");
2150        let target_for_closure = target.clone();
2151        craft_archive(&archive, |tar| {
2152            let mut h = tar::Header::new_gnu();
2153            h.set_entry_type(tar::EntryType::Link);
2154            h.set_size(0);
2155            h.set_mode(0o644);
2156            h.set_cksum();
2157            tar.append_link(&mut h, "payload/borrowed", &target_for_closure)
2158                .expect("hard link entry");
2159        });
2160
2161        let dest = dir.path().join("dest");
2162        let predicted = restore(&RestoreOptions {
2163            dry_run: true,
2164            ..RestoreOptions::new(&archive, &dest)
2165        })
2166        .expect("dry run");
2167
2168        assert_eq!(predicted.hard_links_not_created.len(), 1);
2169        assert_eq!(predicted.hard_links_not_created[0].path, "borrowed");
2170        assert_eq!(
2171            predicted.entries_written, 0,
2172            "a hard link is not an entry that will be written"
2173        );
2174        assert!(!dest.exists());
2175    }
2176
2177    /// An entry type this crate never writes and a restore should not
2178    /// materialize refuses the archive rather than falling through to tar's
2179    /// "unrecognized means regular file" default.
2180    #[test]
2181    fn test_restore_refuses_device_entry() {
2182        let dir = TempDir::new().expect("tempdir");
2183        let archive = dir.path().join("odd.pack");
2184        craft_archive(&archive, |tar| {
2185            let mut h = tar::Header::new_gnu();
2186            h.set_entry_type(tar::EntryType::Fifo);
2187            h.set_size(0);
2188            h.set_mode(0o644);
2189            h.set_cksum();
2190            tar.append_data(&mut h, "payload/pipe", &b""[..])
2191                .expect("fifo entry");
2192        });
2193
2194        let dest = dir.path().join("dest");
2195        let err = restore(&RestoreOptions::new(&archive, &dest)).expect_err("must refuse");
2196        assert!(
2197            matches!(err, PackError::UnusableArchiveEntry { ref kind, .. } if kind == "named pipe"),
2198            "got {err:?}"
2199        );
2200    }
2201
2202    /// A dry run and the restore it predicts agree on where the destination is,
2203    /// even when the destination does not exist yet and its parent is a link.
2204    ///
2205    /// The prediction used to canonicalize a directory that was not there, fail,
2206    /// and keep the path as written — so it looked for sibling worktrees beside
2207    /// a path the restore would never use.
2208    #[cfg(unix)]
2209    #[test]
2210    fn test_dry_run_and_restore_agree_on_the_destination() {
2211        let dir = TempDir::new().expect("tempdir");
2212        let real = dir.path().join("real");
2213        fs::create_dir_all(&real).expect("mkdir");
2214        let via_link = dir.path().join("link");
2215        std::os::unix::fs::symlink(&real, &via_link).expect("symlink");
2216
2217        let root = dir.path().join("proj");
2218        touch(&root.join("a.txt"), "a");
2219        let out = dir.path().join("proj.pack");
2220        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
2221
2222        // Through the link, into a directory that does not exist yet.
2223        let dest = via_link.join("restored");
2224        let predicted = restore(&RestoreOptions {
2225            dry_run: true,
2226            ..RestoreOptions::new(&out, &dest)
2227        })
2228        .expect("dry run");
2229
2230        // The shape the fix addresses, asserted while it still holds:
2231        // canonicalizing a directory that is not there fails, and the old
2232        // fallback was the path exactly as the caller typed it.
2233        assert!(fs::canonicalize(&dest).is_err());
2234        assert_ne!(
2235            predicted.dest, dest,
2236            "the prediction has to resolve past the path as typed"
2237        );
2238
2239        let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
2240
2241        assert_eq!(
2242            predicted.dest, actual.dest,
2243            "a prediction about another directory is not a prediction"
2244        );
2245        assert_eq!(
2246            actual.dest,
2247            fs::canonicalize(&real)
2248                .expect("canonicalize")
2249                .join("restored"),
2250            "both must resolve through the link"
2251        );
2252    }
2253
2254    /// A manifest larger than the read limit refuses the archive instead of
2255    /// allocating whatever the archive asked for.
2256    #[test]
2257    fn test_inspect_refuses_an_oversized_manifest() {
2258        let dir = TempDir::new().expect("tempdir");
2259        let archive = dir.path().join("bomb.pack");
2260
2261        // Compresses to almost nothing and expands past the limit — the shape
2262        // of the problem, in miniature.
2263        let bloat = "# ".repeat(40 * 1024 * 1024);
2264        let manifest = format!("{BARE_MANIFEST}{bloat}");
2265        craft_archive_with_manifest(&archive, &manifest, |_| {});
2266
2267        let err =
2268            restore(&RestoreOptions::new(&archive, dir.path().join("dest"))).expect_err("refuse");
2269        assert!(
2270            matches!(err, PackError::ManifestTooLarge { .. }),
2271            "got {err:?}"
2272        );
2273    }
2274
2275    /// Skipped secrets and caches are carried into the report so the operator
2276    /// learns what still needs doing.
2277    #[test]
2278    fn test_restore_report_carries_skips() {
2279        let dir = TempDir::new().expect("tempdir");
2280        let root = dir.path().join("proj");
2281        touch(&root.join("a.txt"), "a");
2282        touch(&root.join(".env"), "S=1");
2283        touch(&root.join("target/x"), "bin");
2284
2285        let out = dir.path().join("proj.pack");
2286        create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
2287
2288        let dest = dir.path().join("restored");
2289        let report = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
2290
2291        assert!(report.secrets_not_carried.iter().any(|s| s.path == ".env"));
2292        assert!(report.regenerable_caches.iter().any(|s| s.path == "target"));
2293        assert!(!dest.join(".env").exists());
2294        assert!(report.needs_attention());
2295    }
2296}