.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/patch.diff 28.6 KiB 705 lines Raw
1 From ab1398c19bf7c626ec81a11eef52ad666643f774 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 15:52:32 +00004 Subject: [PATCH] build repo.git from the bundle; strip paths and hooks (#555  #56 #57 #58 #59)6 7 #55/#56: export now clones from out/repo.bundle instead of the live8 repo. Bundle transport negotiates reachable objects only, so the9 dumb export is the exact snapshot: one pack, zero loose objects, no10 unreachable history. On this repo: 260 files/226 loose -> 10 files/11 1 pack/0 loose, fsck reports nothing, and a dangling commit in the12 source is absent from the clone.13 14 #57: 'git remote remove origin' drops remote.origin.url, so config15 no longer publishes the build machine's path.16 17 #58: run_plain_git deleted; one run_git_opt(repo: Option, timeout,18 display) runner serves both -C and plain git. Paths stay OsStr19 end to end (snapshot bundle/archive and --git-dir no longer20 round-trip through to_string_lossy). Export gets EXPORT_TIMEOUT21 (120s) instead of sharing the 10s metadata budget.22 23 #59: content-type rule anchored to *.git/HEAD and info/* (no more24 matches for any file named 'head'); unset-origin clone block is25 bundle-first with repo.git as the served-directory form; Dockerfile26 and wiki/deploy describe <origin>/repo.git + HU13_PUBLIC_ORIGIN and27 the /raw/ header rule; /dist gitignored.28 29 tests/clone_export.rs asserts pack/loose/file counts, clean fsck,30 no dangling commit, no path in config, no sample hooks, clone works.31 ---32  .forge/wiki/deploy    |  28 ++++--33  .gitignore            |   1 +34  Dockerfile            |   4 +-35  src/build.rs          |  18 ++--36  src/gitx.rs           | 213 ++++++++++++++++++++----------------------37  src/serve.rs          |  11 ++-38  tests/clone_export.rs | 190 +++++++++++++++++++++++++++++++++++++39  7 files changed, 328 insertions(+), 137 deletions(-)40  create mode 100644 tests/clone_export.rs41 42 diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy43 index 15b953e..3e94c60 10064444 --- a/.forge/wiki/deploy45 +++ b/.forge/wiki/deploy46 @@ -1,10 +1,5 @@47  # Deploy hu13 with Docker48  49 -> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the50 -> branch stays free of `.forge/` writes. Supervisor: move it to the wiki51 -> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md`52 -> via the CLI) and delete this file.53 -54  ## Build the image55  56  From the repo root (build context is sources only — see `.dockerignore`):57 @@ -27,6 +22,7 @@ docker run --rm -p 39313:39313 \58    -v "$PWD:/data/repo:ro" \59    -v hu13-inbox:/data/inbox \60    -e HU13_BRANCH=main \61 +  -e HU13_PUBLIC_ORIGIN=http://localhost:39313 \62    -e HU13_CLONE_URL=https://example.com/mirror.git \63    hu1364  ```65 @@ -35,10 +31,13 @@ Then open `http://localhost:39313/`.66  67  What happens inside (`entrypoint.sh`):68  69 -1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url`70 -   only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure —71 -   bad branch, unreadable repo — aborts LOUD before serving starts72 -   (`set -eu`; serve never runs on a stale/empty site).73 +1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url` /74 +   `--public-origin` only when `HU13_BRANCH` / `HU13_CLONE_URL` /75 +   `HU13_PUBLIC_ORIGIN` are set). Any failure — bad branch, unreadable76 +   repo — aborts LOUD before serving starts (`set -eu`; serve never runs77 +   on a stale/empty site). The build emits `repo.git` (a dumb-HTTP bare78 +   repo: `git clone <origin>/repo.git`, no smart server needed),79 +   `repo.bundle` and `repo.tar.gz`.80  2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox81     --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`,82     `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching83 @@ -52,7 +51,8 @@ What happens inside (`entrypoint.sh`):84  | `/data/repo` (ro)      | —                  | repo checkout to render (required)                 |85  | `/data/inbox`          | —                  | inbox storage; MUST sit outside `--www` (serve refuses otherwise) |86  | `HU13_BRANCH`          | `main`, else `master` | branch to build; wrong name fails the container |87 -| `HU13_CLONE_URL`       | git origin remote  | clone URL advertised on the index                  |88 +| `HU13_PUBLIC_ORIGIN`   | unset              | absolute base URL; enables `git clone <origin>/repo.git` + absolute bundle/tarball links |89 +| `HU13_CLONE_URL`       | git origin remote  | fallback/mirror label shown as "mirror source"     |90  | `HU13_LISTEN`          | `0.0.0.0:39313`    | serve address                                      |91  | `HU13_MAX_TITLE`       | `300`              | inbox title cap (chars)                            |92  | `HU13_MAX_BODY`        | `20000`            | inbox body/prompt cap (chars)                      |93 @@ -62,6 +62,14 @@ What happens inside (`entrypoint.sh`):94  95  ## Notes96  97 +- `dist/` in the repo is gitignored; build outside the checkout (or to98 +  `/srv/www`) so the derived `repo.git` directory never looks tracked.99 +- Static hosting (Cloudflare Pages/nginx): publish the whole `dist/`100 +  directory. `dist/repo.git` is plain files + `update-server-info`, and101 +  `dist/_headers` carries the `/raw/**` download policy for Pages. On102 +  nginx, add the equivalent `add_header Content-Disposition attachment`103 +  for `/raw/` — committed HTML/SVG must never render from that path.104 +105  - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.106    If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`107    (libyaml) with the hand-rolled frontmatter split already used for108 diff --git a/.gitignore b/.gitignore109 index c374b5b..5f196bf 100644110 --- a/.gitignore111 +++ b/.gitignore112 @@ -2,3 +2,4 @@113  .forge/ci/runs/114  .forge/releases/out/115  *.tmp116 +/dist117 diff --git a/Dockerfile b/Dockerfile118 index ea5b517..6c86577 100644119 --- a/Dockerfile120 +++ b/Dockerfile121 @@ -34,7 +34,9 @@ RUN chmod +x /usr/local/bin/entrypoint.sh122  # Header config defaults (m6, issues #21 + #24): empty means fallback —123  # repo dir name for the name (never blank), hidden line for the description.124  # Public origin default (m6, issue #32): empty means unset (bundle-first125 -# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.bundle`).126 +# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.git`127 +# (`git clone <origin>/repo.git` needs no smart server) and128 +# `<origin>/repo.bundle` for the snapshot download).129  ENV HU13_REPO_NAME=""130  ENV HU13_DESCRIPTION=""131  ENV HU13_PUBLIC_ORIGIN=""132 diff --git a/src/build.rs b/src/build.rs133 index 52aeedb..a88adba 100644134 --- a/src/build.rs135 +++ b/src/build.rs136 @@ -745,17 +745,21 @@ pub fn clone_block(137              esc(url)138          ));139      }140 -    out.push_str("<h3>Clone this mirror</h3>\n");141 -    out.push_str(&format!(142 -        "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\143 -         <p>dumb-HTTP checkout served from this server; ask the host for its address.</p>\n",144 -        esc(branch)145 -    ));146 +    // Unset origin: the bundle is the authoritative, self-contained147 +    // clone method (no host address needed). `repo.git` is the same148 +    // snapshot as a served directory, mentioned second (f0rge #59).149      out.push_str("<h3>Clone the bundle</h3>\n");150      out.push_str(&format!(151          "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",152          esc(branch)153      ));154 +    out.push_str("<h3>Served as a directory</h3>\n");155 +    out.push_str(&format!(156 +        "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\157 +         <p>the same snapshot as a dumb-HTTP checkout under this site's \158 +         web root (<code>/repo.git</code>), no smart server needed.</p>\n",159 +        esc(branch)160 +    ));161      out.push_str("<h3>Download the tarball</h3>\n");162      out.push_str(&format!(163          "<pre class=\"clone-cmd\"><code>tar -xzf repo.tar.gz</code></pre>\n<p>{tarball_link}</p>\n"164 @@ -1192,7 +1196,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu165      // rides alongside the bundle/tarball so `git clone <origin>/repo.git`166      // works on any static server (Pages/nginx/serve), no smart HTTP.167      let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;168 -    gitx::export_dumb_repo(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;169 +    gitx::export_dumb_repo(&snap.bundle, &branch, out).map_err(|e| format!("hu13: {e}"))?;170      let bundle_size = std::fs::metadata(&snap.bundle)171          .map(|m| m.len())172          .unwrap_or(0);173 diff --git a/src/gitx.rs b/src/gitx.rs174 index 8d09364..56acaf5 100644175 --- a/src/gitx.rs176 +++ b/src/gitx.rs177 @@ -14,9 +14,16 @@ use std::sync::mpsc;178  use std::thread;179  use std::time::{Duration, Instant};180  181 -/// Timeout for every `git` subprocess.182 +/// Timeout for metadata `git` subprocesses.183  pub const GIT_TIMEOUT: Duration = Duration::from_secs(10);184  185 +/// Timeout for the dumb-export step (a full clone + repack).186 +///187 +/// Deliberately separate from [`GIT_TIMEOUT`]: the export reproduces a188 +/// whole history, so sharing the 10s metadata budget would fail builds189 +/// whose bundle already took seconds (f0rge #58).190 +pub const EXPORT_TIMEOUT: Duration = Duration::from_secs(120);191 +192  /// Default commit count for [`log_n`].193  pub const DEFAULT_LOG_N: usize = 100;194  195 @@ -145,12 +152,26 @@ fn cmd_string(repo: &Path, args: &[&OsStr]) -> String {196  }197  198  fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {199 +    run_git_opt(Some(repo), args, GIT_TIMEOUT, cmd_string(repo, args))200 +}201 +202 +/// Shared runner for `git` subprocesses with a timeout.203 +///204 +/// `repo` is `None` for commands that must not pass `-C` (the dumb-export205 +/// clone/update-server-info pair); `display` is the command label used in206 +/// errors. Paths stay `OsStr` end to end (f0rge #58): no lossy UTF-8207 +/// round-trip.208 +fn run_git_opt(209 +    repo: Option<&Path>,210 +    args: &[&OsStr],211 +    timeout: Duration,212 +    display: String,213 +) -> Result<GitOutput, GitError> {214      let mut cmd = Command::new("git");215 -    cmd.arg("-C")216 -        .arg(repo)217 -        .args(args)218 -        .stdout(Stdio::piped())219 -        .stderr(Stdio::piped());220 +    if let Some(repo) = repo {221 +        cmd.arg("-C").arg(repo);222 +    }223 +    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());224      let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;225  226      let stdout_handle = child.stdout.take();227 @@ -185,7 +206,7 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {228          match child.try_wait() {229              Err(e) => {230                  return Err(GitError::Failed {231 -                    cmd: cmd_string(repo, args),232 +                    cmd: display.clone(),233                      status: "wait-error".to_string(),234                      stderr: e.to_string(),235                  })236 @@ -205,10 +226,10 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {237                  });238              }239              Ok(None) => {240 -                if start.elapsed() >= GIT_TIMEOUT {241 +                if start.elapsed() >= timeout {242                      let _ = child.kill();243                      let _ = child.wait();244 -                    return Err(GitError::Timeout(cmd_string(repo, args)));245 +                    return Err(GitError::Timeout(display.clone()));246                  }247                  thread::sleep(Duration::from_millis(10));248              }249 @@ -847,15 +868,13 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr250      let bundle = out.join("repo.bundle");251      let tarball = out.join("repo.tar.gz");252  253 -    let bundle_str = bundle.to_string_lossy().into_owned();254 -    let os_bundle = OsString::from(&bundle_str);255      let os_branch = OsString::from(branch);256      let a_bundle: &OsStr = OsStr::new("bundle");257      let a_create: &OsStr = OsStr::new("create");258      let args: Vec<&OsStr> = vec![259          a_bundle,260          a_create,261 -        os_bundle.as_os_str(),262 +        bundle.as_os_str(),263          os_branch.as_os_str(),264      ];265      let bytes = run_git(repo, &args);266 @@ -869,14 +888,14 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr267          });268      }269  270 -    let tar_str = tarball.to_string_lossy().into_owned();271 -    let os_tar = OsString::from(&tar_str);272      let os_branch2 = OsString::from(branch);273      let a_archive: &OsStr = OsStr::new("archive");274      let a_fmt: &OsStr = OsStr::new("--format=tar.gz");275 -    let a_out = OsString::from(format!("-o{tar_str}"));276 +    // `git archive -o<path>`: build the flag as an OsString (no lossy277 +    // UTF-8 round-trip for non-UTF-8 output paths).278 +    let mut a_out = OsString::from("-o");279 +    a_out.push(tarball.as_os_str());280      let args2: Vec<&OsStr> = vec![a_archive, a_fmt, a_out.as_os_str(), os_branch2.as_os_str()];281 -    let _ = os_tar;282      let out2 = run_git(repo, &args2)?;283      if !out2.status_ok {284          let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();285 @@ -889,88 +908,24 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr286      Ok(Snapshot { bundle, tarball })287  }288  289 -fn run_plain_git(args: &[&OsStr]) -> Result<GitOutput, GitError> {290 -    let mut cmd = Command::new("git");291 -    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());292 -    let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;293 -    let stdout_handle = child.stdout.take();294 -    let stderr_handle = child.stderr.take();295 -    let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();296 -    let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();297 -    if let Some(mut out) = stdout_handle {298 -        let tx = tx_out;299 -        thread::spawn(move || {300 -            let mut buf = Vec::new();301 -            let _ = out.read_to_end(&mut buf);302 -            let _ = tx.send(buf);303 -        });304 -    } else {305 -        let _ = tx_out.send(Vec::new());306 -    }307 -    if let Some(mut err) = stderr_handle {308 -        let tx = tx_err;309 -        thread::spawn(move || {310 -            let mut buf = Vec::new();311 -            let _ = err.read_to_end(&mut buf);312 -            let _ = tx.send(buf);313 -        });314 -    } else {315 -        let _ = tx_err.send(Vec::new());316 -    }317 -    let start = Instant::now();318 -    loop {319 -        match child.try_wait() {320 -            Err(e) => {321 -                return Err(GitError::Failed {322 -                    cmd: format!(323 -                        "git {}",324 -                        args.iter()325 -                            .map(|a| a.to_string_lossy())326 -                            .collect::<Vec<_>>()327 -                            .join(" ")328 -                    ),329 -                    status: "wait-error".to_string(),330 -                    stderr: e.to_string(),331 -                });332 -            }333 -            Ok(Some(status)) => {334 -                let stdout = rx_out335 -                    .recv_timeout(Duration::from_secs(5))336 -                    .unwrap_or_default();337 -                let stderr = rx_err338 -                    .recv_timeout(Duration::from_secs(5))339 -                    .unwrap_or_default();340 -                return Ok(GitOutput {341 -                    status_ok: status.success(),342 -                    status_str: status.to_string(),343 -                    stdout,344 -                    stderr,345 -                });346 -            }347 -            Ok(None) => {348 -                if start.elapsed() >= GIT_TIMEOUT {349 -                    let _ = child.kill();350 -                    let _ = child.wait();351 -                    return Err(GitError::Timeout(352 -                        "git clone/update-server-info".to_string(),353 -                    ));354 -                }355 -                thread::sleep(Duration::from_millis(10));356 -            }357 -        }358 -    }359 -}360 -361 -/// Export a dumb-HTTP clone dir into `out/repo.git` (issue #54).362 +/// Export a dumb-HTTP clone dir into `out/repo.git` (issues #54/#55/#56/#57).363  ///364 -/// Runs `git clone --bare --single-branch` plus365 -/// `git --git-dir=<dest> update-server-info`, so plain static servers366 -/// (Pages/nginx/`hu13 serve`) all serve `git clone <origin>/repo.git`367 -/// with zero dynamic smart HTTP. No GitHub, no config.368 +/// The source is the **bundle** written by [`snapshot`], not the live369 +/// repo: bundle transport negotiates reachable objects only, so the370 +/// export is exactly the snapshot — one pack, no loose objects, no371 +/// unreachable/dangling history leaking out of `refs` (f0rge #55/#56).372 +/// A local-path clone (`git clone <repo>`) uses local transport instead373 +/// and copies the whole object DB, including unreachable commits.374 +///375 +/// After cloning we drop `remote.origin` (it held the build machine's376 +/// absolute path, f0rge #57), remove the sample hooks (file-count noise),377 +/// and run `update-server-info` so plain static servers378 +/// (Pages/nginx/`hu13 serve`) can serve `git clone <origin>/repo.git`379 +/// with zero dynamic smart HTTP.380  ///381  /// Failures are loud (build fails, never a silent missing dir), and an382  /// existing `out/repo.git` is removed first so stale objects never survive.383 -pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {384 +pub fn export_dumb_repo(bundle: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {385      std::fs::create_dir_all(out)386          .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;387      let dest = out.join("repo.git");388 @@ -978,46 +933,76 @@ pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf389          std::fs::remove_dir_all(&dest)390              .map_err(|e| GitError::Io(format!("clean {}: {e}", dest.to_string_lossy())))?;391      }392 -    let repo_abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());393 -    let repo_str = repo_abs.to_string_lossy().into_owned();394 -    let dest_str = dest.to_string_lossy().into_owned();395 -    let os_repo = OsString::from(&repo_str);396 -    let os_dest = OsString::from(&dest_str);397 -    let os_branch = OsString::from(branch);398 +399 +    // Prefer a relative `.git`-free repo: clone with `--no-hardlinks` is400 +    // irrelevant for bundles, but `--quiet` keeps build output clean.401      let a_clone: &OsStr = OsStr::new("clone");402      let a_bare: &OsStr = OsStr::new("--bare");403 +    let a_quiet: &OsStr = OsStr::new("--quiet");404      let a_single: &OsStr = OsStr::new("--single-branch");405      let a_branch: &OsStr = OsStr::new("--branch");406      let a_sep: &OsStr = OsStr::new("--");407      let args: Vec<&OsStr> = vec![408          a_clone,409          a_bare,410 +        a_quiet,411          a_single,412          a_branch,413 -        os_branch.as_os_str(),414 +        OsStr::new(branch),415          a_sep,416 -        OsStr::new(os_repo.as_os_str()),417 -        os_dest.as_os_str(),418 +        bundle.as_os_str(),419 +        dest.as_os_str(),420      ];421 -    let out1 = run_plain_git(&args)?;422 +    let display =423 +        format!("git clone --bare --single-branch --branch {branch} <repo.bundle> repo.git");424 +    let out1 = run_git_opt(None, &args, EXPORT_TIMEOUT, display.clone())?;425      if !out1.status_ok {426 -        let stderr = String::from_utf8_lossy(&out1.stderr).trim().to_string();427          return Err(GitError::Failed {428 -            cmd: format!("git clone --bare --single-branch --branch {branch} <repo> repo.git"),429 +            cmd: display,430              status: out1.status_str,431 -            stderr,432 +            stderr: String::from_utf8_lossy(&out1.stderr).trim().to_string(),433          });434      }435 -    let git_dir_flag = OsString::from(format!("--git-dir={dest_str}"));436 -    let a_usi: &OsStr = OsStr::new("update-server-info");437 -    let args2: Vec<&OsStr> = vec![git_dir_flag.as_os_str(), a_usi];438 -    let out2 = run_plain_git(&args2)?;439 +440 +    // `remote.origin.url` would publish the local filesystem path.441 +    // Build `--git-dir=<path>` as an OsString: no UTF-8 round-trip.442 +    let mut git_dir_arg = OsString::from("--git-dir=");443 +    git_dir_arg.push(dest.as_os_str());444 +    let a_remote: &OsStr = OsStr::new("remote");445 +    let a_remove: &OsStr = OsStr::new("remove");446 +    let a_origin: &OsStr = OsStr::new("origin");447 +    let args_remote: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_remote, a_remove, a_origin];448 +    let display_remote = "git --git-dir=<dest> remote remove origin".to_string();449 +    let out2 = run_git_opt(None, &args_remote, GIT_TIMEOUT, display_remote.clone())?;450      if !out2.status_ok {451 -        let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();452          return Err(GitError::Failed {453 -            cmd: "git --git-dir=<dest> update-server-info".to_string(),454 +            cmd: display_remote,455              status: out2.status_str,456 -            stderr,457 +            stderr: String::from_utf8_lossy(&out2.stderr).trim().to_string(),458 +        });459 +    }460 +461 +    // Sample hooks are noise for a mirror; removing them also keeps the462 +    // dumb-HTTP file count down for the Pages 20k-file cap.463 +    let hooks = dest.join("hooks");464 +    if let Ok(rd) = std::fs::read_dir(&hooks) {465 +        for ent in rd.flatten() {466 +            let path = ent.path();467 +            if path.extension().is_some_and(|e| e == "sample") {468 +                let _ = std::fs::remove_file(path);469 +            }470 +        }471 +    }472 +473 +    let a_usi: &OsStr = OsStr::new("update-server-info");474 +    let args2: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_usi];475 +    let display_usi = "git --git-dir=<dest> update-server-info".to_string();476 +    let out3 = run_git_opt(None, &args2, EXPORT_TIMEOUT, display_usi.clone())?;477 +    if !out3.status_ok {478 +        return Err(GitError::Failed {479 +            cmd: display_usi,480 +            status: out3.status_str,481 +            stderr: String::from_utf8_lossy(&out3.stderr).trim().to_string(),482          });483      }484      Ok(dest)485 diff --git a/src/serve.rs b/src/serve.rs486 index dc71347..12718d1 100644487 --- a/src/serve.rs488 +++ b/src/serve.rs489 @@ -459,11 +459,12 @@ fn content_type_for(name: &str) -> &'static str {490          "application/atom+xml; charset=utf-8"491      } else if lower.ends_with(".md") || lower.ends_with(".txt") {492          "text/plain; charset=utf-8"493 -    } else if lower.ends_with("/head")494 -        || lower.ends_with("/info/refs")495 -        || lower.ends_with("/info/packs")496 -        || lower.ends_with("/info/alternates")497 -        || lower.ends_with("packed-refs")498 +    } else if lower.ends_with(".git/head")499 +        || (lower.contains(".git/")500 +            && (lower.ends_with("/info/refs")501 +                || lower.ends_with("/info/packs")502 +                || lower.ends_with("/info/alternates")503 +                || lower.ends_with("/packed-refs")))504      {505          // Dumb-HTTP git files (#54): plain text so any static server506          // (Pages/nginx/serve) serves `repo.git` identically. Git ignores507 diff --git a/tests/clone_export.rs b/tests/clone_export.rs508 new file mode 100644509 index 0000000..6c95ec5510 --- /dev/null511 +++ b/tests/clone_export.rs512 @@ -0,0 +1,190 @@513 +//! f0rge #55/#56/#57: `dist/repo.git` is the snapshot, not the object DB.514 +//!515 +//! A local-path clone copies every object (including unreachable history)516 +//! and leaves one loose object per blob; building from `repo.bundle`517 +//! negotiates reachable objects only, giving one pack and no dangling518 +//! commits. The export must also not publish the build machine's path.519 +520 +use std::path::{Path, PathBuf};521 +use std::process::Command;522 +523 +fn fresh(prefix: &str) -> PathBuf {524 +    let dir = std::env::temp_dir().join(format!(525 +        "hu13-export-{prefix}-{}-{}",526 +        std::process::id(),527 +        std::time::SystemTime::now()528 +            .duration_since(std::time::UNIX_EPOCH)529 +            .map(|d| d.as_nanos())530 +            .unwrap_or(0)531 +    ));532 +    std::fs::create_dir_all(&dir).unwrap();533 +    dir534 +}535 +536 +fn git(repo: &Path, args: &[&str]) -> String {537 +    let out = Command::new("git")538 +        .arg("-C")539 +        .arg(repo)540 +        .args(args)541 +        .output()542 +        .expect("spawn git");543 +    assert!(544 +        out.status.success(),545 +        "git {args:?}: {}",546 +        String::from_utf8_lossy(&out.stderr)547 +    );548 +    String::from_utf8_lossy(&out.stdout).trim().to_string()549 +}550 +551 +fn count_files(dir: &Path) -> usize {552 +    let mut n = 0;553 +    let mut stack = vec![dir.to_path_buf()];554 +    while let Some(d) = stack.pop() {555 +        if let Ok(rd) = std::fs::read_dir(&d) {556 +            for e in rd.flatten() {557 +                let p = e.path();558 +                if p.is_dir() {559 +                    stack.push(p);560 +                } else {561 +                    n += 1;562 +                }563 +            }564 +        }565 +    }566 +    n567 +}568 +569 +#[test]570 +fn export_is_exact_snapshot_single_pack_no_paths() {571 +    let repo = fresh("repo");572 +    let out = fresh("out");573 +    for args in [574 +        ["init", "-b", "main"].as_slice(),575 +        &["config", "user.name", "hu13-test"],576 +        &["config", "user.email", "[email protected]"],577 +        &["config", "commit.gpgsign", "false"],578 +    ] {579 +        let st = Command::new("git")580 +            .arg("-C")581 +            .arg(&repo)582 +            .args(args)583 +            .status()584 +            .unwrap();585 +        assert!(st.success());586 +    }587 +    std::fs::write(repo.join("README.md"), "# export\n").unwrap();588 +    std::fs::write(repo.join("a.txt"), "one\n").unwrap();589 +    git(&repo, &["add", "-A"]);590 +    git(&repo, &["commit", "-m", "one"]);591 +    std::fs::write(repo.join("b.txt"), "two\n").unwrap();592 +    git(&repo, &["add", "-A"]);593 +    git(&repo, &["commit", "-m", "two"]);594 +595 +    // Unreachable history: commit on a detached HEAD, then move away.596 +    // This is the #55 leak: local-transport clones carry it, bundles don't.597 +    let base = git(&repo, &["rev-parse", "HEAD"]);598 +    git(&repo, &["checkout", "-q", "--detach"]);599 +    std::fs::write(repo.join("secret.txt"), "unreachable secret\n").unwrap();600 +    git(&repo, &["add", "-A"]);601 +    git(&repo, &["commit", "-q", "-m", "dangling secret"]);602 +    let dangling = git(&repo, &["rev-parse", "HEAD"]);603 +    git(&repo, &["checkout", "-q", "main"]);604 +    std::fs::write(repo.join("secret.txt"), "").ok();605 +    let _ = std::fs::remove_file(repo.join("secret.txt"));606 +    git(&repo, &["reset", "-q", "--hard", &base]);607 +    assert_ne!(dangling, base);608 +609 +    let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))610 +        .arg("build")611 +        .arg(&repo)612 +        .arg("-o")613 +        .arg(&out)614 +        .output()615 +        .unwrap();616 +    assert!(617 +        produced.status.success(),618 +        "build: {}",619 +        String::from_utf8_lossy(&produced.stderr)620 +    );621 +622 +    let gd = out.join("repo.git");623 +    assert!(gd.is_dir(), "repo.git emitted");624 +625 +    // One pack, zero loose objects, few files (Pages 20k cap comfort).626 +    let packs = std::fs::read_dir(gd.join("objects/pack"))627 +        .map(|rd| {628 +            rd.flatten()629 +                .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))630 +                .count()631 +        })632 +        .unwrap_or(0);633 +    assert_eq!(packs, 1, "exactly one pack");634 +    let loose = std::fs::read_dir(gd.join("objects"))635 +        .map(|rd| {636 +            rd.flatten()637 +                .filter(|e| e.path().is_dir())638 +                .filter(|e| e.file_name().len() == 2)639 +                .map(|e| std::fs::read_dir(e.path()).map(|r| r.count()).unwrap_or(0))640 +                .sum::<usize>()641 +        })642 +        .unwrap_or(0);643 +    assert_eq!(loose, 0, "no loose objects");644 +    let files = count_files(&gd);645 +    assert!(files < 40, "dumb repo stays small, got {files} files");646 +647 +    // No unreachable/dangling objects: the snapshot is exact.648 +    let fsck = Command::new("git")649 +        .arg("--git-dir")650 +        .arg(&gd)651 +        .args(["fsck", "--unreachable", "--dangling", "--no-progress"])652 +        .output()653 +        .unwrap();654 +    let report = String::from_utf8_lossy(&fsck.stdout);655 +    assert!(656 +        report.trim().is_empty(),657 +        "no unreachable/dangling objects, got: {report}"658 +    );659 +660 +    // The dangling commit is genuinely absent from the export.661 +    let has = Command::new("git")662 +        .arg("--git-dir")663 +        .arg(&gd)664 +        .args(["cat-file", "-e", &dangling])665 +        .status()666 +        .unwrap();667 +    assert!(!has.success(), "dangling commit must not be exported");668 +669 +    // No local filesystem path in config.670 +    let config = std::fs::read_to_string(gd.join("config")).unwrap();671 +    assert!(672 +        !config.contains(&repo.to_string_lossy().to_string()),673 +        "config must not leak the source path: {config}"674 +    );675 +    assert!(!config.contains("remote"), "no remote section: {config}");676 +677 +    // Sample hooks are gone.678 +    let samples = std::fs::read_dir(gd.join("hooks"))679 +        .map(|rd| {680 +            rd.flatten()681 +                .filter(|e| e.path().extension().is_some_and(|x| x == "sample"))682 +                .count()683 +        })684 +        .unwrap_or(0);685 +    assert_eq!(samples, 0, "no sample hooks");686 +687 +    // And it still clones.688 +    let dest = fresh("cloned").join("c");689 +    let st = Command::new("git")690 +        .arg("clone")691 +        .arg("--quiet")692 +        .arg("-b")693 +        .arg("main")694 +        .arg(&gd)695 +        .arg(&dest)696 +        .status()697 +        .unwrap();698 +    assert!(st.success(), "clone from exported repo.git works");699 +    assert!(dest.join("README.md").is_file());700 +    let log = git(&dest, &["log", "--oneline"]);701 +    assert_eq!(log.lines().count(), 2, "only reachable commits: {log}");702 +}703 -- 704 2.56.0705