1 //! Inbox core tests (m4, f0rge issue #9): one test per rule.2 //!3 //! Each server test spawns the real binary (`hu13 serve --listen4 //! 127.0.0.1:0`), learns the bound port from its `listening on` line, and5 //! talks raw HTTP over `std::net::TcpStream` (no extra deps, no curl6 //! binary needed — TcpStream *is* the curl here).7 8 use std::io::{BufRead, BufReader, Read, Write};9 use std::net::TcpStream;10 use std::os::unix::fs::PermissionsExt;11 use std::path::{Path, PathBuf};12 use std::process::{Child, Command, Stdio};13 use std::sync::atomic::{AtomicU64, Ordering};14 use std::time::{Duration, Instant};15 16 static COUNTER: AtomicU64 = AtomicU64::new(0);17 18 fn unique(prefix: &str) -> PathBuf {19 let id = COUNTER.fetch_add(1, Ordering::SeqCst);20 std::env::temp_dir().join(format!(21 "hu13-inbox-{prefix}-{}-{id}-{}",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 }29 30 struct TmpDir(PathBuf);31 impl TmpDir {32 fn fresh(prefix: &str) -> Self {33 let p = unique(prefix);34 std::fs::create_dir_all(&p).unwrap();35 Self(p)36 }37 fn path(&self) -> &Path {38 &self.039 }40 }41 impl Drop for TmpDir {42 fn drop(&mut self) {43 std::fs::remove_dir_all(&self.0).ok();44 }45 }46 47 fn bin() -> &'static str {48 env!("CARGO_BIN_EXE_hu13")49 }50 51 struct Server {52 child: Child,53 addr: String,54 _www: TmpDir,55 inbox: TmpDir,56 }57 impl Server {58 fn inbox_files(&self) -> Vec<PathBuf> {59 let mut out: Vec<PathBuf> = std::fs::read_dir(self.inbox.path())60 .unwrap()61 .flatten()62 .map(|e| e.path())63 .filter(|p| p.is_file())64 .collect();65 out.sort();66 out67 }68 }69 impl Drop for Server {70 fn drop(&mut self) {71 self.child.kill().ok();72 self.child.wait().ok();73 }74 }75 76 /// Spawn `hu13 serve` on loopback port 0; parse the bound addr.77 fn start_server() -> Server {78 let www = TmpDir::fresh("www");79 let inbox = TmpDir::fresh("inbox");80 std::fs::write(81 www.path().join("index.html"),82 "<!DOCTYPE html><title>hi</title><p>hi</p>",83 )84 .unwrap();85 std::fs::write(www.path().join("style.css"), "body{color:#fff}").unwrap();86 let mut child = Command::new(bin())87 .arg("serve")88 .arg("--www")89 .arg(www.path())90 .arg("--inbox-dir")91 .arg(inbox.path())92 .arg("--listen")93 .arg("127.0.0.1:0")94 .stdout(Stdio::piped())95 .stderr(Stdio::piped())96 .spawn()97 .expect("spawn hu13 serve");98 let stdout = child.stdout.take().expect("piped stdout");99 let mut lines = BufReader::new(stdout).lines();100 let mut addr = String::new();101 for _ in 0..50 {102 match lines.next() {103 Some(Ok(line)) if line.contains("listening on ") => {104 addr = line105 .rsplit("listening on ")106 .next()107 .unwrap_or("")108 .trim()109 .to_string();110 break;111 }112 Some(Ok(_)) => continue,113 _ => break,114 }115 }116 assert!(!addr.is_empty(), "serve printed no `listening on` line");117 assert!(addr.starts_with("127.0.0.1:"), "loopback only, got {addr}");118 // Give the accept loop a breath (bind is done; accept is next).119 std::thread::sleep(Duration::from_millis(100));120 Server {121 child,122 addr,123 _www: www,124 inbox,125 }126 }127 128 /// Send one raw request, read until the server closes (it always does:129 /// `Connection: close`).130 fn transact(addr: &str, req: &[u8]) -> String {131 let mut s = TcpStream::connect(addr).expect("connect");132 s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();133 s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();134 s.write_all(req).expect("write");135 let mut out = Vec::new();136 s.read_to_end(&mut out).expect("read");137 String::from_utf8_lossy(&out).into_owned()138 }139 140 fn status_line(resp: &str) -> &str {141 resp.split("\r\n").next().unwrap_or("")142 }143 144 fn resp_body(resp: &str) -> &str {145 match resp.find("\r\n\r\n") {146 Some(i) => &resp[i + 4..],147 None => "",148 }149 }150 151 /// urlencode (tests are the client here): alnum + `-_.~` bare, space →152 // `+`, everything else `%XX`.153 fn enc(s: &str) -> String {154 let mut o = String::new();155 for b in s.bytes() {156 match b {157 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {158 o.push(b as char);159 }160 b' ' => o.push('+'),161 _ => o.push_str(&format!("%{b:02X}")),162 }163 }164 o165 }166 167 fn post(path: &str, ctype: &str, body: &[u8]) -> Vec<u8> {168 format!(169 "POST {path} HTTP/1.1\r\nHost: x\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",170 body.len()171 )172 .into_bytes()173 .into_iter()174 .chain(body.iter().copied())175 .collect()176 }177 178 const URLENCODED: &str = "application/x-www-form-urlencoded";179 180 fn valid_issue_body(marker: &str) -> String {181 format!(182 "title={}&body={}&handle={}&contact={}&license=agree&website=",183 enc(&format!("issue title {marker}")),184 enc(&format!("issue body {marker} <b>bold</b>")),185 enc("tester"),186 enc("[email protected]"),187 )188 }189 190 fn extract_ack(body: &str) -> String {191 // `ack: <code>UUID</code>` — pull the 36-char uuid out.192 let code = body.find("<code>").expect("ack code tag") + 6;193 let end = body[code..].find("</code>").expect("ack close tag") + code;194 body[code..end].to_string()195 }196 197 fn is_uuid(s: &str) -> bool {198 s.len() == 36199 && s.as_bytes().iter().enumerate().all(|(i, &b)| {200 if [8, 13, 18, 23].contains(&i) {201 b == b'-'202 } else {203 b.is_ascii_hexdigit()204 }205 })206 }207 208 // ---- the rules, one test each ------------------------------------------209 210 #[test]211 fn valid_issue_stores_0600_json_ack_shown_nothing_echoed() {212 let srv = start_server();213 let marker = format!("MKR-{}-{}", std::process::id(), unique("m").display());214 let t0 = Instant::now();215 let resp = transact(216 &srv.addr,217 &post(218 "/inbox/issue",219 URLENCODED,220 valid_issue_body(&marker).as_bytes(),221 ),222 );223 let elapsed = t0.elapsed();224 assert!(status_line(&resp).contains("200"), "got: {resp}");225 assert!(resp.contains("received"), "received page");226 assert!(227 elapsed >= Duration::from_millis(350),228 "fixed ~500ms delay present (took {elapsed:?})"229 );230 let ack = extract_ack(resp_body(&resp));231 assert!(is_uuid(&ack), "ack is a uuid, got {ack}");232 assert!(!resp.contains(&marker), "ZERO reflection of input");233 234 let files = srv.inbox_files();235 assert_eq!(files.len(), 1, "exactly one file stored");236 assert_eq!(237 files[0].file_stem().unwrap().to_string_lossy(),238 ack,239 "ack uuid == filename uuid"240 );241 #[cfg(unix)]242 assert_eq!(243 std::fs::metadata(&files[0]).unwrap().permissions().mode() & 0o777,244 0o600,245 "mode 0600"246 );247 let v: serde_json::Value =248 serde_json::from_str(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();249 assert_eq!(v["type"], "issue");250 assert_eq!(v["title"], format!("issue title {marker}"));251 assert_eq!(v["body"], format!("issue body {marker} <b>bold</b>"));252 assert_eq!(v["handle"], "tester");253 assert_eq!(v["contact"], "[email protected]");254 assert_eq!(v["license_version"], "v1");255 assert!(!v["received_at"].as_str().unwrap_or("").is_empty());256 let hash = v["salted_ip_hash"].as_str().unwrap_or("");257 assert_eq!(hash.len(), 64, "hex sha256");258 assert!(!hash.contains('.'), "no dotted quad on disk");259 assert!(!std::fs::read_to_string(&files[0])260 .unwrap()261 .contains("127.0.0.1"));262 }263 264 #[test]265 fn valid_pr_prompt_stores_with_prompt_field() {266 let srv = start_server();267 let marker = format!("PRM-{}-{}", std::process::id(), unique("m").display());268 let body = format!(269 "title={}&prompt={}&license=on&website=",270 enc(&format!("pr title {marker}")),271 enc(&format!("ONE prompt {marker}")),272 );273 let resp = transact(274 &srv.addr,275 &post("/inbox/pr-prompt", URLENCODED, body.as_bytes()),276 );277 assert!(status_line(&resp).contains("200"), "got: {resp}");278 assert!(!resp.contains(&marker), "no reflection");279 let files = srv.inbox_files();280 assert_eq!(files.len(), 1);281 let v: serde_json::Value =282 serde_json::from_str(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();283 assert_eq!(v["type"], "pr-prompt");284 assert_eq!(v["body"], format!("ONE prompt {marker}"));285 }286 287 #[test]288 fn unchecked_license_is_400_and_stores_nothing() {289 let srv = start_server();290 let body = format!("title={}&body={}&website=", enc("t"), enc("b"));291 let resp = transact(292 &srv.addr,293 &post("/inbox/issue", URLENCODED, body.as_bytes()),294 );295 assert!(status_line(&resp).contains("400"), "got: {resp}");296 assert!(srv.inbox_files().is_empty());297 }298 299 #[test]300 fn multipart_is_400_and_stores_nothing() {301 let srv = start_server();302 let body = "--xyz\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nt\r\n--xyz--\r\n";303 let resp = transact(304 &srv.addr,305 &post(306 "/inbox/issue",307 "multipart/form-data; boundary=xyz",308 body.as_bytes(),309 ),310 );311 assert!(status_line(&resp).contains("400"), "got: {resp}");312 assert!(srv.inbox_files().is_empty());313 }314 315 #[test]316 fn oversize_is_413_and_stores_nothing() {317 let srv = start_server();318 let big = "b".repeat(33 * 1024);319 let body = format!(320 "title={}&body={}&license=agree&website=",321 enc("t"),322 enc(&big)323 );324 assert!(body.len() > 32 * 1024);325 let resp = transact(326 &srv.addr,327 &post("/inbox/issue", URLENCODED, body.as_bytes()),328 );329 assert!(330 status_line(&resp).contains("413"),331 "got: {}",332 &resp[..resp.len().min(200)]333 );334 assert!(srv.inbox_files().is_empty());335 }336 337 #[test]338 fn nul_byte_is_400_and_stores_nothing() {339 let srv = start_server();340 let body = "title=a%00b&body=c&license=agree&website=";341 let resp = transact(342 &srv.addr,343 &post("/inbox/issue", URLENCODED, body.as_bytes()),344 );345 assert!(status_line(&resp).contains("400"), "got: {resp}");346 assert!(srv.inbox_files().is_empty());347 }348 349 #[test]350 fn invalid_utf8_is_400_and_stores_nothing() {351 let srv = start_server();352 let mut body = b"title=t&body=".to_vec();353 body.extend_from_slice(&[0xff, 0xfe, b'x']);354 body.extend_from_slice(b"&license=agree&website=");355 let resp = transact(&srv.addr, &post("/inbox/issue", URLENCODED, &body));356 assert!(status_line(&resp).contains("400"), "got: {resp}");357 assert!(srv.inbox_files().is_empty());358 }359 360 #[test]361 fn overlong_title_is_400() {362 let srv = start_server();363 let body = format!(364 "title={}&body={}&license=agree&website=",365 enc(&"t".repeat(301)),366 enc("b")367 );368 let resp = transact(369 &srv.addr,370 &post("/inbox/issue", URLENCODED, body.as_bytes()),371 );372 assert!(status_line(&resp).contains("400"), "got: {resp}");373 assert!(srv.inbox_files().is_empty());374 }375 376 #[test]377 fn honeypot_filled_says_received_but_stores_zero_files() {378 let srv = start_server();379 let marker = format!("HON-{}-{}", std::process::id(), unique("m").display());380 let body = format!(381 "title={}&body={}&license=agree&website=bot-{marker}",382 enc("t"),383 enc("b"),384 );385 let resp = transact(386 &srv.addr,387 &post("/inbox/issue", URLENCODED, body.as_bytes()),388 );389 assert!(390 status_line(&resp).contains("200"),391 "fake received, got: {resp}"392 );393 assert!(resp_body(&resp).contains("received"));394 assert!(!resp.contains(&marker), "no reflection even on drop");395 assert!(srv.inbox_files().is_empty(), "honeypot stores nothing");396 }397 398 #[test]399 fn controls_stripped_except_newline_tab() {400 let srv = start_server();401 let body = "title=a%01b%09c%0Ad&body=x%0Dy&license=agree&website=";402 let resp = transact(403 &srv.addr,404 &post("/inbox/issue", URLENCODED, body.as_bytes()),405 );406 assert!(status_line(&resp).contains("200"), "got: {resp}");407 let files = srv.inbox_files();408 assert_eq!(files.len(), 1);409 let v: serde_json::Value =410 serde_json::from_str(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();411 assert_eq!(412 v["title"], "ab\tc\nd",413 "controls stripped, tab+newline kept"414 );415 assert_eq!(v["body"], "xy", "CR stripped");416 }417 418 #[test]419 fn wrong_method_on_routes_is_405() {420 let srv = start_server();421 for method in ["GET", "PUT", "DELETE"] {422 for path in ["/inbox/issue", "/inbox/pr-prompt"] {423 let req = format!("{method} {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");424 let resp = transact(&srv.addr, req.as_bytes());425 assert!(426 status_line(&resp).contains("405"),427 "{method} {path}: got: {resp}"428 );429 }430 }431 assert!(srv.inbox_files().is_empty());432 }433 434 #[test]435 fn static_serves_nosniff_and_never_lists() {436 let srv = start_server();437 let resp = transact(438 &srv.addr,439 b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",440 );441 assert!(status_line(&resp).contains("200"), "got: {resp}");442 assert!(resp.contains("X-Content-Type-Options: nosniff"));443 assert!(resp.contains("hi"));444 let missing = transact(445 &srv.addr,446 b"GET /nope.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",447 );448 assert!(status_line(&missing).contains("404"), "got: {missing}");449 assert!(missing.contains("X-Content-Type-Options: nosniff"));450 // Traversal + odd paths never escape www.451 for target in ["/..%2f..%2fetc%2fpasswd", "/%00", "/inbox/issue/"] {452 let req = format!("GET {target} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");453 let r = transact(&srv.addr, req.as_bytes());454 assert!(455 status_line(&r).contains("404") || status_line(&r).contains("400"),456 "{target}: got: {r}"457 );458 assert!(!r.contains("root:"), "no file contents leak");459 }460 // Inbox files are unreachable over HTTP (inbox lives outside www, and461 // no route serves it).462 let files = srv.inbox_files();463 assert!(files.is_empty());464 }465 466 #[test]467 fn inbox_inside_www_refuses_to_start() {468 let www = TmpDir::fresh("wwwref");469 std::fs::write(www.path().join("index.html"), "<p>hi</p>").unwrap();470 let inside = www.path().join("inbox");471 let mut child = Command::new(bin())472 .arg("serve")473 .arg("--www")474 .arg(www.path())475 .arg("--inbox-dir")476 .arg(&inside)477 .arg("--listen")478 .arg("127.0.0.1:0")479 .stdout(Stdio::piped())480 .stderr(Stdio::piped())481 .spawn()482 .expect("spawn");483 let mut exited = None;484 for _ in 0..100 {485 if let Ok(Some(st)) = child.try_wait() {486 exited = Some(st);487 break;488 }489 std::thread::sleep(Duration::from_millis(50));490 }491 match exited {492 Some(st) => assert!(!st.success(), "refusal must exit non-zero"),493 None => {494 child.kill().ok();495 panic!("serve started despite inbox inside www");496 }497 }498 }499 500 #[test]501 fn inbox_list_and_show_escape_terminal_output() {502 let inbox = TmpDir::fresh("evil");503 let evil_title = "hi\x1b[31mRED\x1b[0m\x07<script>alert(1)</script>\u{202e}RTL";504 let doc = serde_json::json!({505 "type": "issue",506 "title": evil_title,507 "body": "line1\r\nline2\x00stripped?",508 "handle": "h",509 "contact": "c",510 "license_version": "v1",511 "received_at": "2026-09-10T00:00:00Z",512 "salted_ip_hash": "ab".repeat(32),513 });514 let id = "11111111-2222-4333-8555-666666666666";515 std::fs::write(516 inbox.path().join(format!("{id}.json")),517 serde_json::to_string(&doc).unwrap(),518 )519 .unwrap();520 521 let run = |args: &[&str]| -> (String, bool) {522 let out = Command::new(bin())523 .args(args)524 .arg("--inbox-dir")525 .arg(inbox.path())526 .output()527 .expect("run hu13 inbox");528 (529 String::from_utf8_lossy(&out.stdout).into_owned(),530 out.status.success(),531 )532 };533 let (list, ok) = run(&["inbox", "list"]);534 assert!(ok, "list exits 0");535 assert!(!list.contains('\x1b'), "no raw ESC in list");536 assert!(!list.contains('\x07'), "no raw BEL in list");537 assert!(538 list.contains("\\x1b[31mRED\\x1b[0m"),539 "ESC escaped, got: {list}"540 );541 assert!(542 list.contains("<script>alert(1)</script>"),543 "plain text stays text"544 );545 assert!(list.contains(id), "uuid listed");546 547 let (show, ok) = run(&["inbox", "show", id]);548 assert!(ok, "show exits 0");549 assert!(!show.contains('\x1b'), "no raw ESC in show");550 assert!(show.contains("\\x1b"), "ESC escaped in show");551 assert!(show.contains("\\u{202e}"), "bidi escaped in show");552 assert!(show.contains("\\x0d"), "CR escaped in show");553 assert!(554 show.contains("<script>alert(1)</script>"),555 "plain text stays text"556 );557 558 // Traversal ids are rejected, never opened as paths.559 let out = Command::new(bin())560 .arg("inbox")561 .arg("show")562 .arg("../evil")563 .arg("--inbox-dir")564 .arg(inbox.path())565 .output()566 .expect("run");567 assert!(!out.status.success(), "bad id rejected");568 }569 570 #[test]571 fn build_emits_forms_with_license_honeypot_actions_no_js() {572 // Minimal fixture repo.573 let repo = TmpDir::fresh("repo");574 let git = |args: &[&str]| {575 let st = Command::new("git")576 .arg("-C")577 .arg(repo.path())578 .args(args)579 .status()580 .expect("spawn git");581 assert!(st.success(), "git {args:?} failed");582 };583 git(&["init", "-b", "main"]);584 git(&["config", "user.name", "hu13-test"]);585 git(&["config", "user.email", "[email protected]"]);586 git(&["config", "commit.gpgsign", "false"]);587 std::fs::write(repo.path().join("README.md"), "# forms fixture\n").unwrap();588 git(&["add", "-A"]);589 git(&["commit", "-m", "init"]);590 let out = unique("dist");591 let st = Command::new(bin())592 .arg("build")593 .arg(repo.path())594 .arg("-o")595 .arg(&out)596 .status()597 .expect("spawn hu13 build");598 assert!(st.success(), "build exits 0");599 for (file, action, field) in [600 ("new-issue.html", "/inbox/issue", "name=\"body\""),601 ("new-pr.html", "/inbox/pr-prompt", "name=\"prompt\""),602 ] {603 let html = std::fs::read_to_string(out.join(file)).expect(file);604 assert!(605 html.contains(&format!("action=\"{action}\"")),606 "{file} posts to {action}"607 );608 assert!(html.contains(field), "{file} has {field}");609 assert!(html.contains("name=\"title\""), "{file} has title");610 assert!(html.contains("name=\"handle\""), "{file} has handle");611 assert!(html.contains("name=\"contact\""), "{file} has contact");612 assert!(613 html.contains("name=\"website\""),614 "{file} honeypot named website"615 );616 assert!(html.contains("name=\"license\""), "{file} license checkbox");617 assert!(618 html.contains("By submitting this text you irrevocably give it to this project."),619 "{file} exact license text"620 );621 assert!(622 html.contains("If you cannot agree\nto that, do not submit.")623 || html.contains("If you cannot agree"),624 "{file} license tail"625 );626 assert!(!html.contains("<script"), "{file} zero JS");627 }628 std::fs::remove_dir_all(&out).ok();629 }