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 & 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