.forge/prs/a69fa92c-0488-4685-afca-657f97aa50d5/patch.diff 30.0 KiB 815 lines Raw
1 From f3f68d6f345b749ddcf9f1bfbb387e014fcf452e Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:42:27 +01004 Subject: [PATCH] m6/clone-origin: public-origin bundle-first cloning5 6 ---7  Dockerfile            |   4 +8  entrypoint.sh         |   5 +9  src/build.rs          |  82 ++++++++++-10  src/feed.rs           |   1 +11  src/main.rs           |  14 +-12  src/serve.rs          | 173 ++++++++++++++++++++++13  tests/clone_origin.rs | 328 ++++++++++++++++++++++++++++++++++++++++++14  7 files changed, 602 insertions(+), 5 deletions(-)15  create mode 100644 tests/clone_origin.rs16 17 diff --git a/Dockerfile b/Dockerfile18 index f3997b5..ea5b517 10064419 --- a/Dockerfile20 +++ b/Dockerfile21 @@ -4,6 +4,7 @@22  #   docker run --rm -p 39313:39313 \23  #     -v "$PWD:/data/repo:ro" -v hu13-inbox:/data/inbox \24  #     [-e HU13_BRANCH=main] [-e HU13_CLONE_URL=...] \25 +#     [-e HU13_PUBLIC_ORIGIN=http://host:39313] \26  #     hu1327  #28  # Stage 1: pinned Rust builder. Only the files cargo needs are copied so29 @@ -32,8 +33,11 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh30  RUN chmod +x /usr/local/bin/entrypoint.sh31  # Header config defaults (m6, issues #21 + #24): empty means fallback —32  # repo dir name for the name (never blank), hidden line for the description.33 +# Public origin default (m6, issue #32): empty means unset (bundle-first34 +# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.bundle`).35  ENV HU13_REPO_NAME=""36  ENV HU13_DESCRIPTION=""37 +ENV HU13_PUBLIC_ORIGIN=""38  USER hu1339  EXPOSE 3931340  ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]41 diff --git a/entrypoint.sh b/entrypoint.sh42 index 191fbe9..21a094f 10075543 --- a/entrypoint.sh44 +++ b/entrypoint.sh45 @@ -8,6 +8,8 @@46  # Env:47  #   HU13_BRANCH     branch to build (default: main/master auto-detect)48  #   HU13_CLONE_URL  clone URL to advertise (default: repo origin remote)49 +#   HU13_PUBLIC_ORIGIN public base URL of this server (default: unset;50 +#                   when set, clone block advertises `<origin>/repo.bundle`)51  #   HU13_REPO_NAME  repo display name (default: repo dir name; never blank)52  #   HU13_DESCRIPTION53  #                   site description (default: hidden, no empty <p>)54 @@ -34,6 +36,9 @@ fi55  if [ -n "${HU13_CLONE_URL:-}" ]; then56      set -- "$@" --clone-url "$HU13_CLONE_URL"57  fi58 +if [ -n "${HU13_PUBLIC_ORIGIN:-}" ]; then59 +    set -- "$@" --public-origin "$HU13_PUBLIC_ORIGIN"60 +fi61  if [ -n "${HU13_REPO_NAME:-}" ]; then62      set -- "$@" --repo-name "$HU13_REPO_NAME"63  fi64 diff --git a/src/build.rs b/src/build.rs65 index 5083fa7..db835bb 10064466 --- a/src/build.rs67 +++ b/src/build.rs68 @@ -33,6 +33,11 @@ pub struct BuildOptions {69      pub branch_flag: Option<String>,70      /// `--clone-url` flag (else git origin remote, else none).71      pub clone_url_flag: Option<String>,72 +    /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset).73 +    /// Base URL of this server, e.g. `http://host:39313`. When set, the74 +    /// clone block advertises `git clone <origin>/repo.bundle`.75 +    /// Empty/whitespace falls through to unset.76 +    pub public_origin_flag: Option<String>,77      /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).78      /// `--title` is accepted as a deprecated alias for the same field.79      /// Empty/whitespace always falls back (the header is never blank).80 @@ -146,6 +151,32 @@ pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option<String> {81      }82  }83  84 +/// Resolve the public origin base URL (m6, f0rge issue #32).85 +///86 +/// Precedence: `--public-origin` flag > `$HU13_PUBLIC_ORIGIN` > unset87 +/// (`None`). Flag and env values are trimmed; empty values fall through88 +/// to unset. A single trailing `/` run is stripped so89 +/// `<origin>/repo.bundle` never doubles the slash.90 +#[must_use]91 +pub fn resolve_public_origin(flag: Option<&str>) -> Option<String> {92 +    if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {93 +        let n = f.trim_end_matches('/');94 +        if !n.is_empty() {95 +            return Some(n.to_string());96 +        }97 +    }98 +    if let Ok(env) = std::env::var("HU13_PUBLIC_ORIGIN") {99 +        let t = env.trim();100 +        if !t.is_empty() {101 +            let n = t.trim_end_matches('/');102 +            if !n.is_empty() {103 +                return Some(n.to_string());104 +            }105 +        }106 +    }107 +    None108 +}109 +110  /// README lookup chain (f0rge convention): repo-root `README.md` >111  /// `readme.md` > `README`, else `.forge/wiki/README.md` >112  /// `.forge/wiki/readme.md`, else none.113 @@ -261,13 +292,20 @@ pub fn file_hash(path: &Path) -> Option<String> {114  }115  116  /// Prominent Clone & download section for the code tab (m6, f0rge117 -/// issue #20): stacked no-JS sections with selectable `<pre>` commands —118 -/// `git clone <url>` first when a clone URL is configured, otherwise119 -/// bundle-first — plus the bundle/tarball links with sizes and shas.120 +/// issues #20 + #32): stacked no-JS sections with selectable `<pre>`121 +/// commands. When a public origin is configured the primary command is122 +/// `git clone <origin>/repo.bundle` (exact snapshot note + tarball123 +/// secondary, origin remote demoted to "mirror source"); otherwise124 +/// bundle-first as before (`git clone <url>` first when a clone URL is125 +/// configured, else local bundle) plus the bundle/tarball links with126 +/// sizes and shas.127  #[must_use]128 +#[allow(clippy::too_many_arguments)]129  pub fn clone_block(130      clone_url: Option<&str>,131 +    public_origin: Option<&str>,132      branch: &str,133 +    short_sha: &str,134      bundle_size: u64,135      bundle_sha: Option<&str>,136      tarball_size: u64,137 @@ -299,6 +337,32 @@ pub fn clone_block(138          "<section class=\"clone-block\" aria-label=\"Clone and download\">\n\139           <h2>Clone &amp; download</h2>\n",140      );141 +    if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {142 +        let base = origin.trim_end_matches('/');143 +        let bundle_url = format!("{base}/repo.bundle");144 +        let tarball_url = format!("{base}/repo.tar.gz");145 +        out.push_str("<h3>Clone with git</h3>\n");146 +        out.push_str(&format!(147 +            "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n\148 +             <p>exact snapshot of <code>{}</code> @ <code>{}</code>.</p>\n",149 +            esc(&bundle_url),150 +            esc(branch),151 +            esc(short_sha)152 +        ));153 +        out.push_str("<h3>Download the tarball</h3>\n");154 +        out.push_str(&format!(155 +            "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n<p>{tarball_link}</p>\n",156 +            esc(&tarball_url)157 +        ));158 +        if let Some(url) = clone_url {159 +            out.push_str(&format!(160 +                "<p class=\"muted\">mirror source: <code>{}</code></p>\n",161 +                esc(url)162 +            ));163 +        }164 +        out.push_str("</section>\n");165 +        return out;166 +    }167      if let Some(url) = clone_url {168          out.push_str("<h3>Clone with git</h3>\n");169          out.push_str(&format!(170 @@ -318,7 +382,13 @@ pub fn clone_block(171      if clone_url.is_none() {172          out.push_str(173              "<p class=\"muted\">No clone URL is configured for this mirror — \174 -             the bundle and tarball above carry the full snapshot.</p>\n",175 +             the bundle and tarball above carry the full snapshot. \176 +             They are served from this server: ask the host for its address.</p>\n",177 +        );178 +    } else {179 +        out.push_str(180 +            "<p class=\"muted\">The bundle and tarball above are \181 +             served from this server: ask the host for its address.</p>\n",182          );183      }184      out.push_str("</section>\n");185 @@ -390,6 +460,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu186          .map_err(|e| format!("hu13: {e}"))?;187      let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;188      let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());189 +    let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref());190  191      let name = resolve_repo_name(repo, opts.repo_name_flag.as_deref());192      let description = resolve_description(opts.description_flag.as_deref());193 @@ -613,7 +684,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu194      ));195      body.push_str(&clone_block(196          clone_url.as_deref(),197 +        public_origin.as_deref(),198          &branch,199 +        &short_sha,200          bundle_size,201          file_hash(&snap.bundle).as_deref(),202          tarball_size,203 @@ -724,6 +797,7 @@ mod tests {204          BuildOptions {205              branch_flag: None,206              clone_url_flag: None,207 +            public_origin_flag: None,208              repo_name_flag: None,209              description_flag: None,210              max_log: 100,211 diff --git a/src/feed.rs b/src/feed.rs212 index d55d1c2..c6b99b6 100644213 --- a/src/feed.rs214 +++ b/src/feed.rs215 @@ -431,6 +431,7 @@ mod tests {216          let opts = crate::build::BuildOptions {217              branch_flag: None,218              clone_url_flag: None,219 +            public_origin_flag: None,220              repo_name_flag: None,221              description_flag: None,222              max_log: 100,223 diff --git a/src/main.rs b/src/main.rs224 index 61f93d2..bcacf00 100644225 --- a/src/main.rs226 +++ b/src/main.rs227 @@ -50,6 +50,8 @@ OPTIONS:228      -o <OUT>               Output directory for the static site229          --branch <NAME>    Branch to pin (else HU13_BRANCH, else try main/master)230          --clone-url <URL>  Clone URL to advertise (else git origin remote, else none)231 +        --public-origin <URL> Public base URL of this server (else HU13_PUBLIC_ORIGIN, else unset;232 +                           when set, clone block advertises `<origin>/repo.bundle`)233          --repo-name <S>    Repo display name (else HU13_REPO_NAME, else repo dir name;234                             --title is a deprecated alias; empty falls back, never blank)235          --description <S>  Site description (else HU13_DESCRIPTION, else hidden;236 @@ -118,6 +120,7 @@ fn run_build(args: &[String]) -> i32 {237      let mut out: Option<String> = None;238      let mut branch: Option<String> = None;239      let mut clone_url: Option<String> = None;240 +    let mut public_origin: Option<String> = None;241      let mut repo_name: Option<String> = None;242      let mut description: Option<String> = None;243      let mut max_log: usize = 100;244 @@ -148,6 +151,14 @@ fn run_build(args: &[String]) -> i32 {245                  }246                  clone_url = Some(args[i].clone());247              }248 +            "--public-origin" => {249 +                i += 1;250 +                if i >= args.len() {251 +                    eprintln!("hu13 build: --public-origin needs a value");252 +                    return 2;253 +                }254 +                public_origin = Some(args[i].clone());255 +            }256              "--title" | "--repo-name" => {257                  i += 1;258                  if i >= args.len() {259 @@ -193,12 +204,13 @@ fn run_build(args: &[String]) -> i32 {260          i += 1;261      }262      let (Some(repo), Some(out)) = (repo, out) else {263 -        eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--repo-name S] [--description S] [--max-log N]");264 +        eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--public-origin URL] [--repo-name S] [--description S] [--max-log N]");265          return 2;266      };267      let opts = build::BuildOptions {268          branch_flag: branch,269          clone_url_flag: clone_url,270 +        public_origin_flag: public_origin,271          repo_name_flag: repo_name,272          description_flag: description,273          max_log,274 diff --git a/src/serve.rs b/src/serve.rs275 index 796c6fa..d6429f5 100644276 --- a/src/serve.rs277 +++ b/src/serve.rs278 @@ -80,6 +80,50 @@ struct Shared {279      max_body: usize,280      max_bytes: usize,281      quota: usize,282 +    /// Bare repo materialized from `www/repo.bundle` for git smart HTTP283 +    /// (`GET /repo.bundle/info/refs`, `POST /repo.bundle/git-upload-pack`),284 +    /// so `git clone http://host:port/repo.bundle` works (m6, issue #32).285 +    /// `None` when no bundle was present at startup (static-only mode).286 +    git_dir: Option<PathBuf>,287 +}288 +289 +/// Materialize `www/repo.bundle` into a bare repo for smart HTTP.290 +///291 +/// Runs `git clone --bare --quiet <bundle> <tmp>/repo.git` once at serve292 +/// startup. Returns `None` when no bundle exists or git fails (serve293 +/// continues static-only; `/repo.bundle` still downloads via GET).294 +fn prepare_git_dir(www: &Path) -> Option<PathBuf> {295 +    let bundle = www.join("repo.bundle");296 +    if !bundle.is_file() {297 +        return None;298 +    }299 +    let dir = std::env::temp_dir().join(format!(300 +        "hu13-git-{}-{}",301 +        std::process::id(),302 +        std::time::SystemTime::now()303 +            .duration_since(std::time::UNIX_EPOCH)304 +            .map(|d| d.as_nanos())305 +            .unwrap_or(0)306 +    ));307 +    let git_dir = dir.join("repo.git");308 +    let st = std::process::Command::new("git")309 +        .arg("clone")310 +        .arg("--bare")311 +        .arg("--quiet")312 +        .arg(&bundle)313 +        .arg(&git_dir)314 +        .status();315 +    match st {316 +        Ok(s) if s.success() && git_dir.is_dir() => Some(git_dir),317 +        Ok(s) => {318 +            eprintln!("hu13: prepare git dir from bundle failed ({s}); smart HTTP disabled");319 +            None320 +        }321 +        Err(e) => {322 +            eprintln!("hu13: spawn git for smart HTTP: {e}; smart HTTP disabled");323 +            None324 +        }325 +    }326  }327  328  /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so329 @@ -180,6 +224,7 @@ pub fn run(opts: &ServeOptions) -> i32 {330          max_body: opts.max_body,331          max_bytes: opts.max_bytes,332          quota: opts.quota,333 +        git_dir: prepare_git_dir(&opts.www),334      });335      for conn in listener.incoming() {336          match conn {337 @@ -473,6 +518,14 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {338          handle_inbox(&mut stream, &head, rest, kind, shared);339          return;340      }341 +    // Git smart HTTP for the bundle (m6, issue #32): `git clone342 +    // http://host:port/repo.bundle` speaks smart HTTP (`info/refs` +343 +    // `git-upload-pack`), not a raw bundle download. Serve it from a344 +    // bare repo materialized from `www/repo.bundle` at startup.345 +    if path == "/repo.bundle/info/refs" || path == "/repo.bundle/git-upload-pack" {346 +        handle_git_smart(&mut stream, &head, rest, &path, shared);347 +        return;348 +    }349      // Static: files only, never a listing (dirs → 404). Unknown paths350      // are 404 for ANY method (including POST); a known file with the351      // wrong method is 405. Fail closed, echo nothing.352 @@ -539,6 +592,126 @@ fn content_length(head: &Head) -> Option<usize> {353      header(head, "content-length")?.trim().parse().ok()354  }355  356 +/// Git smart HTTP for `/repo.bundle` (m6, issue #32).357 +///358 +/// `git clone http://host:port/repo.bundle` never GETs the bundle bytes359 +/// directly: `git-remote-http` first asks360 +/// `GET /repo.bundle/info/refs?service=git-upload-pack`, then361 +/// `POST /repo.bundle/git-upload-pack`. Answer both from the bare repo362 +/// materialized at startup (`Shared::git_dir`); anything else (wrong363 +/// method, missing bundle, git failure) is 404/405 with no echo.364 +fn handle_git_smart(365 +    stream: &mut TcpStream,366 +    head: &Head,367 +    rest: Vec<u8>,368 +    path: &str,369 +    shared: &Shared,370 +) {371 +    let Some(git_dir) = shared.git_dir.as_ref() else {372 +        static_error(stream, 404);373 +        return;374 +    };375 +    if path == "/repo.bundle/info/refs" {376 +        if head.method != "GET" {377 +            static_error(stream, 405);378 +            return;379 +        }380 +        let query = head.target.split('?').nth(1).unwrap_or("");381 +        if !query.contains("service=git-upload-pack") {382 +            static_error(stream, 404);383 +            return;384 +        }385 +        let out = std::process::Command::new("git")386 +            .arg("upload-pack")387 +            .arg("--stateless-rpc")388 +            .arg("--advertise-refs")389 +            .arg(git_dir)390 +            .output();391 +        match out {392 +            Ok(o) if o.status.success() => {393 +                let mut body = b"001e# service=git-upload-pack\n0000".to_vec();394 +                body.extend_from_slice(&o.stdout);395 +                respond(396 +                    stream,397 +                    200,398 +                    "application/x-git-upload-pack-advertisement",399 +                    &[("Cache-Control", "no-cache")],400 +                    &body,401 +                );402 +            }403 +            _ => static_error(stream, 404),404 +        }405 +        return;406 +    }407 +    // POST /repo.bundle/git-upload-pack408 +    if head.method != "POST" {409 +        settle(stream, rest.len(), content_length(head));410 +        static_error(stream, 405);411 +        return;412 +    }413 +    let len: usize = match header(head, "content-length") {414 +        Some(v) => match v.trim().parse() {415 +            Ok(n) => n,416 +            Err(_) => return,417 +        },418 +        None => return,419 +    };420 +    // Wants are small; cap the request at 1MiB (fail closed).421 +    const MAX_GIT_REQUEST: usize = 1024 * 1024;422 +    if len > MAX_GIT_REQUEST {423 +        settle(stream, rest.len(), Some(len));424 +        static_error(stream, 413);425 +        return;426 +    }427 +    let mut req_body = rest;428 +    if req_body.len() > len {429 +        req_body.truncate(len);430 +    }431 +    while req_body.len() < len {432 +        let mut chunk = [0u8; 8192];433 +        let want = (len - req_body.len()).min(chunk.len());434 +        match stream.read(&mut chunk[..want]) {435 +            Ok(0) => break,436 +            Ok(n) => req_body.extend_from_slice(&chunk[..n]),437 +            Err(_) => return,438 +        }439 +    }440 +    if req_body.len() != len {441 +        return;442 +    }443 +    let mut child = match std::process::Command::new("git")444 +        .arg("upload-pack")445 +        .arg("--stateless-rpc")446 +        .arg(git_dir)447 +        .stdin(std::process::Stdio::piped())448 +        .stdout(std::process::Stdio::piped())449 +        .stderr(std::process::Stdio::null())450 +        .spawn()451 +    {452 +        Ok(c) => c,453 +        Err(_) => {454 +            static_error(stream, 503);455 +            return;456 +        }457 +    };458 +    if let Some(mut stdin) = child.stdin.take() {459 +        let _ = stdin.write_all(&req_body);460 +    }461 +    let out = child.wait_with_output();462 +    match out {463 +        Ok(o) if o.status.success() => {464 +            respond(465 +                stream,466 +                200,467 +                "application/x-git-upload-pack-result",468 +                &[("Cache-Control", "no-cache")],469 +                &o.stdout,470 +            );471 +        }472 +        _ => static_error(stream, 503),473 +    }474 +}475 +476  #[allow(clippy::too_many_lines)]477  fn handle_inbox(478      stream: &mut TcpStream,479 diff --git a/tests/clone_origin.rs b/tests/clone_origin.rs480 new file mode 100644481 index 0000000..d5881c7482 --- /dev/null483 +++ b/tests/clone_origin.rs484 @@ -0,0 +1,328 @@485 +//! Clone-from-server (m6, f0rge issue #32):486 +//! `--public-origin` / `HU13_PUBLIC_ORIGIN` (flag > env > unset),487 +//! clone-block both-mode asserts, serve `/repo.bundle` byte-identical,488 +//! live `git clone http://host:port/repo.bundle`, entrypoint passthrough.489 +490 +use std::io::{BufRead, BufReader, Read, Write};491 +use std::net::TcpStream;492 +use std::path::{Path, PathBuf};493 +use std::process::{Child, Command, Stdio};494 +use std::sync::Mutex;495 +use std::time::Duration;496 +497 +static ENV_LOCK: Mutex<()> = Mutex::new(());498 +499 +fn bin() -> &'static str {500 +    env!("CARGO_BIN_EXE_hu13")501 +}502 +503 +fn fresh(prefix: &str) -> PathBuf {504 +    let dir = std::env::temp_dir().join(format!(505 +        "hu13-origin-{prefix}-{}-{}",506 +        std::process::id(),507 +        std::time::SystemTime::now()508 +            .duration_since(std::time::UNIX_EPOCH)509 +            .map(|d| d.as_nanos())510 +            .unwrap_or(0)511 +    ));512 +    std::fs::create_dir_all(&dir).unwrap();513 +    dir514 +}515 +516 +fn git(repo: &Path, args: &[&str]) {517 +    let st = Command::new("git")518 +        .arg("-C")519 +        .arg(repo)520 +        .args(args)521 +        .status()522 +        .expect("spawn git");523 +    assert!(st.success(), "git {args:?} failed");524 +}525 +526 +fn fixture_repo() -> PathBuf {527 +    let dir = fresh("repo");528 +    git(&dir, &["init", "-b", "main"]);529 +    git(&dir, &["config", "user.name", "hu13-test"]);530 +    git(&dir, &["config", "user.email", "[email protected]"]);531 +    git(&dir, &["config", "commit.gpgsign", "false"]);532 +    std::fs::write(dir.join("README.md"), "# origin fixture\n").unwrap();533 +    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();534 +    git(&dir, &["add", "-A"]);535 +    git(&dir, &["commit", "-m", "init"]);536 +    dir537 +}538 +539 +fn set_env(k: &str, v: Option<&str>) {540 +    unsafe {541 +        match v {542 +            Some(val) => std::env::set_var(k, val),543 +            None => std::env::remove_var(k),544 +        }545 +    }546 +}547 +548 +fn build_with(repo: &Path, extra: &[&str]) -> PathBuf {549 +    let out = fresh("out");550 +    let st = Command::new(bin())551 +        .arg("build")552 +        .arg(repo)553 +        .arg("-o")554 +        .arg(&out)555 +        .args(extra)556 +        .status()557 +        .expect("spawn hu13 build");558 +    assert!(st.success(), "build exits 0");559 +    out560 +}561 +562 +// ---- block asserts -------------------------------------------------------563 +564 +#[test]565 +fn unset_mode_is_bundle_first_with_server_note() {566 +    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());567 +    set_env("HU13_PUBLIC_ORIGIN", None);568 +    let repo = fixture_repo();569 +    let out = build_with(&repo, &[]);570 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");571 +    assert!(index.contains("clone-block"), "clone block present");572 +    assert!(573 +        index.contains("git clone repo.bundle"),574 +        "bundle-first cmd, got: {}",575 +        &index[index.find("clone-block").unwrap_or(0)..]576 +            [..2000.min(index.len() - index.find("clone-block").unwrap_or(0))]577 +    );578 +    assert!(579 +        index.contains("served from this server: ask the host for its address"),580 +        "server note in unset mode"581 +    );582 +    assert!(583 +        !index.contains("git clone http"),584 +        "no absolute bundle cmd when unset"585 +    );586 +    assert!(index.contains("/repo.bundle"), "bundle linked");587 +    assert!(index.contains("/repo.tar.gz"), "tarball linked");588 +    set_env("HU13_PUBLIC_ORIGIN", None);589 +}590 +591 +#[test]592 +fn public_origin_primary_is_absolute_bundle_with_snapshot_note() {593 +    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());594 +    set_env("HU13_PUBLIC_ORIGIN", None);595 +    let repo = fixture_repo();596 +    let origin = "http://127.0.0.1:39313";597 +    let out = build_with(&repo, &["--public-origin", origin]);598 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");599 +    assert!(600 +        index.contains(&format!("git clone {origin}/repo.bundle")),601 +        "primary absolute bundle cmd"602 +    );603 +    assert!(index.contains("exact snapshot of"), "snapshot note present");604 +    assert!(index.contains("main"), "branch in snapshot note");605 +    assert!(index.contains("/repo.tar.gz"), "secondary tarball link");606 +    assert!(607 +        !index.contains("git clone repo.bundle -b"),608 +        "local bundle cmd replaced by absolute one"609 +    );610 +    set_env("HU13_PUBLIC_ORIGIN", None);611 +}612 +613 +#[test]614 +fn public_origin_with_clone_url_shows_mirror_source() {615 +    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());616 +    set_env("HU13_PUBLIC_ORIGIN", None);617 +    let repo = fixture_repo();618 +    let origin = "http://10.0.0.5:39313";619 +    let mirror = "https://example.com/mirror.git";620 +    let out = build_with(&repo, &["--public-origin", origin, "--clone-url", mirror]);621 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");622 +    assert!(623 +        index.contains(&format!("git clone {origin}/repo.bundle")),624 +        "public origin still primary"625 +    );626 +    assert!(index.contains("mirror source"), "mirror source label");627 +    assert!(index.contains(mirror), "origin remote shown");628 +    // Primary (absolute bundle) must come before the mirror source line.629 +    let primary = index630 +        .find(&format!("git clone {origin}/repo.bundle"))631 +        .expect("primary pos");632 +    let mirror_pos = index.find("mirror source").expect("mirror pos");633 +    assert!(primary < mirror_pos, "primary before mirror source");634 +    set_env("HU13_PUBLIC_ORIGIN", None);635 +}636 +637 +#[test]638 +fn public_origin_flag_beats_env_and_trailing_slash_stripped() {639 +    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());640 +    // Env only -> used.641 +    set_env("HU13_PUBLIC_ORIGIN", Some("http://env.example:39313/"));642 +    let repo = fixture_repo();643 +    let out = build_with(&repo, &[]);644 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");645 +    assert!(646 +        index.contains("git clone http://env.example:39313/repo.bundle"),647 +        "env origin used, trailing slash stripped (no //)"648 +    );649 +    assert!(!index.contains("//repo.bundle"), "no doubled slash");650 +    // Flag beats env.651 +    let out = build_with(&repo, &["--public-origin", "http://flag.example:39313"]);652 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");653 +    assert!(654 +        index.contains("git clone http://flag.example:39313/repo.bundle"),655 +        "flag wins over env"656 +    );657 +    assert!(!index.contains("env.example"), "env suppressed by flag");658 +    // Empty values fall back to unset.659 +    set_env("HU13_PUBLIC_ORIGIN", Some("   "));660 +    let out = build_with(&repo, &[]);661 +    let index = std::fs::read_to_string(out.join("index.html")).expect("index");662 +    assert!(663 +        index.contains("served from this server: ask the host for its address"),664 +        "empty env falls back to unset"665 +    );666 +    set_env("HU13_PUBLIC_ORIGIN", None);667 +}668 +669 +// ---- entrypoint / docker -------------------------------------------------670 +671 +#[test]672 +fn entrypoint_and_dockerfile_pass_public_origin() {673 +    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));674 +    let ep = std::fs::read_to_string(manifest.join("entrypoint.sh")).expect("entrypoint.sh");675 +    for needle in [676 +        "HU13_PUBLIC_ORIGIN",677 +        "--public-origin",678 +        "\"$HU13_PUBLIC_ORIGIN\"",679 +    ] {680 +        assert!(ep.contains(needle), "entrypoint passes {needle}");681 +    }682 +    let docker = std::fs::read_to_string(manifest.join("Dockerfile")).expect("Dockerfile");683 +    assert!(684 +        docker.contains("HU13_PUBLIC_ORIGIN"),685 +        "Dockerfile defaults HU13_PUBLIC_ORIGIN"686 +    );687 +}688 +689 +// ---- serve ---------------------------------------------------------------690 +691 +struct Server {692 +    child: Child,693 +    addr: String,694 +}695 +696 +impl Drop for Server {697 +    fn drop(&mut self) {698 +        self.child.kill().ok();699 +        self.child.wait().ok();700 +    }701 +}702 +703 +fn start_server(www: &Path, inbox: &Path) -> Server {704 +    let mut child = Command::new(bin())705 +        .arg("serve")706 +        .arg("--www")707 +        .arg(www)708 +        .arg("--inbox-dir")709 +        .arg(inbox)710 +        .arg("--listen")711 +        .arg("127.0.0.1:0")712 +        .stdout(Stdio::piped())713 +        .stderr(Stdio::piped())714 +        .spawn()715 +        .expect("spawn hu13 serve");716 +    let stdout = child.stdout.take().expect("piped stdout");717 +    let mut lines = BufReader::new(stdout).lines();718 +    let mut addr = String::new();719 +    for _ in 0..50 {720 +        match lines.next() {721 +            Some(Ok(line)) if line.contains("listening on ") => {722 +                addr = line723 +                    .rsplit("listening on ")724 +                    .next()725 +                    .unwrap_or("")726 +                    .trim()727 +                    .to_string();728 +                break;729 +            }730 +            Some(Ok(_)) => continue,731 +            _ => break,732 +        }733 +    }734 +    assert!(!addr.is_empty(), "serve printed no `listening on` line");735 +    std::thread::sleep(Duration::from_millis(100));736 +    Server { child, addr }737 +}738 +739 +fn get(addr: &str, path: &str) -> Vec<u8> {740 +    let mut s = TcpStream::connect(addr).expect("connect");741 +    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();742 +    s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();743 +    let req = format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");744 +    s.write_all(req.as_bytes()).expect("write");745 +    let mut out = Vec::new();746 +    s.read_to_end(&mut out).expect("read");747 +    out748 +}749 +750 +fn split_response(raw: &[u8]) -> (&[u8], &[u8]) {751 +    let pos = raw752 +        .windows(4)753 +        .position(|w| w == b"\r\n\r\n")754 +        .expect("header/body split");755 +    (&raw[..pos], &raw[pos + 4..])756 +}757 +758 +#[test]759 +fn serve_gets_repo_bundle_bytes_identical() {760 +    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());761 +    set_env("HU13_PUBLIC_ORIGIN", None);762 +    let repo = fixture_repo();763 +    let out = build_with(&repo, &[]);764 +    let inbox = fresh("inbox");765 +    let srv = start_server(&out, &inbox);766 +767 +    let want = std::fs::read(out.join("repo.bundle")).expect("repo.bundle on disk");768 +    assert!(!want.is_empty(), "bundle non-empty");769 +    let raw = get(&srv.addr, "/repo.bundle");770 +    let (head, body) = split_response(&raw);771 +    let head = String::from_utf8_lossy(head).into_owned();772 +    assert!(head.contains("200"), "GET /repo.bundle -> 200, got: {head}");773 +    assert!(774 +        head.contains("X-Content-Type-Options: nosniff"),775 +        "nosniff on bundle"776 +    );777 +    assert_eq!(body, want.as_slice(), "bundle bytes identical");778 +779 +    // Tarball serves too.780 +    let want_tar = std::fs::read(out.join("repo.tar.gz")).expect("tarball on disk");781 +    let raw = get(&srv.addr, "/repo.tar.gz");782 +    let (head, body) = split_response(&raw);783 +    let head = String::from_utf8_lossy(head).into_owned();784 +    assert!(head.contains("200"), "GET /repo.tar.gz -> 200, got: {head}");785 +    assert_eq!(body, want_tar.as_slice(), "tarball bytes identical");786 +    set_env("HU13_PUBLIC_ORIGIN", None);787 +}788 +789 +#[test]790 +fn git_clone_over_http_bundle_works() {791 +    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());792 +    set_env("HU13_PUBLIC_ORIGIN", None);793 +    let repo = fixture_repo();794 +    let out = build_with(&repo, &[]);795 +    let inbox = fresh("inbox");796 +    let srv = start_server(&out, &inbox);797 +    let url = format!("http://{}/repo.bundle", srv.addr);798 +    let dest_parent = fresh("cloned");799 +    let dest = dest_parent.join("cloned");800 +    let st = Command::new("git")801 +        .arg("clone")802 +        .arg("-b")803 +        .arg("main")804 +        .arg(&url)805 +        .arg(&dest)806 +        .status()807 +        .expect("spawn git clone");808 +    assert!(st.success(), "git clone {url} works");809 +    assert!(dest.join("README.md").exists(), "cloned README present");810 +    assert!(dest.join("hello.txt").exists(), "cloned file present");811 +    set_env("HU13_PUBLIC_ORIGIN", None);812 +}813 -- 814 2.55.0815