From f3f68d6f345b749ddcf9f1bfbb387e014fcf452e Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 07:42:27 +0100 Subject: [PATCH] m6/clone-origin: public-origin bundle-first cloning --- Dockerfile | 4 + entrypoint.sh | 5 + src/build.rs | 82 ++++++++++- src/feed.rs | 1 + src/main.rs | 14 +- src/serve.rs | 173 ++++++++++++++++++++++ tests/clone_origin.rs | 328 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 602 insertions(+), 5 deletions(-) create mode 100644 tests/clone_origin.rs diff --git a/Dockerfile b/Dockerfile index f3997b5..ea5b517 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ # docker run --rm -p 39313:39313 \ # -v "$PWD:/data/repo:ro" -v hu13-inbox:/data/inbox \ # [-e HU13_BRANCH=main] [-e HU13_CLONE_URL=...] \ +# [-e HU13_PUBLIC_ORIGIN=http://host:39313] \ # hu13 # # Stage 1: pinned Rust builder. Only the files cargo needs are copied so @@ -32,8 +33,11 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh # Header config defaults (m6, issues #21 + #24): empty means fallback — # repo dir name for the name (never blank), hidden line for the description. +# Public origin default (m6, issue #32): empty means unset (bundle-first +# presentation; set HU13_PUBLIC_ORIGIN to advertise `/repo.bundle`). ENV HU13_REPO_NAME="" ENV HU13_DESCRIPTION="" +ENV HU13_PUBLIC_ORIGIN="" USER hu13 EXPOSE 39313 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index 191fbe9..21a094f 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -8,6 +8,8 @@ # Env: # HU13_BRANCH branch to build (default: main/master auto-detect) # HU13_CLONE_URL clone URL to advertise (default: repo origin remote) +# HU13_PUBLIC_ORIGIN public base URL of this server (default: unset; +# when set, clone block advertises `/repo.bundle`) # HU13_REPO_NAME repo display name (default: repo dir name; never blank) # HU13_DESCRIPTION # site description (default: hidden, no empty

) @@ -34,6 +36,9 @@ fi if [ -n "${HU13_CLONE_URL:-}" ]; then set -- "$@" --clone-url "$HU13_CLONE_URL" fi +if [ -n "${HU13_PUBLIC_ORIGIN:-}" ]; then + set -- "$@" --public-origin "$HU13_PUBLIC_ORIGIN" +fi if [ -n "${HU13_REPO_NAME:-}" ]; then set -- "$@" --repo-name "$HU13_REPO_NAME" fi diff --git a/src/build.rs b/src/build.rs index 5083fa7..db835bb 100644 --- a/src/build.rs +++ b/src/build.rs @@ -33,6 +33,11 @@ pub struct BuildOptions { pub branch_flag: Option, /// `--clone-url` flag (else git origin remote, else none). pub clone_url_flag: Option, + /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset). + /// Base URL of this server, e.g. `http://host:39313`. When set, the + /// clone block advertises `git clone /repo.bundle`. + /// Empty/whitespace falls through to unset. + pub public_origin_flag: Option, /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name). /// `--title` is accepted as a deprecated alias for the same field. /// 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 { } } +/// Resolve the public origin base URL (m6, f0rge issue #32). +/// +/// Precedence: `--public-origin` flag > `$HU13_PUBLIC_ORIGIN` > unset +/// (`None`). Flag and env values are trimmed; empty values fall through +/// to unset. A single trailing `/` run is stripped so +/// `/repo.bundle` never doubles the slash. +#[must_use] +pub fn resolve_public_origin(flag: Option<&str>) -> Option { + if let Some(f) = flag.map(str::trim).filter(|s| !s.is_empty()) { + let n = f.trim_end_matches('/'); + if !n.is_empty() { + return Some(n.to_string()); + } + } + if let Ok(env) = std::env::var("HU13_PUBLIC_ORIGIN") { + let t = env.trim(); + if !t.is_empty() { + let n = t.trim_end_matches('/'); + if !n.is_empty() { + return Some(n.to_string()); + } + } + } + None +} + /// README lookup chain (f0rge convention): repo-root `README.md` > /// `readme.md` > `README`, else `.forge/wiki/README.md` > /// `.forge/wiki/readme.md`, else none. @@ -261,13 +292,20 @@ pub fn file_hash(path: &Path) -> Option { } /// Prominent Clone & download section for the code tab (m6, f0rge -/// issue #20): stacked no-JS sections with selectable `

` commands —
-/// `git clone ` first when a clone URL is configured, otherwise
-/// bundle-first — plus the bundle/tarball links with sizes and shas.
+/// issues #20 + #32): stacked no-JS sections with selectable `
`
+/// commands. When a public origin is configured the primary command is
+/// `git clone /repo.bundle` (exact snapshot note + tarball
+/// secondary, origin remote demoted to "mirror source"); otherwise
+/// bundle-first as before (`git clone ` first when a clone URL is
+/// configured, else local bundle) plus the bundle/tarball links with
+/// sizes and shas.
 #[must_use]
+#[allow(clippy::too_many_arguments)]
 pub fn clone_block(
     clone_url: Option<&str>,
+    public_origin: Option<&str>,
     branch: &str,
+    short_sha: &str,
     bundle_size: u64,
     bundle_sha: Option<&str>,
     tarball_size: u64,
@@ -299,6 +337,32 @@ pub fn clone_block(
         "
\n\

Clone & download

\n", ); + if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) { + let base = origin.trim_end_matches('/'); + let bundle_url = format!("{base}/repo.bundle"); + let tarball_url = format!("{base}/repo.tar.gz"); + out.push_str("

Clone with git

\n"); + out.push_str(&format!( + "
git clone {}
\n\ +

exact snapshot of {} @ {}.

\n", + esc(&bundle_url), + esc(branch), + esc(short_sha) + )); + out.push_str("

Download the tarball

\n"); + out.push_str(&format!( + "
curl -O {}
\n

{tarball_link}

\n", + esc(&tarball_url) + )); + if let Some(url) = clone_url { + out.push_str(&format!( + "

mirror source: {}

\n", + esc(url) + )); + } + out.push_str("
\n"); + return out; + } if let Some(url) = clone_url { out.push_str("

Clone with git

\n"); out.push_str(&format!( @@ -318,7 +382,13 @@ pub fn clone_block( if clone_url.is_none() { out.push_str( "

No clone URL is configured for this mirror — \ - the bundle and tarball above carry the full snapshot.

\n", + the bundle and tarball above carry the full snapshot. \ + They are served from this server: ask the host for its address.

\n", + ); + } else { + out.push_str( + "

The bundle and tarball above are \ + served from this server: ask the host for its address.

\n", ); } out.push_str("\n"); @@ -390,6 +460,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Result Output directory for the static site --branch Branch to pin (else HU13_BRANCH, else try main/master) --clone-url Clone URL to advertise (else git origin remote, else none) + --public-origin Public base URL of this server (else HU13_PUBLIC_ORIGIN, else unset; + when set, clone block advertises `/repo.bundle`) --repo-name Repo display name (else HU13_REPO_NAME, else repo dir name; --title is a deprecated alias; empty falls back, never blank) --description Site description (else HU13_DESCRIPTION, else hidden; @@ -118,6 +120,7 @@ fn run_build(args: &[String]) -> i32 { let mut out: Option = None; let mut branch: Option = None; let mut clone_url: Option = None; + let mut public_origin: Option = None; let mut repo_name: Option = None; let mut description: Option = None; let mut max_log: usize = 100; @@ -148,6 +151,14 @@ fn run_build(args: &[String]) -> i32 { } clone_url = Some(args[i].clone()); } + "--public-origin" => { + i += 1; + if i >= args.len() { + eprintln!("hu13 build: --public-origin needs a value"); + return 2; + } + public_origin = Some(args[i].clone()); + } "--title" | "--repo-name" => { i += 1; if i >= args.len() { @@ -193,12 +204,13 @@ fn run_build(args: &[String]) -> i32 { i += 1; } let (Some(repo), Some(out)) = (repo, out) else { - eprintln!("hu13 build: usage: hu13 build -o [--branch X] [--clone-url URL] [--repo-name S] [--description S] [--max-log N]"); + eprintln!("hu13 build: usage: hu13 build -o [--branch X] [--clone-url URL] [--public-origin URL] [--repo-name S] [--description S] [--max-log N]"); return 2; }; let opts = build::BuildOptions { branch_flag: branch, clone_url_flag: clone_url, + public_origin_flag: public_origin, repo_name_flag: repo_name, description_flag: description, max_log, diff --git a/src/serve.rs b/src/serve.rs index 796c6fa..d6429f5 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -80,6 +80,50 @@ struct Shared { max_body: usize, max_bytes: usize, quota: usize, + /// Bare repo materialized from `www/repo.bundle` for git smart HTTP + /// (`GET /repo.bundle/info/refs`, `POST /repo.bundle/git-upload-pack`), + /// so `git clone http://host:port/repo.bundle` works (m6, issue #32). + /// `None` when no bundle was present at startup (static-only mode). + git_dir: Option, +} + +/// Materialize `www/repo.bundle` into a bare repo for smart HTTP. +/// +/// Runs `git clone --bare --quiet /repo.git` once at serve +/// startup. Returns `None` when no bundle exists or git fails (serve +/// continues static-only; `/repo.bundle` still downloads via GET). +fn prepare_git_dir(www: &Path) -> Option { + let bundle = www.join("repo.bundle"); + if !bundle.is_file() { + return None; + } + let dir = std::env::temp_dir().join(format!( + "hu13-git-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let git_dir = dir.join("repo.git"); + let st = std::process::Command::new("git") + .arg("clone") + .arg("--bare") + .arg("--quiet") + .arg(&bundle) + .arg(&git_dir) + .status(); + match st { + Ok(s) if s.success() && git_dir.is_dir() => Some(git_dir), + Ok(s) => { + eprintln!("hu13: prepare git dir from bundle failed ({s}); smart HTTP disabled"); + None + } + Err(e) => { + eprintln!("hu13: spawn git for smart HTTP: {e}; smart HTTP disabled"); + None + } + } } /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so @@ -180,6 +224,7 @@ pub fn run(opts: &ServeOptions) -> i32 { max_body: opts.max_body, max_bytes: opts.max_bytes, quota: opts.quota, + git_dir: prepare_git_dir(&opts.www), }); for conn in listener.incoming() { match conn { @@ -473,6 +518,14 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) { handle_inbox(&mut stream, &head, rest, kind, shared); return; } + // Git smart HTTP for the bundle (m6, issue #32): `git clone + // http://host:port/repo.bundle` speaks smart HTTP (`info/refs` + + // `git-upload-pack`), not a raw bundle download. Serve it from a + // bare repo materialized from `www/repo.bundle` at startup. + if path == "/repo.bundle/info/refs" || path == "/repo.bundle/git-upload-pack" { + handle_git_smart(&mut stream, &head, rest, &path, shared); + return; + } // Static: files only, never a listing (dirs → 404). Unknown paths // are 404 for ANY method (including POST); a known file with the // wrong method is 405. Fail closed, echo nothing. @@ -539,6 +592,126 @@ fn content_length(head: &Head) -> Option { header(head, "content-length")?.trim().parse().ok() } +/// Git smart HTTP for `/repo.bundle` (m6, issue #32). +/// +/// `git clone http://host:port/repo.bundle` never GETs the bundle bytes +/// directly: `git-remote-http` first asks +/// `GET /repo.bundle/info/refs?service=git-upload-pack`, then +/// `POST /repo.bundle/git-upload-pack`. Answer both from the bare repo +/// materialized at startup (`Shared::git_dir`); anything else (wrong +/// method, missing bundle, git failure) is 404/405 with no echo. +fn handle_git_smart( + stream: &mut TcpStream, + head: &Head, + rest: Vec, + path: &str, + shared: &Shared, +) { + let Some(git_dir) = shared.git_dir.as_ref() else { + static_error(stream, 404); + return; + }; + if path == "/repo.bundle/info/refs" { + if head.method != "GET" { + static_error(stream, 405); + return; + } + let query = head.target.split('?').nth(1).unwrap_or(""); + if !query.contains("service=git-upload-pack") { + static_error(stream, 404); + return; + } + let out = std::process::Command::new("git") + .arg("upload-pack") + .arg("--stateless-rpc") + .arg("--advertise-refs") + .arg(git_dir) + .output(); + match out { + Ok(o) if o.status.success() => { + let mut body = b"001e# service=git-upload-pack\n0000".to_vec(); + body.extend_from_slice(&o.stdout); + respond( + stream, + 200, + "application/x-git-upload-pack-advertisement", + &[("Cache-Control", "no-cache")], + &body, + ); + } + _ => static_error(stream, 404), + } + return; + } + // POST /repo.bundle/git-upload-pack + if head.method != "POST" { + settle(stream, rest.len(), content_length(head)); + static_error(stream, 405); + return; + } + let len: usize = match header(head, "content-length") { + Some(v) => match v.trim().parse() { + Ok(n) => n, + Err(_) => return, + }, + None => return, + }; + // Wants are small; cap the request at 1MiB (fail closed). + const MAX_GIT_REQUEST: usize = 1024 * 1024; + if len > MAX_GIT_REQUEST { + settle(stream, rest.len(), Some(len)); + static_error(stream, 413); + return; + } + let mut req_body = rest; + if req_body.len() > len { + req_body.truncate(len); + } + while req_body.len() < len { + let mut chunk = [0u8; 8192]; + let want = (len - req_body.len()).min(chunk.len()); + match stream.read(&mut chunk[..want]) { + Ok(0) => break, + Ok(n) => req_body.extend_from_slice(&chunk[..n]), + Err(_) => return, + } + } + if req_body.len() != len { + return; + } + let mut child = match std::process::Command::new("git") + .arg("upload-pack") + .arg("--stateless-rpc") + .arg(git_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(c) => c, + Err(_) => { + static_error(stream, 503); + return; + } + }; + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(&req_body); + } + let out = child.wait_with_output(); + match out { + Ok(o) if o.status.success() => { + respond( + stream, + 200, + "application/x-git-upload-pack-result", + &[("Cache-Control", "no-cache")], + &o.stdout, + ); + } + _ => static_error(stream, 503), + } +} + #[allow(clippy::too_many_lines)] fn handle_inbox( stream: &mut TcpStream, diff --git a/tests/clone_origin.rs b/tests/clone_origin.rs new file mode 100644 index 0000000..d5881c7 --- /dev/null +++ b/tests/clone_origin.rs @@ -0,0 +1,328 @@ +//! Clone-from-server (m6, f0rge issue #32): +//! `--public-origin` / `HU13_PUBLIC_ORIGIN` (flag > env > unset), +//! clone-block both-mode asserts, serve `/repo.bundle` byte-identical, +//! live `git clone http://host:port/repo.bundle`, entrypoint passthrough. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_hu13") +} + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-origin-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); +} + +fn fixture_repo() -> PathBuf { + let dir = fresh("repo"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + std::fs::write(dir.join("README.md"), "# origin fixture\n").unwrap(); + std::fs::write(dir.join("hello.txt"), "hello\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "init"]); + dir +} + +fn set_env(k: &str, v: Option<&str>) { + unsafe { + match v { + Some(val) => std::env::set_var(k, val), + None => std::env::remove_var(k), + } + } +} + +fn build_with(repo: &Path, extra: &[&str]) -> PathBuf { + let out = fresh("out"); + let st = Command::new(bin()) + .arg("build") + .arg(repo) + .arg("-o") + .arg(&out) + .args(extra) + .status() + .expect("spawn hu13 build"); + assert!(st.success(), "build exits 0"); + out +} + +// ---- block asserts ------------------------------------------------------- + +#[test] +fn unset_mode_is_bundle_first_with_server_note() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_env("HU13_PUBLIC_ORIGIN", None); + let repo = fixture_repo(); + let out = build_with(&repo, &[]); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!(index.contains("clone-block"), "clone block present"); + assert!( + index.contains("git clone repo.bundle"), + "bundle-first cmd, got: {}", + &index[index.find("clone-block").unwrap_or(0)..] + [..2000.min(index.len() - index.find("clone-block").unwrap_or(0))] + ); + assert!( + index.contains("served from this server: ask the host for its address"), + "server note in unset mode" + ); + assert!( + !index.contains("git clone http"), + "no absolute bundle cmd when unset" + ); + assert!(index.contains("/repo.bundle"), "bundle linked"); + assert!(index.contains("/repo.tar.gz"), "tarball linked"); + set_env("HU13_PUBLIC_ORIGIN", None); +} + +#[test] +fn public_origin_primary_is_absolute_bundle_with_snapshot_note() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_env("HU13_PUBLIC_ORIGIN", None); + let repo = fixture_repo(); + let origin = "http://127.0.0.1:39313"; + let out = build_with(&repo, &["--public-origin", origin]); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!( + index.contains(&format!("git clone {origin}/repo.bundle")), + "primary absolute bundle cmd" + ); + assert!(index.contains("exact snapshot of"), "snapshot note present"); + assert!(index.contains("main"), "branch in snapshot note"); + assert!(index.contains("/repo.tar.gz"), "secondary tarball link"); + assert!( + !index.contains("git clone repo.bundle -b"), + "local bundle cmd replaced by absolute one" + ); + set_env("HU13_PUBLIC_ORIGIN", None); +} + +#[test] +fn public_origin_with_clone_url_shows_mirror_source() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_env("HU13_PUBLIC_ORIGIN", None); + let repo = fixture_repo(); + let origin = "http://10.0.0.5:39313"; + let mirror = "https://example.com/mirror.git"; + let out = build_with(&repo, &["--public-origin", origin, "--clone-url", mirror]); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!( + index.contains(&format!("git clone {origin}/repo.bundle")), + "public origin still primary" + ); + assert!(index.contains("mirror source"), "mirror source label"); + assert!(index.contains(mirror), "origin remote shown"); + // Primary (absolute bundle) must come before the mirror source line. + let primary = index + .find(&format!("git clone {origin}/repo.bundle")) + .expect("primary pos"); + let mirror_pos = index.find("mirror source").expect("mirror pos"); + assert!(primary < mirror_pos, "primary before mirror source"); + set_env("HU13_PUBLIC_ORIGIN", None); +} + +#[test] +fn public_origin_flag_beats_env_and_trailing_slash_stripped() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Env only -> used. + set_env("HU13_PUBLIC_ORIGIN", Some("http://env.example:39313/")); + let repo = fixture_repo(); + let out = build_with(&repo, &[]); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!( + index.contains("git clone http://env.example:39313/repo.bundle"), + "env origin used, trailing slash stripped (no //)" + ); + assert!(!index.contains("//repo.bundle"), "no doubled slash"); + // Flag beats env. + let out = build_with(&repo, &["--public-origin", "http://flag.example:39313"]); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!( + index.contains("git clone http://flag.example:39313/repo.bundle"), + "flag wins over env" + ); + assert!(!index.contains("env.example"), "env suppressed by flag"); + // Empty values fall back to unset. + set_env("HU13_PUBLIC_ORIGIN", Some(" ")); + let out = build_with(&repo, &[]); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!( + index.contains("served from this server: ask the host for its address"), + "empty env falls back to unset" + ); + set_env("HU13_PUBLIC_ORIGIN", None); +} + +// ---- entrypoint / docker ------------------------------------------------- + +#[test] +fn entrypoint_and_dockerfile_pass_public_origin() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ep = std::fs::read_to_string(manifest.join("entrypoint.sh")).expect("entrypoint.sh"); + for needle in [ + "HU13_PUBLIC_ORIGIN", + "--public-origin", + "\"$HU13_PUBLIC_ORIGIN\"", + ] { + assert!(ep.contains(needle), "entrypoint passes {needle}"); + } + let docker = std::fs::read_to_string(manifest.join("Dockerfile")).expect("Dockerfile"); + assert!( + docker.contains("HU13_PUBLIC_ORIGIN"), + "Dockerfile defaults HU13_PUBLIC_ORIGIN" + ); +} + +// ---- serve --------------------------------------------------------------- + +struct Server { + child: Child, + addr: String, +} + +impl Drop for Server { + fn drop(&mut self) { + self.child.kill().ok(); + self.child.wait().ok(); + } +} + +fn start_server(www: &Path, inbox: &Path) -> Server { + let mut child = Command::new(bin()) + .arg("serve") + .arg("--www") + .arg(www) + .arg("--inbox-dir") + .arg(inbox) + .arg("--listen") + .arg("127.0.0.1:0") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn hu13 serve"); + let stdout = child.stdout.take().expect("piped stdout"); + let mut lines = BufReader::new(stdout).lines(); + let mut addr = String::new(); + for _ in 0..50 { + match lines.next() { + Some(Ok(line)) if line.contains("listening on ") => { + addr = line + .rsplit("listening on ") + .next() + .unwrap_or("") + .trim() + .to_string(); + break; + } + Some(Ok(_)) => continue, + _ => break, + } + } + assert!(!addr.is_empty(), "serve printed no `listening on` line"); + std::thread::sleep(Duration::from_millis(100)); + Server { child, addr } +} + +fn get(addr: &str, path: &str) -> Vec { + let mut s = TcpStream::connect(addr).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + s.set_write_timeout(Some(Duration::from_secs(10))).unwrap(); + let req = format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + s.write_all(req.as_bytes()).expect("write"); + let mut out = Vec::new(); + s.read_to_end(&mut out).expect("read"); + out +} + +fn split_response(raw: &[u8]) -> (&[u8], &[u8]) { + let pos = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .expect("header/body split"); + (&raw[..pos], &raw[pos + 4..]) +} + +#[test] +fn serve_gets_repo_bundle_bytes_identical() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_env("HU13_PUBLIC_ORIGIN", None); + let repo = fixture_repo(); + let out = build_with(&repo, &[]); + let inbox = fresh("inbox"); + let srv = start_server(&out, &inbox); + + let want = std::fs::read(out.join("repo.bundle")).expect("repo.bundle on disk"); + assert!(!want.is_empty(), "bundle non-empty"); + let raw = get(&srv.addr, "/repo.bundle"); + let (head, body) = split_response(&raw); + let head = String::from_utf8_lossy(head).into_owned(); + assert!(head.contains("200"), "GET /repo.bundle -> 200, got: {head}"); + assert!( + head.contains("X-Content-Type-Options: nosniff"), + "nosniff on bundle" + ); + assert_eq!(body, want.as_slice(), "bundle bytes identical"); + + // Tarball serves too. + let want_tar = std::fs::read(out.join("repo.tar.gz")).expect("tarball on disk"); + let raw = get(&srv.addr, "/repo.tar.gz"); + let (head, body) = split_response(&raw); + let head = String::from_utf8_lossy(head).into_owned(); + assert!(head.contains("200"), "GET /repo.tar.gz -> 200, got: {head}"); + assert_eq!(body, want_tar.as_slice(), "tarball bytes identical"); + set_env("HU13_PUBLIC_ORIGIN", None); +} + +#[test] +fn git_clone_over_http_bundle_works() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_env("HU13_PUBLIC_ORIGIN", None); + let repo = fixture_repo(); + let out = build_with(&repo, &[]); + let inbox = fresh("inbox"); + let srv = start_server(&out, &inbox); + let url = format!("http://{}/repo.bundle", srv.addr); + let dest_parent = fresh("cloned"); + let dest = dest_parent.join("cloned"); + let st = Command::new("git") + .arg("clone") + .arg("-b") + .arg("main") + .arg(&url) + .arg(&dest) + .status() + .expect("spawn git clone"); + assert!(st.success(), "git clone {url} works"); + assert!(dest.join("README.md").exists(), "cloned README present"); + assert!(dest.join("hello.txt").exists(), "cloned file present"); + set_env("HU13_PUBLIC_ORIGIN", None); +} -- 2.55.0