tests/inbox.rs 19.6 KiB 629 lines Raw
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 }