1 //! Clone-from-server (m6, f0rge issue #32; dumb-clone #54):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` (smart, serve-only) +5 //! `git clone <origin>/repo.git` (dumb, any static server), entrypoint passthrough.6 7 use std::sync::atomic::{AtomicU64, Ordering};8 9 static SEQ: AtomicU64 = AtomicU64::new(0);10 11 use std::io::{BufRead, BufReader, Read, Write};12 use std::net::TcpStream;13 use std::path::{Path, PathBuf};14 use std::process::{Child, Command, Stdio};15 use std::sync::Mutex;16 use std::time::Duration;17 18 static ENV_LOCK: Mutex<()> = Mutex::new(());19 20 fn bin() -> &'static str {21 env!("CARGO_BIN_EXE_hu13")22 }23 24 fn fresh(prefix: &str) -> PathBuf {25 let dir = std::env::temp_dir().join(format!(26 "hu13-origin-{prefix}-{}-{}",27 std::process::id(),28 std::time::SystemTime::now()29 .duration_since(std::time::UNIX_EPOCH)30 .map(|d| d.as_nanos())31 .unwrap_or(0)32 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))33 ));34 std::fs::create_dir_all(&dir).unwrap();35 dir36 }37 38 fn git(repo: &Path, args: &[&str]) {39 let st = Command::new("git")40 .arg("-C")41 .arg(repo)42 .args(args)43 .status()44 .expect("spawn git");45 assert!(st.success(), "git {args:?} failed");46 }47 48 fn fixture_repo() -> PathBuf {49 let dir = fresh("repo");50 git(&dir, &["init", "-b", "main"]);51 git(&dir, &["config", "user.name", "hu13-test"]);52 git(&dir, &["config", "user.email", "[email protected]"]);53 git(&dir, &["config", "commit.gpgsign", "false"]);54 std::fs::write(dir.join("README.md"), "# origin fixture\n").unwrap();55 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();56 git(&dir, &["add", "-A"]);57 git(&dir, &["commit", "-m", "init"]);58 dir59 }60 61 fn set_env(k: &str, v: Option<&str>) {62 unsafe {63 match v {64 Some(val) => std::env::set_var(k, val),65 None => std::env::remove_var(k),66 }67 }68 }69 70 fn build_with(repo: &Path, extra: &[&str]) -> PathBuf {71 let out = fresh("out");72 let st = Command::new(bin())73 .arg("build")74 .arg(repo)75 .arg("-o")76 .arg(&out)77 .args(extra)78 .status()79 .expect("spawn hu13 build");80 assert!(st.success(), "build exits 0");81 out82 }83 84 // ---- block asserts -------------------------------------------------------85 86 #[test]87 fn unset_mode_is_bundle_first_with_server_note() {88 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());89 set_env("HU13_PUBLIC_ORIGIN", None);90 let repo = fixture_repo();91 let out = build_with(&repo, &[]);92 let index = std::fs::read_to_string(out.join("index.html")).expect("index");93 assert!(index.contains("clone-block"), "clone block present");94 assert!(95 index.contains("git clone repo.bundle"),96 "bundle-first cmd, got: {}",97 &index[index.find("clone-block").unwrap_or(0)..]98 [..2000.min(index.len() - index.find("clone-block").unwrap_or(0))]99 );100 assert!(101 index.contains("served from this server: ask the host for its address"),102 "server note in unset mode"103 );104 assert!(105 !index.contains("git clone http"),106 "no absolute bundle cmd when unset"107 );108 assert!(index.contains("/repo.bundle"), "bundle linked");109 assert!(index.contains("/repo.tar.gz"), "tarball linked");110 set_env("HU13_PUBLIC_ORIGIN", None);111 }112 113 #[test]114 fn public_origin_primary_is_absolute_bundle_with_snapshot_note() {115 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());116 set_env("HU13_PUBLIC_ORIGIN", None);117 let repo = fixture_repo();118 let origin = "http://127.0.0.1:39313";119 let out = build_with(&repo, &["--public-origin", origin]);120 let index = std::fs::read_to_string(out.join("index.html")).expect("index");121 assert!(122 index.contains(&format!("git clone {origin}/repo.git")),123 "primary dumb clone cmd"124 );125 assert!(index.contains("exact snapshot of"), "snapshot note present");126 assert!(index.contains("main"), "branch in snapshot note");127 assert!(index.contains("/repo.tar.gz"), "secondary tarball link");128 assert!(129 index.contains(&format!("curl -O {origin}/repo.bundle")),130 "bundle dumb download secondary"131 );132 assert!(133 out.join("repo.git").join("HEAD").exists(),134 "repo.git emitted"135 );136 assert!(137 out.join("repo.git").join("info/refs").exists(),138 "update-server-info ran"139 );140 set_env("HU13_PUBLIC_ORIGIN", None);141 }142 143 #[test]144 fn public_origin_with_clone_url_shows_mirror_source() {145 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());146 set_env("HU13_PUBLIC_ORIGIN", None);147 let repo = fixture_repo();148 let origin = "http://10.0.0.5:39313";149 let mirror = "https://example.com/mirror.git";150 let out = build_with(&repo, &["--public-origin", origin, "--clone-url", mirror]);151 let index = std::fs::read_to_string(out.join("index.html")).expect("index");152 assert!(153 index.contains(&format!("git clone {origin}/repo.git")),154 "public origin still primary (dumb)"155 );156 assert!(index.contains("mirror source"), "mirror source label");157 assert!(index.contains(mirror), "origin remote shown");158 // Primary (dumb) must come before the mirror source line.159 let primary = index160 .find(&format!("git clone {origin}/repo.git"))161 .expect("primary pos");162 let mirror_pos = index.find("mirror source").expect("mirror pos");163 assert!(primary < mirror_pos, "primary before mirror source");164 set_env("HU13_PUBLIC_ORIGIN", None);165 }166 167 #[test]168 fn public_origin_flag_beats_env_and_trailing_slash_stripped() {169 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());170 // Env only -> used.171 set_env("HU13_PUBLIC_ORIGIN", Some("http://env.example:39313/"));172 let repo = fixture_repo();173 let out = build_with(&repo, &[]);174 let index = std::fs::read_to_string(out.join("index.html")).expect("index");175 assert!(176 index.contains("git clone http://env.example:39313/repo.git"),177 "env origin used, trailing slash stripped (no //)"178 );179 assert!(!index.contains("//repo.git"), "no doubled slash");180 // Flag beats env.181 let out = build_with(&repo, &["--public-origin", "http://flag.example:39313"]);182 let index = std::fs::read_to_string(out.join("index.html")).expect("index");183 assert!(184 index.contains("git clone http://flag.example:39313/repo.git"),185 "flag wins over env"186 );187 assert!(!index.contains("env.example"), "env suppressed by flag");188 // Empty values fall back to unset.189 set_env("HU13_PUBLIC_ORIGIN", Some(" "));190 let out = build_with(&repo, &[]);191 let index = std::fs::read_to_string(out.join("index.html")).expect("index");192 assert!(193 index.contains("served from this server: ask the host for its address"),194 "empty env falls back to unset"195 );196 set_env("HU13_PUBLIC_ORIGIN", None);197 }198 199 // ---- entrypoint / docker -------------------------------------------------200 201 #[test]202 fn entrypoint_and_dockerfile_pass_public_origin() {203 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));204 let ep = std::fs::read_to_string(manifest.join("entrypoint.sh")).expect("entrypoint.sh");205 for needle in [206 "HU13_PUBLIC_ORIGIN",207 "--public-origin",208 "\"$HU13_PUBLIC_ORIGIN\"",209 ] {210 assert!(ep.contains(needle), "entrypoint passes {needle}");211 }212 let docker = std::fs::read_to_string(manifest.join("Dockerfile")).expect("Dockerfile");213 assert!(214 docker.contains("HU13_PUBLIC_ORIGIN"),215 "Dockerfile defaults HU13_PUBLIC_ORIGIN"216 );217 }218 219 // ---- serve ---------------------------------------------------------------220 221 struct Server {222 child: Child,223 addr: String,224 }225 226 impl Drop for Server {227 fn drop(&mut self) {228 self.child.kill().ok();229 self.child.wait().ok();230 }231 }232 233 fn start_server(www: &Path, inbox: &Path) -> Server {234 let mut child = Command::new(bin())235 .arg("serve")236 .arg("--www")237 .arg(www)238 .arg("--inbox-dir")239 .arg(inbox)240 .arg("--listen")241 .arg("127.0.0.1:0")242 .stdout(Stdio::piped())243 .stderr(Stdio::piped())244 .spawn()245 .expect("spawn hu13 serve");246 let stdout = child.stdout.take().expect("piped stdout");247 let mut lines = BufReader::new(stdout).lines();248 let mut addr = String::new();249 for _ in 0..50 {250 match lines.next() {251 Some(Ok(line)) if line.contains("listening on ") => {252 addr = line253 .rsplit("listening on ")254 .next()255 .unwrap_or("")256 .trim()257 .to_string();258 break;259 }260 Some(Ok(_)) => continue,261 _ => break,262 }263 }264 assert!(!addr.is_empty(), "serve printed no `listening on` line");265 std::thread::sleep(Duration::from_millis(100));266 Server { child, addr }267 }268 269 fn get(addr: &str, path: &str) -> Vec<u8> {270 let mut s = TcpStream::connect(addr).expect("connect");271 s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();272 s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();273 let req = format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");274 s.write_all(req.as_bytes()).expect("write");275 let mut out = Vec::new();276 s.read_to_end(&mut out).expect("read");277 out278 }279 280 fn split_response(raw: &[u8]) -> (&[u8], &[u8]) {281 let pos = raw282 .windows(4)283 .position(|w| w == b"\r\n\r\n")284 .expect("header/body split");285 (&raw[..pos], &raw[pos + 4..])286 }287 288 #[test]289 fn serve_gets_repo_bundle_bytes_identical() {290 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());291 set_env("HU13_PUBLIC_ORIGIN", None);292 let repo = fixture_repo();293 let out = build_with(&repo, &[]);294 let inbox = fresh("inbox");295 let srv = start_server(&out, &inbox);296 297 let want = std::fs::read(out.join("repo.bundle")).expect("repo.bundle on disk");298 assert!(!want.is_empty(), "bundle non-empty");299 let raw = get(&srv.addr, "/repo.bundle");300 let (head, body) = split_response(&raw);301 let head = String::from_utf8_lossy(head).into_owned();302 assert!(head.contains("200"), "GET /repo.bundle -> 200, got: {head}");303 assert!(304 head.contains("X-Content-Type-Options: nosniff"),305 "nosniff on bundle"306 );307 assert_eq!(body, want.as_slice(), "bundle bytes identical");308 309 // Tarball serves too.310 let want_tar = std::fs::read(out.join("repo.tar.gz")).expect("tarball on disk");311 let raw = get(&srv.addr, "/repo.tar.gz");312 let (head, body) = split_response(&raw);313 let head = String::from_utf8_lossy(head).into_owned();314 assert!(head.contains("200"), "GET /repo.tar.gz -> 200, got: {head}");315 assert_eq!(body, want_tar.as_slice(), "tarball bytes identical");316 set_env("HU13_PUBLIC_ORIGIN", None);317 }318 319 #[test]320 fn git_clone_over_http_bundle_works() {321 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());322 set_env("HU13_PUBLIC_ORIGIN", None);323 let repo = fixture_repo();324 let out = build_with(&repo, &[]);325 let inbox = fresh("inbox");326 let srv = start_server(&out, &inbox);327 let url = format!("http://{}/repo.bundle", srv.addr);328 let dest_parent = fresh("cloned");329 let dest = dest_parent.join("cloned");330 let st = Command::new("git")331 .arg("clone")332 .arg("-b")333 .arg("main")334 .arg(&url)335 .arg(&dest)336 .status()337 .expect("spawn git clone");338 assert!(st.success(), "git clone {url} works");339 assert!(dest.join("README.md").exists(), "cloned README present");340 assert!(dest.join("hello.txt").exists(), "cloned file present");341 set_env("HU13_PUBLIC_ORIGIN", None);342 }343 344 #[test]345 fn dumb_repo_git_clones_file_and_http() {346 // #54: dist/repo.git is a bare dumb-HTTP dir — `git clone <path>` and347 // `git clone http://host/repo.git` both work, no smart HTTP, no GitHub.348 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());349 set_env("HU13_PUBLIC_ORIGIN", None);350 let repo = fixture_repo();351 let out = build_with(&repo, &[]);352 let git_dir = out.join("repo.git");353 assert!(git_dir.join("HEAD").exists(), "HEAD emitted");354 assert!(git_dir.join("info/refs").exists(), "info/refs emitted");355 assert!(356 git_dir.join("objects/info/packs").exists(),357 "objects/info/packs emitted"358 );359 // File-path clone (works on file:// + any static host).360 let dest_parent = fresh("dumb-file");361 let dest = dest_parent.join("cloned");362 let st = Command::new("git")363 .arg("clone")364 .arg("-b")365 .arg("main")366 .arg(&git_dir)367 .arg(&dest)368 .status()369 .expect("spawn git clone");370 assert!(st.success(), "file clone of repo.git works");371 assert!(dest.join("README.md").exists(), "cloned README present");372 // HTTP clone via hu13 serve (same bytes Pages/nginx would serve).373 let inbox = fresh("inbox-dumb");374 let srv = start_server(&out, &inbox);375 let raw = get(&srv.addr, "/repo.git/info/refs");376 let (head, body) = split_response(&raw);377 let head = String::from_utf8_lossy(head).into_owned();378 assert!(head.contains("200"), "GET info/refs -> 200, got: {head}");379 assert!(!body.is_empty(), "info/refs non-empty");380 let url = format!("http://{}/repo.git", srv.addr);381 let dest_parent = fresh("dumb-http");382 let dest = dest_parent.join("cloned");383 let st = Command::new("git")384 .arg("clone")385 .arg("-b")386 .arg("main")387 .arg(&url)388 .arg(&dest)389 .status()390 .expect("spawn git clone");391 assert!(st.success(), "git clone {url} works");392 assert!(dest.join("README.md").exists(), "http cloned README");393 assert!(dest.join("hello.txt").exists(), "http cloned file");394 set_env("HU13_PUBLIC_ORIGIN", None);395 }