#29 m6/clone-origin

mergedpassopened
details
branches
master ← m6/clone-origin
updated
id
a69fa92c-0488-4685-afca-657f97aa50d5

Patch

Dockerfile+4 −0
diff --git a/Dockerfile b/Dockerfileindex f3997b5..ea5b517 100644--- a/Dockerfile+++ b/Dockerfile@@ -4,6 +4,7 @@44 #   docker run --rm -p 39313:39313 \55 #     -v "$PWD:/data/repo:ro" -v hu13-inbox:/data/inbox \66 #     [-e HU13_BRANCH=main] [-e HU13_CLONE_URL=...] \7+#     [-e HU13_PUBLIC_ORIGIN=http://host:39313] \78 #     hu1389 #910 # Stage 1: pinned Rust builder. Only the files cargo needs are copied so@@ -32,8 +33,11 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh3233 RUN chmod +x /usr/local/bin/entrypoint.sh3334 # Header config defaults (m6, issues #21 + #24): empty means fallback —3435 # repo dir name for the name (never blank), hidden line for the description.36+# Public origin default (m6, issue #32): empty means unset (bundle-first37+# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.bundle`).3538 ENV HU13_REPO_NAME=""3639 ENV HU13_DESCRIPTION=""40+ENV HU13_PUBLIC_ORIGIN=""3741 USER hu133842 EXPOSE 393133943 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
entrypoint.sh+5 −0
diff --git a/entrypoint.sh b/entrypoint.shindex 191fbe9..21a094f 100755--- a/entrypoint.sh+++ b/entrypoint.sh@@ -8,6 +8,8 @@88 # Env:99 #   HU13_BRANCH     branch to build (default: main/master auto-detect)1010 #   HU13_CLONE_URL  clone URL to advertise (default: repo origin remote)11+#   HU13_PUBLIC_ORIGIN public base URL of this server (default: unset;12+#                   when set, clone block advertises `<origin>/repo.bundle`)1113 #   HU13_REPO_NAME  repo display name (default: repo dir name; never blank)1214 #   HU13_DESCRIPTION1315 #                   site description (default: hidden, no empty <p>)@@ -34,6 +36,9 @@ fi3436 if [ -n "${HU13_CLONE_URL:-}" ]; then3537     set -- "$@" --clone-url "$HU13_CLONE_URL"3638 fi39+if [ -n "${HU13_PUBLIC_ORIGIN:-}" ]; then40+    set -- "$@" --public-origin "$HU13_PUBLIC_ORIGIN"41+fi3742 if [ -n "${HU13_REPO_NAME:-}" ]; then3843     set -- "$@" --repo-name "$HU13_REPO_NAME"3944 fi
src/build.rs+78 −4
diff --git a/src/build.rs b/src/build.rsindex 5083fa7..db835bb 100644--- a/src/build.rs+++ b/src/build.rs@@ -33,6 +33,11 @@ pub struct BuildOptions {3333     pub branch_flag: Option<String>,3434     /// `--clone-url` flag (else git origin remote, else none).3535     pub clone_url_flag: Option<String>,36+    /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset).37+    /// Base URL of this server, e.g. `http://host:39313`. When set, the38+    /// clone block advertises `git clone <origin>/repo.bundle`.39+    /// Empty/whitespace falls through to unset.40+    pub public_origin_flag: Option<String>,3641     /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).3742     /// `--title` is accepted as a deprecated alias for the same field.3843     /// Empty/whitespace always falls back (the header is never blank).@@ -146,6 +151,32 @@ pub fn resolve_clone_url(repo: &Path, flag: Option<&str>) -> Option<String> {146151     }147152 }148153 154+/// Resolve the public origin base URL (m6, f0rge issue #32).155+///156+/// Precedence: `--public-origin` flag > `$HU13_PUBLIC_ORIGIN` > unset157+/// (`None`). Flag and env values are trimmed; empty values fall through158+/// to unset. A single trailing `/` run is stripped so159+/// `<origin>/repo.bundle` never doubles the slash.160+#[must_use]161+pub fn resolve_public_origin(flag: Option<&str>) -> Option<String> {162+    if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) {163+        let n = f.trim_end_matches('/');164+        if !n.is_empty() {165+            return Some(n.to_string());166+        }167+    }168+    if let Ok(env) = std::env::var("HU13_PUBLIC_ORIGIN") {169+        let t = env.trim();170+        if !t.is_empty() {171+            let n = t.trim_end_matches('/');172+            if !n.is_empty() {173+                return Some(n.to_string());174+            }175+        }176+    }177+    None178+}179+149180 /// README lookup chain (f0rge convention): repo-root `README.md` >150181 /// `readme.md` > `README`, else `.forge/wiki/README.md` >151182 /// `.forge/wiki/readme.md`, else none.@@ -261,13 +292,20 @@ pub fn file_hash(path: &Path) -> Option<String> {261292 }262293 263294 /// Prominent Clone & download section for the code tab (m6, f0rge264-/// issue #20): stacked no-JS sections with selectable `<pre>` commands —265-/// `git clone <url>` first when a clone URL is configured, otherwise266-/// bundle-first — plus the bundle/tarball links with sizes and shas.295+/// issues #20 + #32): stacked no-JS sections with selectable `<pre>`296+/// commands. When a public origin is configured the primary command is297+/// `git clone <origin>/repo.bundle` (exact snapshot note + tarball298+/// secondary, origin remote demoted to "mirror source"); otherwise299+/// bundle-first as before (`git clone <url>` first when a clone URL is300+/// configured, else local bundle) plus the bundle/tarball links with301+/// sizes and shas.267302 #[must_use]303+#[allow(clippy::too_many_arguments)]268304 pub fn clone_block(269305     clone_url: Option<&str>,306+    public_origin: Option<&str>,270307     branch: &str,308+    short_sha: &str,271309     bundle_size: u64,272310     bundle_sha: Option<&str>,273311     tarball_size: u64,@@ -299,6 +337,32 @@ pub fn clone_block(299337         "<section class=\"clone-block\" aria-label=\"Clone and download\">\n\300338          <h2>Clone &amp; download</h2>\n",301339     );340+    if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {341+        let base = origin.trim_end_matches('/');342+        let bundle_url = format!("{base}/repo.bundle");343+        let tarball_url = format!("{base}/repo.tar.gz");344+        out.push_str("<h3>Clone with git</h3>\n");345+        out.push_str(&format!(346+            "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n\347+             <p>exact snapshot of <code>{}</code> @ <code>{}</code>.</p>\n",348+            esc(&bundle_url),349+            esc(branch),350+            esc(short_sha)351+        ));352+        out.push_str("<h3>Download the tarball</h3>\n");353+        out.push_str(&format!(354+            "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n<p>{tarball_link}</p>\n",355+            esc(&tarball_url)356+        ));357+        if let Some(url) = clone_url {358+            out.push_str(&format!(359+                "<p class=\"muted\">mirror source: <code>{}</code></p>\n",360+                esc(url)361+            ));362+        }363+        out.push_str("</section>\n");364+        return out;365+    }302366     if let Some(url) = clone_url {303367         out.push_str("<h3>Clone with git</h3>\n");304368         out.push_str(&format!(@@ -318,7 +382,13 @@ pub fn clone_block(318382     if clone_url.is_none() {319383         out.push_str(320384             "<p class=\"muted\">No clone URL is configured for this mirror — \321-             the bundle and tarball above carry the full snapshot.</p>\n",385+             the bundle and tarball above carry the full snapshot. \386+             They are served from this server: ask the host for its address.</p>\n",387+        );388+    } else {389+        out.push_str(390+            "<p class=\"muted\">The bundle and tarball above are \391+             served from this server: ask the host for its address.</p>\n",322392         );323393     }324394     out.push_str("</section>\n");@@ -390,6 +460,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu390460         .map_err(|e| format!("hu13: {e}"))?;391461     let short_sha = gitx::rev_parse_short(repo, &branch).map_err(|e| format!("hu13: {e}"))?;392462     let clone_url = resolve_clone_url(repo, opts.clone_url_flag.as_deref());463+    let public_origin = resolve_public_origin(opts.public_origin_flag.as_deref());393464 394465     let name = resolve_repo_name(repo, opts.repo_name_flag.as_deref());395466     let description = resolve_description(opts.description_flag.as_deref());@@ -613,7 +684,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu613684     ));614685     body.push_str(&clone_block(615686         clone_url.as_deref(),687+        public_origin.as_deref(),616688         &branch,689+        &short_sha,617690         bundle_size,618691         file_hash(&snap.bundle).as_deref(),619692         tarball_size,@@ -724,6 +797,7 @@ mod tests {724797         BuildOptions {725798             branch_flag: None,726799             clone_url_flag: None,800+            public_origin_flag: None,727801             repo_name_flag: None,728802             description_flag: None,729803             max_log: 100,
src/feed.rs+1 −0
diff --git a/src/feed.rs b/src/feed.rsindex d55d1c2..c6b99b6 100644--- a/src/feed.rs+++ b/src/feed.rs@@ -431,6 +431,7 @@ mod tests {431431         let opts = crate::build::BuildOptions {432432             branch_flag: None,433433             clone_url_flag: None,434+            public_origin_flag: None,434435             repo_name_flag: None,435436             description_flag: None,436437             max_log: 100,
src/main.rs+13 −1
diff --git a/src/main.rs b/src/main.rsindex 61f93d2..bcacf00 100644--- a/src/main.rs+++ b/src/main.rs@@ -50,6 +50,8 @@ OPTIONS:5050     -o <OUT>               Output directory for the static site5151         --branch <NAME>    Branch to pin (else HU13_BRANCH, else try main/master)5252         --clone-url <URL>  Clone URL to advertise (else git origin remote, else none)53+        --public-origin <URL> Public base URL of this server (else HU13_PUBLIC_ORIGIN, else unset;54+                           when set, clone block advertises `<origin>/repo.bundle`)5355         --repo-name <S>    Repo display name (else HU13_REPO_NAME, else repo dir name;5456                            --title is a deprecated alias; empty falls back, never blank)5557         --description <S>  Site description (else HU13_DESCRIPTION, else hidden;@@ -118,6 +120,7 @@ fn run_build(args: &[String]) -> i32 {118120     let mut out: Option<String> = None;119121     let mut branch: Option<String> = None;120122     let mut clone_url: Option<String> = None;123+    let mut public_origin: Option<String> = None;121124     let mut repo_name: Option<String> = None;122125     let mut description: Option<String> = None;123126     let mut max_log: usize = 100;@@ -148,6 +151,14 @@ fn run_build(args: &[String]) -> i32 {148151                 }149152                 clone_url = Some(args[i].clone());150153             }154+            "--public-origin" => {155+                i += 1;156+                if i >= args.len() {157+                    eprintln!("hu13 build: --public-origin needs a value");158+                    return 2;159+                }160+                public_origin = Some(args[i].clone());161+            }151162             "--title" | "--repo-name" => {152163                 i += 1;153164                 if i >= args.len() {@@ -193,12 +204,13 @@ fn run_build(args: &[String]) -> i32 {193204         i += 1;194205     }195206     let (Some(repo), Some(out)) = (repo, out) else {196-        eprintln!("hu13 build: usage: hu13 build <REPO> -o <OUT> [--branch X] [--clone-url URL] [--repo-name S] [--description S] [--max-log N]");207+        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]");197208         return 2;198209     };199210     let opts = build::BuildOptions {200211         branch_flag: branch,201212         clone_url_flag: clone_url,213+        public_origin_flag: public_origin,202214         repo_name_flag: repo_name,203215         description_flag: description,204216         max_log,
src/serve.rs+173 −0
diff --git a/src/serve.rs b/src/serve.rsindex 796c6fa..d6429f5 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -80,6 +80,50 @@ struct Shared {8080     max_body: usize,8181     max_bytes: usize,8282     quota: usize,83+    /// Bare repo materialized from `www/repo.bundle` for git smart HTTP84+    /// (`GET /repo.bundle/info/refs`, `POST /repo.bundle/git-upload-pack`),85+    /// so `git clone http://host:port/repo.bundle` works (m6, issue #32).86+    /// `None` when no bundle was present at startup (static-only mode).87+    git_dir: Option<PathBuf>,88+}89+90+/// Materialize `www/repo.bundle` into a bare repo for smart HTTP.91+///92+/// Runs `git clone --bare --quiet <bundle> <tmp>/repo.git` once at serve93+/// startup. Returns `None` when no bundle exists or git fails (serve94+/// continues static-only; `/repo.bundle` still downloads via GET).95+fn prepare_git_dir(www: &Path) -> Option<PathBuf> {96+    let bundle = www.join("repo.bundle");97+    if !bundle.is_file() {98+        return None;99+    }100+    let dir = std::env::temp_dir().join(format!(101+        "hu13-git-{}-{}",102+        std::process::id(),103+        std::time::SystemTime::now()104+            .duration_since(std::time::UNIX_EPOCH)105+            .map(|d| d.as_nanos())106+            .unwrap_or(0)107+    ));108+    let git_dir = dir.join("repo.git");109+    let st = std::process::Command::new("git")110+        .arg("clone")111+        .arg("--bare")112+        .arg("--quiet")113+        .arg(&bundle)114+        .arg(&git_dir)115+        .status();116+    match st {117+        Ok(s) if s.success() && git_dir.is_dir() => Some(git_dir),118+        Ok(s) => {119+            eprintln!("hu13: prepare git dir from bundle failed ({s}); smart HTTP disabled");120+            None121+        }122+        Err(e) => {123+            eprintln!("hu13: spawn git for smart HTTP: {e}; smart HTTP disabled");124+            None125+        }126+    }83127 }84128 85129 /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so@@ -180,6 +224,7 @@ pub fn run(opts: &ServeOptions) -> i32 {180224         max_body: opts.max_body,181225         max_bytes: opts.max_bytes,182226         quota: opts.quota,227+        git_dir: prepare_git_dir(&opts.www),183228     });184229     for conn in listener.incoming() {185230         match conn {@@ -473,6 +518,14 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {473518         handle_inbox(&mut stream, &head, rest, kind, shared);474519         return;475520     }521+    // Git smart HTTP for the bundle (m6, issue #32): `git clone522+    // http://host:port/repo.bundle` speaks smart HTTP (`info/refs` +523+    // `git-upload-pack`), not a raw bundle download. Serve it from a524+    // bare repo materialized from `www/repo.bundle` at startup.525+    if path == "/repo.bundle/info/refs" || path == "/repo.bundle/git-upload-pack" {526+        handle_git_smart(&mut stream, &head, rest, &path, shared);527+        return;528+    }476529     // Static: files only, never a listing (dirs → 404). Unknown paths477530     // are 404 for ANY method (including POST); a known file with the478531     // wrong method is 405. Fail closed, echo nothing.@@ -539,6 +592,126 @@ fn content_length(head: &Head) -> Option<usize> {539592     header(head, "content-length")?.trim().parse().ok()540593 }541594 595+/// Git smart HTTP for `/repo.bundle` (m6, issue #32).596+///597+/// `git clone http://host:port/repo.bundle` never GETs the bundle bytes598+/// directly: `git-remote-http` first asks599+/// `GET /repo.bundle/info/refs?service=git-upload-pack`, then600+/// `POST /repo.bundle/git-upload-pack`. Answer both from the bare repo601+/// materialized at startup (`Shared::git_dir`); anything else (wrong602+/// method, missing bundle, git failure) is 404/405 with no echo.603+fn handle_git_smart(604+    stream: &mut TcpStream,605+    head: &Head,606+    rest: Vec<u8>,607+    path: &str,608+    shared: &Shared,609+) {610+    let Some(git_dir) = shared.git_dir.as_ref() else {611+        static_error(stream, 404);612+        return;613+    };614+    if path == "/repo.bundle/info/refs" {615+        if head.method != "GET" {616+            static_error(stream, 405);617+            return;618+        }619+        let query = head.target.split('?').nth(1).unwrap_or("");620+        if !query.contains("service=git-upload-pack") {621+            static_error(stream, 404);622+            return;623+        }624+        let out = std::process::Command::new("git")625+            .arg("upload-pack")626+            .arg("--stateless-rpc")627+            .arg("--advertise-refs")628+            .arg(git_dir)629+            .output();630+        match out {631+            Ok(o) if o.status.success() => {632+                let mut body = b"001e# service=git-upload-pack\n0000".to_vec();633+                body.extend_from_slice(&o.stdout);634+                respond(635+                    stream,636+                    200,637+                    "application/x-git-upload-pack-advertisement",638+                    &[("Cache-Control", "no-cache")],639+                    &body,640+                );641+            }642+            _ => static_error(stream, 404),643+        }644+        return;645+    }646+    // POST /repo.bundle/git-upload-pack647+    if head.method != "POST" {648+        settle(stream, rest.len(), content_length(head));649+        static_error(stream, 405);650+        return;651+    }652+    let len: usize = match header(head, "content-length") {653+        Some(v) => match v.trim().parse() {654+            Ok(n) => n,655+            Err(_) => return,656+        },657+        None => return,658+    };659+    // Wants are small; cap the request at 1MiB (fail closed).660+    const MAX_GIT_REQUEST: usize = 1024 * 1024;661+    if len > MAX_GIT_REQUEST {662+        settle(stream, rest.len(), Some(len));663+        static_error(stream, 413);664+        return;665+    }666+    let mut req_body = rest;667+    if req_body.len() > len {668+        req_body.truncate(len);669+    }670+    while req_body.len() < len {671+        let mut chunk = [0u8; 8192];672+        let want = (len - req_body.len()).min(chunk.len());673+        match stream.read(&mut chunk[..want]) {674+            Ok(0) => break,675+            Ok(n) => req_body.extend_from_slice(&chunk[..n]),676+            Err(_) => return,677+        }678+    }679+    if req_body.len() != len {680+        return;681+    }682+    let mut child = match std::process::Command::new("git")683+        .arg("upload-pack")684+        .arg("--stateless-rpc")685+        .arg(git_dir)686+        .stdin(std::process::Stdio::piped())687+        .stdout(std::process::Stdio::piped())688+        .stderr(std::process::Stdio::null())689+        .spawn()690+    {691+        Ok(c) => c,692+        Err(_) => {693+            static_error(stream, 503);694+            return;695+        }696+    };697+    if let Some(mut stdin) = child.stdin.take() {698+        let _ = stdin.write_all(&req_body);699+    }700+    let out = child.wait_with_output();701+    match out {702+        Ok(o) if o.status.success() => {703+            respond(704+                stream,705+                200,706+                "application/x-git-upload-pack-result",707+                &[("Cache-Control", "no-cache")],708+                &o.stdout,709+            );710+        }711+        _ => static_error(stream, 503),712+    }713+}714+542715 #[allow(clippy::too_many_lines)]543716 fn handle_inbox(544717     stream: &mut TcpStream,
tests/clone_origin.rs+328 −1
diff --git a/tests/clone_origin.rs b/tests/clone_origin.rsnew file mode 100644index 0000000..d5881c7--- /dev/null+++ b/tests/clone_origin.rs@@ -0,0 +1,328 @@1+//! Clone-from-server (m6, f0rge issue #32):2+//! `--public-origin` / `HU13_PUBLIC_ORIGIN` (flag > env > unset),3+//! clone-block both-mode asserts, serve `/repo.bundle` byte-identical,4+//! live `git clone http://host:port/repo.bundle`, entrypoint passthrough.5+6+use std::io::{BufRead, BufReader, Read, Write};7+use std::net::TcpStream;8+use std::path::{Path, PathBuf};9+use std::process::{Child, Command, Stdio};10+use std::sync::Mutex;11+use std::time::Duration;12+13+static ENV_LOCK: Mutex<()> = Mutex::new(());14+15+fn bin() -> &'static str {16+    env!("CARGO_BIN_EXE_hu13")17+}18+19+fn fresh(prefix: &str) -> PathBuf {20+    let dir = std::env::temp_dir().join(format!(21+        "hu13-origin-{prefix}-{}-{}",22+        std::process::id(),23+        std::time::SystemTime::now()24+            .duration_since(std::time::UNIX_EPOCH)25+            .map(|d| d.as_nanos())26+            .unwrap_or(0)27+    ));28+    std::fs::create_dir_all(&dir).unwrap();29+    dir30+}31+32+fn git(repo: &Path, args: &[&str]) {33+    let st = Command::new("git")34+        .arg("-C")35+        .arg(repo)36+        .args(args)37+        .status()38+        .expect("spawn git");39+    assert!(st.success(), "git {args:?} failed");40+}41+42+fn fixture_repo() -> PathBuf {43+    let dir = fresh("repo");44+    git(&dir, &["init", "-b", "main"]);45+    git(&dir, &["config", "user.name", "hu13-test"]);46+    git(&dir, &["config", "user.email", "[email protected]"]);47+    git(&dir, &["config", "commit.gpgsign", "false"]);48+    std::fs::write(dir.join("README.md"), "# origin fixture\n").unwrap();49+    std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();50+    git(&dir, &["add", "-A"]);51+    git(&dir, &["commit", "-m", "init"]);52+    dir53+}54+55+fn set_env(k: &str, v: Option<&str>) {56+    unsafe {57+        match v {58+            Some(val) => std::env::set_var(k, val),59+            None => std::env::remove_var(k),60+        }61+    }62+}63+64+fn build_with(repo: &Path, extra: &[&str]) -> PathBuf {65+    let out = fresh("out");66+    let st = Command::new(bin())67+        .arg("build")68+        .arg(repo)69+        .arg("-o")70+        .arg(&out)71+        .args(extra)72+        .status()73+        .expect("spawn hu13 build");74+    assert!(st.success(), "build exits 0");75+    out76+}77+78+// ---- block asserts -------------------------------------------------------79+80+#[test]81+fn unset_mode_is_bundle_first_with_server_note() {82+    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());83+    set_env("HU13_PUBLIC_ORIGIN", None);84+    let repo = fixture_repo();85+    let out = build_with(&repo, &[]);86+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");87+    assert!(index.contains("clone-block"), "clone block present");88+    assert!(89+        index.contains("git clone repo.bundle"),90+        "bundle-first cmd, got: {}",91+        &index[index.find("clone-block").unwrap_or(0)..]92+            [..2000.min(index.len() - index.find("clone-block").unwrap_or(0))]93+    );94+    assert!(95+        index.contains("served from this server: ask the host for its address"),96+        "server note in unset mode"97+    );98+    assert!(99+        !index.contains("git clone http"),100+        "no absolute bundle cmd when unset"101+    );102+    assert!(index.contains("/repo.bundle"), "bundle linked");103+    assert!(index.contains("/repo.tar.gz"), "tarball linked");104+    set_env("HU13_PUBLIC_ORIGIN", None);105+}106+107+#[test]108+fn public_origin_primary_is_absolute_bundle_with_snapshot_note() {109+    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());110+    set_env("HU13_PUBLIC_ORIGIN", None);111+    let repo = fixture_repo();112+    let origin = "http://127.0.0.1:39313";113+    let out = build_with(&repo, &["--public-origin", origin]);114+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");115+    assert!(116+        index.contains(&format!("git clone {origin}/repo.bundle")),117+        "primary absolute bundle cmd"118+    );119+    assert!(index.contains("exact snapshot of"), "snapshot note present");120+    assert!(index.contains("main"), "branch in snapshot note");121+    assert!(index.contains("/repo.tar.gz"), "secondary tarball link");122+    assert!(123+        !index.contains("git clone repo.bundle -b"),124+        "local bundle cmd replaced by absolute one"125+    );126+    set_env("HU13_PUBLIC_ORIGIN", None);127+}128+129+#[test]130+fn public_origin_with_clone_url_shows_mirror_source() {131+    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());132+    set_env("HU13_PUBLIC_ORIGIN", None);133+    let repo = fixture_repo();134+    let origin = "http://10.0.0.5:39313";135+    let mirror = "https://example.com/mirror.git";136+    let out = build_with(&repo, &["--public-origin", origin, "--clone-url", mirror]);137+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");138+    assert!(139+        index.contains(&format!("git clone {origin}/repo.bundle")),140+        "public origin still primary"141+    );142+    assert!(index.contains("mirror source"), "mirror source label");143+    assert!(index.contains(mirror), "origin remote shown");144+    // Primary (absolute bundle) must come before the mirror source line.145+    let primary = index146+        .find(&format!("git clone {origin}/repo.bundle"))147+        .expect("primary pos");148+    let mirror_pos = index.find("mirror source").expect("mirror pos");149+    assert!(primary < mirror_pos, "primary before mirror source");150+    set_env("HU13_PUBLIC_ORIGIN", None);151+}152+153+#[test]154+fn public_origin_flag_beats_env_and_trailing_slash_stripped() {155+    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());156+    // Env only -> used.157+    set_env("HU13_PUBLIC_ORIGIN", Some("http://env.example:39313/"));158+    let repo = fixture_repo();159+    let out = build_with(&repo, &[]);160+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");161+    assert!(162+        index.contains("git clone http://env.example:39313/repo.bundle"),163+        "env origin used, trailing slash stripped (no //)"164+    );165+    assert!(!index.contains("//repo.bundle"), "no doubled slash");166+    // Flag beats env.167+    let out = build_with(&repo, &["--public-origin", "http://flag.example:39313"]);168+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");169+    assert!(170+        index.contains("git clone http://flag.example:39313/repo.bundle"),171+        "flag wins over env"172+    );173+    assert!(!index.contains("env.example"), "env suppressed by flag");174+    // Empty values fall back to unset.175+    set_env("HU13_PUBLIC_ORIGIN", Some("   "));176+    let out = build_with(&repo, &[]);177+    let index = std::fs::read_to_string(out.join("index.html")).expect("index");178+    assert!(179+        index.contains("served from this server: ask the host for its address"),180+        "empty env falls back to unset"181+    );182+    set_env("HU13_PUBLIC_ORIGIN", None);183+}184+185+// ---- entrypoint / docker -------------------------------------------------186+187+#[test]188+fn entrypoint_and_dockerfile_pass_public_origin() {189+    let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));190+    let ep = std::fs::read_to_string(manifest.join("entrypoint.sh")).expect("entrypoint.sh");191+    for needle in [192+        "HU13_PUBLIC_ORIGIN",193+        "--public-origin",194+        "\"$HU13_PUBLIC_ORIGIN\"",195+    ] {196+        assert!(ep.contains(needle), "entrypoint passes {needle}");197+    }198+    let docker = std::fs::read_to_string(manifest.join("Dockerfile")).expect("Dockerfile");199+    assert!(200+        docker.contains("HU13_PUBLIC_ORIGIN"),201+        "Dockerfile defaults HU13_PUBLIC_ORIGIN"202+    );203+}204+205+// ---- serve ---------------------------------------------------------------206+207+struct Server {208+    child: Child,209+    addr: String,210+}211+212+impl Drop for Server {213+    fn drop(&mut self) {214+        self.child.kill().ok();215+        self.child.wait().ok();216+    }217+}218+219+fn start_server(www: &Path, inbox: &Path) -> Server {220+    let mut child = Command::new(bin())221+        .arg("serve")222+        .arg("--www")223+        .arg(www)224+        .arg("--inbox-dir")225+        .arg(inbox)226+        .arg("--listen")227+        .arg("127.0.0.1:0")228+        .stdout(Stdio::piped())229+        .stderr(Stdio::piped())230+        .spawn()231+        .expect("spawn hu13 serve");232+    let stdout = child.stdout.take().expect("piped stdout");233+    let mut lines = BufReader::new(stdout).lines();234+    let mut addr = String::new();235+    for _ in 0..50 {236+        match lines.next() {237+            Some(Ok(line)) if line.contains("listening on ") => {238+                addr = line239+                    .rsplit("listening on ")240+                    .next()241+                    .unwrap_or("")242+                    .trim()243+                    .to_string();244+                break;245+            }246+            Some(Ok(_)) => continue,247+            _ => break,248+        }249+    }250+    assert!(!addr.is_empty(), "serve printed no `listening on` line");251+    std::thread::sleep(Duration::from_millis(100));252+    Server { child, addr }253+}254+255+fn get(addr: &str, path: &str) -> Vec<u8> {256+    let mut s = TcpStream::connect(addr).expect("connect");257+    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();258+    s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();259+    let req = format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");260+    s.write_all(req.as_bytes()).expect("write");261+    let mut out = Vec::new();262+    s.read_to_end(&mut out).expect("read");263+    out264+}265+266+fn split_response(raw: &[u8]) -> (&[u8], &[u8]) {267+    let pos = raw268+        .windows(4)269+        .position(|w| w == b"\r\n\r\n")270+        .expect("header/body split");271+    (&raw[..pos], &raw[pos + 4..])272+}273+274+#[test]275+fn serve_gets_repo_bundle_bytes_identical() {276+    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());277+    set_env("HU13_PUBLIC_ORIGIN", None);278+    let repo = fixture_repo();279+    let out = build_with(&repo, &[]);280+    let inbox = fresh("inbox");281+    let srv = start_server(&out, &inbox);282+283+    let want = std::fs::read(out.join("repo.bundle")).expect("repo.bundle on disk");284+    assert!(!want.is_empty(), "bundle non-empty");285+    let raw = get(&srv.addr, "/repo.bundle");286+    let (head, body) = split_response(&raw);287+    let head = String::from_utf8_lossy(head).into_owned();288+    assert!(head.contains("200"), "GET /repo.bundle -> 200, got: {head}");289+    assert!(290+        head.contains("X-Content-Type-Options: nosniff"),291+        "nosniff on bundle"292+    );293+    assert_eq!(body, want.as_slice(), "bundle bytes identical");294+295+    // Tarball serves too.296+    let want_tar = std::fs::read(out.join("repo.tar.gz")).expect("tarball on disk");297+    let raw = get(&srv.addr, "/repo.tar.gz");298+    let (head, body) = split_response(&raw);299+    let head = String::from_utf8_lossy(head).into_owned();300+    assert!(head.contains("200"), "GET /repo.tar.gz -> 200, got: {head}");301+    assert_eq!(body, want_tar.as_slice(), "tarball bytes identical");302+    set_env("HU13_PUBLIC_ORIGIN", None);303+}304+305+#[test]306+fn git_clone_over_http_bundle_works() {307+    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());308+    set_env("HU13_PUBLIC_ORIGIN", None);309+    let repo = fixture_repo();310+    let out = build_with(&repo, &[]);311+    let inbox = fresh("inbox");312+    let srv = start_server(&out, &inbox);313+    let url = format!("http://{}/repo.bundle", srv.addr);314+    let dest_parent = fresh("cloned");315+    let dest = dest_parent.join("cloned");316+    let st = Command::new("git")317+        .arg("clone")318+        .arg("-b")319+        .arg("main")320+        .arg(&url)321+        .arg(&dest)322+        .status()323+        .expect("spawn git clone");324+    assert!(st.success(), "git clone {url} works");325+    assert!(dest.join("README.md").exists(), "cloned README present");326+    assert!(dest.join("hello.txt").exists(), "cloned file present");327+    set_env("HU13_PUBLIC_ORIGIN", None);328+}0-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls