1use 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#[derive(Debug, Clone)]
48pub struct RestoreOptions {
49 pub archive: PathBuf,
51 pub dest: PathBuf,
53 pub force: bool,
61 pub dry_run: bool,
73}
74
75impl RestoreOptions {
76 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#[derive(Debug, Clone)]
89pub struct RestoreReport {
90 pub dest: PathBuf,
92 pub manifest: Manifest,
94 pub dry_run: bool,
96 pub entries_written: u64,
98 pub destination_exists: bool,
103 pub would_overwrite: Vec<String>,
107 pub would_remain: Vec<String>,
113 pub rewritten_worktrees: Vec<String>,
119 pub missing_worktrees: Vec<String>,
124 pub conflicting_worktrees: Vec<WorktreeConflict>,
133 pub missing_worktree_parent: Option<String>,
139 pub dangling_symlinks: Vec<SymlinkRecord>,
145 pub link_reports_suppressed: Vec<String>,
153 pub regenerable_caches: Vec<CacheRecord>,
159 pub secrets_not_carried: Vec<SkipRecord>,
161 pub hard_links_not_created: Vec<HardLinkRecord>,
167}
168
169#[derive(Debug, Clone, Serialize)]
189pub struct HardLinkRecord {
190 pub path: String,
192 pub target: String,
200 pub command: String,
206}
207
208impl HardLinkRecord {
209 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
224fn 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
243fn shell_quote(raw: &str) -> String {
246 format!("'{}'", raw.replace('\'', r"'\''"))
247}
248
249#[derive(Debug, Clone, Serialize)]
258pub struct WorktreeConflict {
259 pub name: String,
261 pub path: String,
263 pub found: String,
265}
266
267impl RestoreReport {
268 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
285pub 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
355fn 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
418fn 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 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
455fn 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
494fn 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 return false;
521 }
522
523 !dest.join(&resolved).exists()
524}
525
526pub(crate) enum EntryPlan {
536 Extract,
538 HardLink,
540 Refuse(&'static str),
542}
543
544pub(crate) fn entry_plan(kind: tar::EntryType) -> EntryPlan {
546 use tar::EntryType as T;
547
548 match kind {
549 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 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
566fn 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 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 continue;
586 };
587 if rel.as_os_str().is_empty() {
588 continue;
589 }
590 let rel = Contained::entry(rel)?;
595
596 match entry_plan(entry.header().entry_type()) {
597 EntryPlan::Extract => {}
598 EntryPlan::HardLink => {
599 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 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 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
642fn 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 Err(_) => {}
683 }
684 }
685 Ok(())
686}
687
688#[derive(Debug, Clone)]
694struct PointerPair {
695 name: String,
697 admin: PathBuf,
699 dot_git: PathBuf,
701}
702
703#[derive(Debug, Default)]
705struct WorktreePlan {
706 pairs: Vec<PointerPair>,
708 missing: Vec<String>,
710 conflicted: Vec<WorktreeConflict>,
713 missing_parent: Option<String>,
715}
716
717struct Checked<'a> {
725 worktrees: Vec<CheckedWorktree<'a>>,
727 origin: Option<CheckedOrigin<'a>>,
729}
730
731struct CheckedWorktree<'a> {
733 name: Contained,
735 path: Option<Contained>,
741 record: &'a WorktreeRecord,
743}
744
745struct CheckedOrigin<'a> {
747 name: Contained,
749 origin: &'a WorktreeOrigin,
751}
752
753fn 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 for link in &manifest.symlinks {
791 Contained::path("symlinks[].path", &link.path)?;
792 }
793
794 Ok(Checked { worktrees, origin })
795}
796
797enum Wiring {
806 Wire(PointerPair),
808 Occupied(WorktreeConflict),
811 Absent,
813 Nothing,
815}
816
817fn 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 Wiring::Absent => plan.missing_parent = Some(origin.origin.parent_root.clone()),
861 Wiring::Nothing => {}
862 }
863 }
864
865 plan
866}
867
868fn 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 let worktree_root = rel.join_onto(dest);
887 if unpacked && (!admin.is_dir() || !worktree_root.is_dir()) {
888 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 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 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
955fn 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
1002fn 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
1020fn same_place(claimed: &Path, expected: &Path) -> bool {
1027 canonicalize_or(claimed) == canonicalize_or(expected)
1028}
1029
1030fn 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
1045fn 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
1062fn is_dangling(dest: &Path, record: &SymlinkRecord) -> bool {
1064 let link = dest.join(&record.path);
1065 if !link.is_symlink() {
1066 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 #[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 #[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 #[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 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 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 fn pointer(path: &Path) -> String {
1231 fs::read_to_string(path).expect("read").trim().to_string()
1232 }
1233
1234 #[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 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 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 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 #[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 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 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 #[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 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 #[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 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 assert_eq!(
1685 fs::read_to_string(dest.join("shared.txt")).expect("read"),
1686 "old content"
1687 );
1688 }
1689
1690 #[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 #[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 let actual = restore(&RestoreOptions::new(&out, &dest)).expect("restore");
1749 assert_eq!(actual.dangling_symlinks.len(), 1);
1750 }
1751
1752 #[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 #[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 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 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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}