#29 m6/clone-origin
Patch
Dockerfile+4 −0--- 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--- 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--- 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 & 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--- 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--- 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--- 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--- /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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.