tests/clone_export.rs 6.1 KiB 190 lines Raw
1 //! f0rge #55/#56/#57: `dist/repo.git` is the snapshot, not the object DB.2 //!3 //! A local-path clone copies every object (including unreachable history)4 //! and leaves one loose object per blob; building from `repo.bundle`5 //! negotiates reachable objects only, giving one pack and no dangling6 //! commits. The export must also not publish the build machine's path.7 8 use std::path::{Path, PathBuf};9 use std::process::Command;10 11 fn fresh(prefix: &str) -> PathBuf {12     let dir = std::env::temp_dir().join(format!(13         "hu13-export-{prefix}-{}-{}",14         std::process::id(),15         std::time::SystemTime::now()16             .duration_since(std::time::UNIX_EPOCH)17             .map(|d| d.as_nanos())18             .unwrap_or(0)19     ));20     std::fs::create_dir_all(&dir).unwrap();21     dir22 }23 24 fn git(repo: &Path, args: &[&str]) -> String {25     let out = Command::new("git")26         .arg("-C")27         .arg(repo)28         .args(args)29         .output()30         .expect("spawn git");31     assert!(32         out.status.success(),33         "git {args:?}: {}",34         String::from_utf8_lossy(&out.stderr)35     );36     String::from_utf8_lossy(&out.stdout).trim().to_string()37 }38 39 fn count_files(dir: &Path) -> usize {40     let mut n = 0;41     let mut stack = vec![dir.to_path_buf()];42     while let Some(d) = stack.pop() {43         if let Ok(rd) = std::fs::read_dir(&d) {44             for e in rd.flatten() {45                 let p = e.path();46                 if p.is_dir() {47                     stack.push(p);48                 } else {49                     n += 1;50                 }51             }52         }53     }54     n55 }56 57 #[test]58 fn export_is_exact_snapshot_single_pack_no_paths() {59     let repo = fresh("repo");60     let out = fresh("out");61     for args in [62         ["init", "-b", "main"].as_slice(),63         &["config", "user.name", "hu13-test"],64         &["config", "user.email", "[email protected]"],65         &["config", "commit.gpgsign", "false"],66     ] {67         let st = Command::new("git")68             .arg("-C")69             .arg(&repo)70             .args(args)71             .status()72             .unwrap();73         assert!(st.success());74     }75     std::fs::write(repo.join("README.md"), "# export\n").unwrap();76     std::fs::write(repo.join("a.txt"), "one\n").unwrap();77     git(&repo, &["add", "-A"]);78     git(&repo, &["commit", "-m", "one"]);79     std::fs::write(repo.join("b.txt"), "two\n").unwrap();80     git(&repo, &["add", "-A"]);81     git(&repo, &["commit", "-m", "two"]);82 83     // Unreachable history: commit on a detached HEAD, then move away.84     // This is the #55 leak: local-transport clones carry it, bundles don't.85     let base = git(&repo, &["rev-parse", "HEAD"]);86     git(&repo, &["checkout", "-q", "--detach"]);87     std::fs::write(repo.join("secret.txt"), "unreachable secret\n").unwrap();88     git(&repo, &["add", "-A"]);89     git(&repo, &["commit", "-q", "-m", "dangling secret"]);90     let dangling = git(&repo, &["rev-parse", "HEAD"]);91     git(&repo, &["checkout", "-q", "main"]);92     std::fs::write(repo.join("secret.txt"), "").ok();93     let _ = std::fs::remove_file(repo.join("secret.txt"));94     git(&repo, &["reset", "-q", "--hard", &base]);95     assert_ne!(dangling, base);96 97     let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))98         .arg("build")99         .arg(&repo)100         .arg("-o")101         .arg(&out)102         .output()103         .unwrap();104     assert!(105         produced.status.success(),106         "build: {}",107         String::from_utf8_lossy(&produced.stderr)108     );109 110     let gd = out.join("repo.git");111     assert!(gd.is_dir(), "repo.git emitted");112 113     // One pack, zero loose objects, few files (Pages 20k cap comfort).114     let packs = std::fs::read_dir(gd.join("objects/pack"))115         .map(|rd| {116             rd.flatten()117                 .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))118                 .count()119         })120         .unwrap_or(0);121     assert_eq!(packs, 1, "exactly one pack");122     let loose = std::fs::read_dir(gd.join("objects"))123         .map(|rd| {124             rd.flatten()125                 .filter(|e| e.path().is_dir())126                 .filter(|e| e.file_name().len() == 2)127                 .map(|e| std::fs::read_dir(e.path()).map(|r| r.count()).unwrap_or(0))128                 .sum::<usize>()129         })130         .unwrap_or(0);131     assert_eq!(loose, 0, "no loose objects");132     let files = count_files(&gd);133     assert!(files < 40, "dumb repo stays small, got {files} files");134 135     // No unreachable/dangling objects: the snapshot is exact.136     let fsck = Command::new("git")137         .arg("--git-dir")138         .arg(&gd)139         .args(["fsck", "--unreachable", "--dangling", "--no-progress"])140         .output()141         .unwrap();142     let report = String::from_utf8_lossy(&fsck.stdout);143     assert!(144         report.trim().is_empty(),145         "no unreachable/dangling objects, got: {report}"146     );147 148     // The dangling commit is genuinely absent from the export.149     let has = Command::new("git")150         .arg("--git-dir")151         .arg(&gd)152         .args(["cat-file", "-e", &dangling])153         .status()154         .unwrap();155     assert!(!has.success(), "dangling commit must not be exported");156 157     // No local filesystem path in config.158     let config = std::fs::read_to_string(gd.join("config")).unwrap();159     assert!(160         !config.contains(&repo.to_string_lossy().to_string()),161         "config must not leak the source path: {config}"162     );163     assert!(!config.contains("remote"), "no remote section: {config}");164 165     // Sample hooks are gone.166     let samples = std::fs::read_dir(gd.join("hooks"))167         .map(|rd| {168             rd.flatten()169                 .filter(|e| e.path().extension().is_some_and(|x| x == "sample"))170                 .count()171         })172         .unwrap_or(0);173     assert_eq!(samples, 0, "no sample hooks");174 175     // And it still clones.176     let dest = fresh("cloned").join("c");177     let st = Command::new("git")178         .arg("clone")179         .arg("--quiet")180         .arg("-b")181         .arg("main")182         .arg(&gd)183         .arg(&dest)184         .status()185         .unwrap();186     assert!(st.success(), "clone from exported repo.git works");187     assert!(dest.join("README.md").is_file());188     let log = git(&dest, &["log", "--oneline"]);189     assert_eq!(log.lines().count(), 2, "only reachable commits: {log}");190 }