.forge/prs/5192f459-b858-4cb5-ba0f-d64cc1ce80e2/patch.diff 84.8 KiB 2450 lines Raw
1 From 13bf92238766acdc1cf866155ab4f1fd6b26733c Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 05:48:46 +01004 Subject: [PATCH] m4/inbox: forms, serve POSTs, storage, ack, license (875  tests)6 7 ---8  src/agents.rs  |  10 +-9  src/build.rs   |   2 +10  src/inbox.rs   | 932 +++++++++++++++++++++++++++++++++++++++++++++++++11  src/main.rs    | 140 ++++++--12  src/serve.rs   | 591 +++++++++++++++++++++++++++++++13  tests/inbox.rs | 629 +++++++++++++++++++++++++++++++++14  6 files changed, 2277 insertions(+), 27 deletions(-)15  create mode 100644 src/inbox.rs16  create mode 100644 src/serve.rs17  create mode 100644 tests/inbox.rs18 19 diff --git a/src/agents.rs b/src/agents.rs20 index 3de1133..67caf5a 10064421 --- a/src/agents.rs22 +++ b/src/agents.rs23 @@ -18,7 +18,9 @@ use crate::build::markdown_to_html;24  use crate::layout::{esc, page};25  26  /// License grant quoted verbatim from `DRAFT.md` (receiver-friendly v1).27 -pub const LICENSE_TEXT: &str = "By submitting this text you irrevocably give it to this project.\nYou keep no rights in it, you are owed nothing for it, and you will\nmake no claim over it — ownership, credit, payment, infringement,\nor otherwise — against the maintainer or anyone who uses it. The\nmaintainer may keep, share, change, or use it for any purpose, in\nany code or license, without notice or payment. If you cannot agree\nto that, do not submit.";28 +/// Single source: [`crate::inbox::LICENSE_TEXT`] — the forms, this page,29 +/// and storage all derive from that one constant (no drift by construction).30 +pub use crate::inbox::LICENSE_TEXT;31  32  fn agents_markdown(33      site_name: &str,34 @@ -54,7 +56,7 @@ fn agents_markdown(35           | `handle` | optional, plain text, <=40 chars |\n\36           | `contact` | optional, plain text, <=100 chars |\n\37           | `license` | required checkbox — must be checked, else `400` |\n\38 -         | honeypot | `honeypot` field must be left empty (filled = silently dropped, still says received) |\n\39 +         | honeypot | `website` field must be left empty (filled = silently dropped, still says received) |\n\40           | whole request | <=32KB else `413` |\n\n\41           On success the server shows a `received` page with a one-time ack uuid (shown once, not in a URL, no lookup endpoint) and reflects nothing back (kills reflected XSS). No GET, no listing, no read-back. Inbox never appears in the site; accepted text shows up later as a real issue/PR.\n\n\42           ### License (required — check the box only if you agree)\n\n\43 @@ -73,7 +75,7 @@ fn agents_markdown(44             --data-urlencode \"handle=my-bot\" \\\n\45             --data-urlencode \"[email protected]\" \\\n\46             --data-urlencode \"license=agree\" \\\n\47 -           --data-urlencode \"honeypot=\"\n\48 +           --data-urlencode \"website=\"\n\49           ```\n\n\50           Suggest a PR prompt (ONE prompt, not code):\n\n\51           ```sh\n\52 @@ -83,7 +85,7 @@ fn agents_markdown(53             --data-urlencode \"handle=my-bot\" \\\n\54             --data-urlencode \"[email protected]\" \\\n\55             --data-urlencode \"license=agree\" \\\n\56 -           --data-urlencode \"honeypot=\"\n\57 +           --data-urlencode \"website=\"\n\58           ```\n\n\59           Caps reminder: `title` <=300 chars, `body`/`prompt` <=20000 chars, whole request <=32KB. Rate ~2/hr per IP; be polite.\n",60          site_name = site_name,61 diff --git a/src/build.rs b/src/build.rs62 index 5acf830..f6bd309 10064463 --- a/src/build.rs64 +++ b/src/build.rs65 @@ -369,6 +369,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu66      releases::render_loaded(&release_data, repo, out, &name, &nav)?;67      api::render(out, &forge_data, &commit_log, &release_data, &wiki_pages)?;68      agents::render(out, &name, &nav, clone_url.as_deref(), &branch, &short_sha)?;69 +    // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.70 +    crate::inbox::render_forms(out, &name, &nav)?;71  72      // Snapshot last so the index can link sizes.73      let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;74 diff --git a/src/inbox.rs b/src/inbox.rs75 new file mode 10064476 index 0000000..fc59e7377 --- /dev/null78 +++ b/src/inbox.rs79 @@ -0,0 +1,932 @@80 +//! Inbox core (m4, f0rge issue #9): the ONLY public write surface.81 +//!82 +//! Two dumb text-only mailboxes (`issue` = title+body, `pr-prompt` =83 +//! title+prompt). Hostile input by design: free text from strangers that is84 +//! never rendered to HTML, never logged, never executed, never in PATH.85 +//! Storage is `<uuid4>.json` (server-named, mode `0600`) under the inbox86 +//! dir; the only read path is the local `hu13 inbox list|show` (escaped87 +//! plain text to the terminal) or direct filesystem/SSH access.88 +//!89 +//! Hashing choice (std-only, no new deps per the hostile-surface rule):90 +//! `salted_ip_hash` = hex(SHA-256(`"v1|" + ip + "|" + yyyy-mm-dd + "|" +91 +//! server_salt`)). SHA-256 is vendored below (~60 lines) because `std`92 +//! ships no SHA and no fixed-key SipHash; the daily date rotates the salt93 +//! and a per-startup random `server_salt` keeps restarts unlinkable.94 +//! UUIDs come from `/dev/urandom` (never from user input, never in a path95 +//! except as the generated name).96 +97 +use std::path::Path;98 +99 +use crate::layout::{esc, page};100 +101 +/// Max chars for `title` (both kinds).102 +pub const MAX_TITLE_CHARS: usize = 300;103 +/// Max chars for `body` / `prompt`.104 +pub const MAX_BODY_CHARS: usize = 20000;105 +/// Max chars for optional `handle`.106 +pub const MAX_HANDLE_CHARS: usize = 40;107 +/// Max chars for optional `contact`.108 +pub const MAX_CONTACT_CHARS: usize = 100;109 +/// Max whole POST body bytes (else `413`).110 +pub const MAX_REQUEST_BYTES: usize = 32 * 1024;111 +/// Per-connection read timeout, seconds (else drop).112 +pub const POST_READ_TIMEOUT_SECS: u64 = 5;113 +/// Fixed delay before answering any inbox-route response, ms (no oracle).114 +pub const ACK_DELAY_MS: u64 = 500;115 +/// Stored `license_version` for every accepted submission.116 +pub const LICENSE_VERSION: &str = "v1";117 +118 +// TODO(m4-harden, issue #10): abuse controls live here. Stubs below are119 +// wired into the POST path (they currently allow everything) so #10 only120 +// has to fill in bodies + flags/env tunables, no call-site surgery.121 +122 +/// TODO(m4-harden, issue #10): per-IP bucket (2/hr) — wire flags123 +/// `--rate-per-hour` / `HU13_RATE_PER_HOUR` here.124 +pub const RATE_PER_HOUR: u32 = 2;125 +/// TODO(m4-harden, issue #10): global quota (files) — wire flags126 +/// `--quota` / `HU13_QUOTA` here.127 +pub const QUOTA_FILES: usize = 500;128 +129 +/// TODO(m4-harden, issue #10): per-IP token bucket. Currently always130 +/// allows; #10 implements the bucket + `429` path (already wired).131 +#[must_use]132 +pub fn check_rate_limit(_ip: &str) -> bool {133 +    let _ = RATE_PER_HOUR;134 +    true135 +}136 +137 +/// TODO(m4-harden, issue #10): global quota (`503` when full until drained138 +/// by hand). Currently always allows; #10 implements the count + `503`139 +/// path (already wired).140 +#[must_use]141 +pub fn check_quota(_inbox_dir: &Path) -> bool {142 +    let _ = QUOTA_FILES;143 +    true144 +}145 +146 +// TODO(m4-harden, issue #10): `Origin` check + `SameSite=Lax` live at the147 +// HTTP layer (`serve.rs`); we set no cookies and no CORS today, so there148 +// is nothing to check yet. #10 adds the check without changing this file.149 +150 +/// License grant — EXACT final text from `DRAFT.md` (receiver-friendly v1).151 +/// The forms, the agents page, and storage all derive from this one152 +/// constant (deferred owner re-read pre-v1 tracks wording drift).153 +pub const LICENSE_TEXT: &str = "By submitting this text you irrevocably give it to this project.\nYou keep no rights in it, you are owed nothing for it, and you will\nmake no claim over it — ownership, credit, payment, infringement,\nor otherwise — against the maintainer or anyone who uses it. The\nmaintainer may keep, share, change, or use it for any purpose, in\nany code or license, without notice or payment. If you cannot agree\nto that, do not submit.";154 +155 +/// Checkbox values accepted as "license checked". Browsers send `on` by156 +/// default; our forms + agents-page curl examples send `agree`.157 +fn license_checked(value: Option<&str>) -> bool {158 +    match value.map(|v| v.trim().to_ascii_lowercase()) {159 +        Some(v) => matches!(160 +            v.as_str(),161 +            "on" | "agree" | "yes" | "true" | "1" | "checked" | "accept"162 +        ),163 +        None => false,164 +    }165 +}166 +167 +/// Which of the two mailboxes a submission targets.168 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]169 +pub enum InboxType {170 +    Issue,171 +    PrPrompt,172 +}173 +174 +impl InboxType {175 +    #[must_use]176 +    pub fn as_str(self) -> &'static str {177 +        match self {178 +            InboxType::Issue => "issue",179 +            InboxType::PrPrompt => "pr-prompt",180 +        }181 +    }182 +183 +    /// Body field name on the wire: `body` for issues, `prompt` for184 +    /// pr-prompts (a `body` alias is accepted for robustness).185 +    #[must_use]186 +    pub fn body_field(self) -> &'static str {187 +        match self {188 +            InboxType::Issue => "body",189 +            InboxType::PrPrompt => "prompt",190 +        }191 +    }192 +193 +    #[must_use]194 +    pub fn route(self) -> &'static str {195 +        match self {196 +            InboxType::Issue => "/inbox/issue",197 +            InboxType::PrPrompt => "/inbox/pr-prompt",198 +        }199 +    }200 +}201 +202 +/// Validated, control-stripped submission, ready to store.203 +#[derive(Debug, Clone)]204 +pub struct Validated {205 +    pub kind: InboxType,206 +    pub title: String,207 +    pub body: String,208 +    pub handle: String,209 +    pub contact: String,210 +}211 +212 +/// Outcome of [`validate_form`]: store it, fake it (honeypot), or reject.213 +#[derive(Debug)]214 +pub enum Validation {215 +    /// Real submission — store + ack with the file uuid.216 +    Accept(Validated),217 +    /// Honeypot filled — say `received` with a fresh uuid, store nothing.218 +    Honeypot,219 +    /// Fail closed with this HTTP status (400/413). Generic page, no echo.220 +    Reject(u16),221 +}222 +223 +/// Percent-decode one `application/x-www-form-urlencoded` component224 +/// (`+` = space). Rejects malformed `%` escapes and non-UTF-8.225 +fn urldecode_component(s: &str) -> Option<String> {226 +    let mut bytes: Vec<u8> = Vec::with_capacity(s.len());227 +    let mut it = s.as_bytes().iter().peekable();228 +    while let Some(&b) = it.next() {229 +        match b {230 +            b'+' => bytes.push(b' '),231 +            b'%' => {232 +                let hi = *it.next()?;233 +                let lo = *it.next()?;234 +                let hex = |c: u8| -> Option<u8> {235 +                    match c {236 +                        b'0'..=b'9' => Some(c - b'0'),237 +                        b'a'..=b'f' => Some(c - b'a' + 10),238 +                        b'A'..=b'F' => Some(c - b'A' + 10),239 +                        _ => None,240 +                    }241 +                };242 +                bytes.push((hex(hi)? << 4) | hex(lo)?);243 +            }244 +            _ => bytes.push(b),245 +        }246 +    }247 +    String::from_utf8(bytes).ok()248 +}249 +250 +/// Parse a urlencoded body into ordered pairs. `None` = malformed251 +/// (bad `%` escape or non-UTF-8) → caller answers `400`.252 +pub fn parse_form(body: &str) -> Option<Vec<(String, String)>> {253 +    if body.is_empty() {254 +        return Some(Vec::new());255 +    }256 +    let mut out = Vec::new();257 +    for pair in body.split('&') {258 +        let (k, v) = match pair.split_once('=') {259 +            Some((k, v)) => (k, v),260 +            None => (pair, ""),261 +        };262 +        out.push((urldecode_component(k)?, urldecode_component(v)?));263 +    }264 +    Some(out)265 +}266 +267 +/// First occurrence of `name` in parsed pairs.268 +fn field<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> {269 +    pairs270 +        .iter()271 +        .find(|(k, _)| k == name)272 +        .map(|(_, v)| v.as_str())273 +}274 +275 +/// `true` when the honeypot is filled (either the canonical `website`276 +/// field or the legacy `honeypot` alias — any non-empty value).277 +fn honeypot_filled(pairs: &[(String, String)]) -> bool {278 +    field(pairs, "website").is_some_and(|v| !v.is_empty())279 +        || field(pairs, "honeypot").is_some_and(|v| !v.is_empty())280 +}281 +282 +/// Strip control chars except `\n` and `\t`. Callers must reject NUL283 +/// *before* this (NUL would otherwise vanish instead of failing closed).284 +#[must_use]285 +pub fn strip_controls(s: &str) -> String {286 +    s.chars()287 +        .filter(|&c| c == '\n' || c == '\t' || !c.is_control())288 +        .collect()289 +}290 +291 +fn char_len_ok(s: &str, max: usize) -> bool {292 +    s.chars().count() <= max293 +}294 +295 +/// Validate parsed pairs for `kind`: honeypot → [`Validation::Honeypot`];296 +/// bad input → `Reject(400)`; oversize whole shape is enforced by the HTTP297 +/// layer (`413`) before we are called.298 +#[must_use]299 +pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation {300 +    if honeypot_filled(pairs) {301 +        return Validation::Honeypot;302 +    }303 +    // Fail closed on NUL anywhere (checked before stripping, or it would304 +    // silently disappear).305 +    if pairs306 +        .iter()307 +        .any(|(k, v)| k.contains('\0') || v.contains('\0'))308 +    {309 +        return Validation::Reject(400);310 +    }311 +    let raw_title = field(pairs, "title").unwrap_or("");312 +    // Primary body field per kind (`prompt` for pr-prompts, with a `body`313 +    // alias for robustness); issues take `body`.314 +    let raw_body = field(pairs, kind.body_field())315 +        .or_else(|| {316 +            if kind == InboxType::PrPrompt {317 +                field(pairs, "body")318 +            } else {319 +                None320 +            }321 +        })322 +        .unwrap_or("");323 +    let raw_handle = field(pairs, "handle").unwrap_or("");324 +    let raw_contact = field(pairs, "contact").unwrap_or("");325 +    let title = strip_controls(raw_title);326 +    let body = strip_controls(raw_body);327 +    let handle = strip_controls(raw_handle);328 +    let contact = strip_controls(raw_contact);329 +    if title.trim().is_empty() || body.trim().is_empty() {330 +        return Validation::Reject(400);331 +    }332 +    if !char_len_ok(&title, MAX_TITLE_CHARS)333 +        || !char_len_ok(&body, MAX_BODY_CHARS)334 +        || !char_len_ok(&handle, MAX_HANDLE_CHARS)335 +        || !char_len_ok(&contact, MAX_CONTACT_CHARS)336 +    {337 +        return Validation::Reject(400);338 +    }339 +    if !license_checked(field(pairs, "license")) {340 +        return Validation::Reject(400);341 +    }342 +    Validation::Accept(Validated {343 +        kind,344 +        title,345 +        body,346 +        handle,347 +        contact,348 +    })349 +}350 +351 +/// `received` page: one-time ack uuid, ZERO reflection of input. The same352 +/// page answers real stores and honeypot drops (no oracle).353 +#[must_use]354 +pub fn received_page(ack: &str) -> String {355 +    page(356 +        "received",357 +        "",358 +        &format!(359 +            "<h1>received</h1>\n<p>ack: <code>{}</code></p>\n\360 +             <p>Thank you. This identifier was shown once and is not a lookup URL.</p>\n",361 +            esc(ack)362 +        ),363 +    )364 +}365 +366 +/// Generic (input-free) error page for inbox routes.367 +#[must_use]368 +pub fn inbox_error_page(title: &str, heading: &str, detail: &str) -> String {369 +    page(370 +        title,371 +        "",372 +        &format!("<h1>{}</h1>\n<p>{}</p>\n", esc(heading), esc(detail)),373 +    )374 +}375 +376 +// ---- randomness / time / hashing (std only) ----------------------------377 +378 +fn random_bytes(n: usize) -> Vec<u8> {379 +    let mut buf = vec![0u8; n];380 +    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {381 +        use std::io::Read;382 +        if f.read_exact(&mut buf).is_ok() {383 +            return buf;384 +        }385 +    }386 +    // Fallback (non-Linux / constrained): xorshift64* seeded from387 +    // time+pid. Never panics; still unguessable enough for ack uuids.388 +    let seed = unix_secs()389 +        .wrapping_mul(0x9E3779B97F4A7C15)390 +        .wrapping_add(std::process::id() as u64)391 +        | 1;392 +    let mut x = seed;393 +    for b in buf.iter_mut() {394 +        x ^= x >> 12;395 +        x ^= x << 25;396 +        x ^= x >> 27;397 +        *b = (x.wrapping_mul(0x2545F4914F6CDD1D) >> 56) as u8;398 +    }399 +    buf400 +}401 +402 +/// Server-generated uuid4 (no user input in the path, ever).403 +#[must_use]404 +pub fn new_uuid4() -> String {405 +    let b = random_bytes(16);406 +    let mut h = [0u8; 16];407 +    h.copy_from_slice(&b[..16]);408 +    h[6] = (h[6] & 0x0F) | 0x40; // version 4409 +    h[8] = (h[8] & 0x3F) | 0x80; // variant 10410 +    format!(411 +        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-\412 +         {:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",413 +        h[0],414 +        h[1],415 +        h[2],416 +        h[3],417 +        h[4],418 +        h[5],419 +        h[6],420 +        h[7],421 +        h[8],422 +        h[9],423 +        h[10],424 +        h[11],425 +        h[12],426 +        h[13],427 +        h[14],428 +        h[15]429 +    )430 +}431 +432 +fn unix_secs() -> u64 {433 +    std::time::SystemTime::now()434 +        .duration_since(std::time::UNIX_EPOCH)435 +        .map(|d| d.as_secs())436 +        .unwrap_or(0)437 +}438 +439 +fn civil_from_days(z: u64) -> (i64, u32, u32) {440 +    // Howard Hinnant's civil_from_days (proleptic Gregorian).441 +    let z = z as i64 + 719_468;442 +    let era = z.div_euclid(146_097);443 +    let doe = z - era * 146_097;444 +    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;445 +    let y = yoe + era * 400;446 +    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);447 +    let mp = (5 * doy + 2) / 153;448 +    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;449 +    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;450 +    (if m <= 2 { y + 1 } else { y }, m, d)451 +}452 +453 +fn utc_parts() -> (i64, u32, u32, u32, u32, u32) {454 +    let s = unix_secs();455 +    let (y, m, d) = civil_from_days(s / 86_400);456 +    let t = (s % 86_400) as u32;457 +    (y, m, d, t / 3600, (t % 3600) / 60, t % 60)458 +}459 +460 +/// `YYYY-MM-DDTHH:MM:SSZ` now (UTC).461 +#[must_use]462 +pub fn utc_now_rfc3339() -> String {463 +    let (y, m, d, hh, mm, ss) = utc_parts();464 +    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")465 +}466 +467 +/// `YYYY-MM-DD` today (UTC) — the daily rotation of the IP-hash salt.468 +#[must_use]469 +pub fn utc_today() -> String {470 +    let (y, m, d, _, _, _) = utc_parts();471 +    format!("{y:04}-{m:02}-{d:02}")472 +}473 +474 +// Minimal SHA-256 (FIPS 180-4), std-only. ~60 lines so the inbox stores475 +// salted IP hashes without adding a dependency to the write surface.476 +#[allow(clippy::many_single_char_names)]477 +fn sha256(data: &[u8]) -> [u8; 32] {478 +    const K: [u32; 64] = [479 +        0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,480 +        0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,481 +        0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,482 +        0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,483 +        0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,484 +        0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,485 +        0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,486 +        0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,487 +        0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,488 +        0xc67178f2,489 +    ];490 +    let mut h: [u32; 8] = [491 +        0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,492 +        0x5be0cd19,493 +    ];494 +    let mut msg = data.to_vec();495 +    let bit_len = (data.len() as u64).wrapping_mul(8);496 +    msg.push(0x80);497 +    while msg.len() % 64 != 56 {498 +        msg.push(0);499 +    }500 +    msg.extend_from_slice(&bit_len.to_be_bytes());501 +    let (blocks, _) = msg.as_chunks::<64>();502 +    for chunk in blocks {503 +        let mut w = [0u32; 64];504 +        let (words, _) = chunk.as_chunks::<4>();505 +        for (i, b) in words.iter().enumerate().take(16) {506 +            w[i] = u32::from_be_bytes(*b);507 +        }508 +        for i in 16..64 {509 +            let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);510 +            let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);511 +            w[i] = w[i - 16]512 +                .wrapping_add(s0)513 +                .wrapping_add(w[i - 7])514 +                .wrapping_add(s1);515 +        }516 +        let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) =517 +            (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);518 +        for i in 0..64 {519 +            let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);520 +            let ch = (e & f) ^ ((!e) & g);521 +            let t1 = hh522 +                .wrapping_add(s1)523 +                .wrapping_add(ch)524 +                .wrapping_add(K[i])525 +                .wrapping_add(w[i]);526 +            let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);527 +            let maj = (a & b) ^ (a & c) ^ (b & c);528 +            let t2 = s0.wrapping_add(maj);529 +            hh = g;530 +            g = f;531 +            f = e;532 +            e = d.wrapping_add(t1);533 +            d = c;534 +            c = b;535 +            b = a;536 +            a = t1.wrapping_add(t2);537 +        }538 +        h[0] = h[0].wrapping_add(a);539 +        h[1] = h[1].wrapping_add(b);540 +        h[2] = h[2].wrapping_add(c);541 +        h[3] = h[3].wrapping_add(d);542 +        h[4] = h[4].wrapping_add(e);543 +        h[5] = h[5].wrapping_add(f);544 +        h[6] = h[6].wrapping_add(g);545 +        h[7] = h[7].wrapping_add(hh);546 +    }547 +    let mut out = [0u8; 32];548 +    for (i, v) in h.iter().enumerate() {549 +        out[4 * i..4 * i + 4].copy_from_slice(&v.to_be_bytes());550 +    }551 +    out552 +}553 +554 +#[must_use]555 +pub fn sha256_hex(data: &[u8]) -> String {556 +    let mut s = String::with_capacity(64);557 +    for b in sha256(data) {558 +        s.push_str(&format!("{b:02x}"));559 +    }560 +    s561 +}562 +563 +/// Salted IP hash: `SHA256-hex("v1|" + ip + "|" + yyyy-mm-dd + "|" +564 +/// server_salt)`. No dotted quads ever touch disk.565 +#[must_use]566 +pub fn salted_ip_hash(ip: &str, date: &str, server_salt: &str) -> String {567 +    sha256_hex(format!("v1|{ip}|{date}|{server_salt}").as_bytes())568 +}569 +570 +/// One startup's worth of salt (hex, 16 random bytes).571 +#[must_use]572 +pub fn new_server_salt() -> String {573 +    let mut s = String::with_capacity(32);574 +    for b in random_bytes(16) {575 +        s.push_str(&format!("{b:02x}"));576 +    }577 +    s578 +}579 +580 +// ---- storage -----------------------------------------------------------581 +582 +/// Write one accepted submission as `<uuid4>.json` mode `0600`.583 +/// Returns the ack uuid (= filename stem).584 +pub fn store_submission(585 +    inbox_dir: &Path,586 +    v: &Validated,587 +    ip: &str,588 +    server_salt: &str,589 +) -> Result<String, String> {590 +    std::fs::create_dir_all(inbox_dir).map_err(|e| format!("hu13: create inbox dir: {e}"))?;591 +    let uuid = new_uuid4();592 +    let dest = inbox_dir.join(format!("{uuid}.json"));593 +    // Insertion order is the field order (serde_json preserves it).594 +    let doc = serde_json::json!({595 +        "type": v.kind.as_str(),596 +        "title": v.title,597 +        "body": v.body,598 +        "handle": v.handle,599 +        "contact": v.contact,600 +        "license_version": LICENSE_VERSION,601 +        "received_at": utc_now_rfc3339(),602 +        "salted_ip_hash": salted_ip_hash(ip, &utc_today(), server_salt),603 +    });604 +    let mut opts = std::fs::OpenOptions::new();605 +    opts.write(true).create_new(true);606 +    #[cfg(unix)]607 +    {608 +        use std::os::unix::fs::OpenOptionsExt;609 +        opts.mode(0o600);610 +    }611 +    let mut f = opts612 +        .open(&dest)613 +        .map_err(|e| format!("hu13: write inbox file: {e}"))?;614 +    use std::io::Write;615 +    serde_json::to_writer(&mut f, &doc).map_err(|e| format!("hu13: encode inbox file: {e}"))?;616 +    f.write_all(b"\n")617 +        .map_err(|e| format!("hu13: write inbox file: {e}"))?;618 +    Ok(uuid)619 +}620 +621 +// ---- forms (built into dist, zero JS) ----------------------------------622 +623 +fn license_block() -> String {624 +    let mut out = String::from("<blockquote><p>");625 +    let lines: Vec<&str> = LICENSE_TEXT.split('\n').collect();626 +    for (i, line) in lines.iter().enumerate() {627 +        if i > 0 {628 +            out.push_str("<br>\n");629 +        }630 +        out.push_str(&esc(line));631 +    }632 +    out.push_str("</p></blockquote>\n");633 +    out634 +}635 +636 +fn form_page(637 +    site_name: &str,638 +    nav: &str,639 +    heading: &str,640 +    intro: &str,641 +    action: &str,642 +    body_name: &str,643 +    body_label: &str,644 +) -> String {645 +    let body = format!(646 +        "<h1>{heading}</h1>\n<p>{intro}</p>\n\647 +         <form method=\"post\" action=\"{action}\">\n\648 +         <p><label for=\"f-title\">Title (required, up to 300 chars)</label><br>\n\649 +         <input type=\"text\" id=\"f-title\" name=\"title\" maxlength=\"300\" required size=\"60\"></p>\n\650 +         <p><label for=\"f-body\">{body_label} (required, up to 20000 chars)</label><br>\n\651 +         <textarea id=\"f-body\" name=\"{body_name}\" rows=\"12\" cols=\"72\" maxlength=\"20000\" required></textarea></p>\n\652 +         <p><label for=\"f-handle\">Handle (optional, up to 40 chars)</label><br>\n\653 +         <input type=\"text\" id=\"f-handle\" name=\"handle\" maxlength=\"40\" size=\"40\" autocomplete=\"off\"></p>\n\654 +         <p><label for=\"f-contact\">Contact (optional, up to 100 chars)</label><br>\n\655 +         <input type=\"text\" id=\"f-contact\" name=\"contact\" maxlength=\"100\" size=\"60\" autocomplete=\"off\"></p>\n\656 +         <div style=\"display:none\" aria-hidden=\"true\">\n\657 +         <p><label>Leave this empty: <input type=\"text\" name=\"website\" value=\"\" autocomplete=\"off\" tabindex=\"-1\"></label></p>\n\658 +         </div>\n\659 +         {license}\660 +         <p><label><input type=\"checkbox\" name=\"license\" value=\"agree\" required> \661 +         I agree to the grant above (required — unchecked submissions are rejected).</label></p>\n\662 +         <p><button type=\"submit\">Submit</button></p>\n\663 +         </form>\n\664 +         <p class=\"muted\">Plain POST, no JavaScript. The server answers a one-time ack id \665 +         and reflects nothing back. Inbox contents never appear on this site.</p>\n",666 +        heading = esc(heading),667 +        intro = esc(intro),668 +        action = esc(action),669 +        body_label = esc(body_label),670 +        license = license_block(),671 +    );672 +    page(&format!("{heading} — {site_name}"), nav, &body)673 +}674 +675 +/// Write `new-issue.html` + `new-pr.html` into the built site.676 +pub fn render_forms(out: &Path, site_name: &str, nav: &str) -> Result<(), String> {677 +    let issue = form_page(678 +        site_name,679 +        nav,680 +        "Suggest an issue",681 +        "Detailed actionable text from your user. Free textbox on purpose — say what a maintainer can act on.",682 +        InboxType::Issue.route(),683 +        "body",684 +        "Body",685 +    );686 +    let pr = form_page(687 +        site_name,688 +        nav,689 +        "Suggest a change",690 +        "ONE prompt, plain text. No files, no diff, no branch. Code words inside stay text — never applied, never executed.",691 +        InboxType::PrPrompt.route(),692 +        "prompt",693 +        "Prompt",694 +    );695 +    std::fs::write(out.join("new-issue.html"), issue)696 +        .map_err(|e| format!("hu13: write new-issue.html: {e}"))?;697 +    std::fs::write(out.join("new-pr.html"), pr)698 +        .map_err(|e| format!("hu13: write new-pr.html: {e}"))?;699 +    Ok(())700 +}701 +702 +// ---- terminal escaping + local read commands ---------------------------703 +704 +/// Escape a string for plain-text terminal output: ANSI introducers die705 +/// with the ESC byte (`\x1b`), other C0/C1 controls + DEL become `\xNN` /706 +/// `\u{...}`, bidi/format trickery (`U+202A–U+202E`, `U+2066–U+2069`,707 +/// `U+200B–U+200F`, `U+FEFF`, `U+00AD`) becomes `\u{...}`. `\n` and `\t`708 +/// pass through so entries stay readable.709 +#[must_use]710 +pub fn escape_terminal(s: &str) -> String {711 +    let mut out = String::with_capacity(s.len());712 +    for c in s.chars() {713 +        match c {714 +            '\n' | '\t' => out.push(c),715 +            '\u{1b}' => out.push_str("\\x1b"),716 +            c if c.is_control() => {717 +                let n = c as u32;718 +                if n < 0x100 {719 +                    out.push_str(&format!("\\x{n:02x}"));720 +                } else {721 +                    out.push_str(&format!("\\u{{{n:x}}}"));722 +                }723 +            }724 +            '\u{200b}'..='\u{200f}'725 +            | '\u{202a}'..='\u{202e}'726 +            | '\u{2066}'..='\u{2069}'727 +            | '\u{00ad}'728 +            | '\u{feff}' => {729 +                out.push_str(&format!("\\u{{{:x}}}", c as u32));730 +            }731 +            _ => out.push(c),732 +        }733 +    }734 +    out735 +}736 +737 +/// One stored submission, as read back locally.738 +#[derive(Debug, Clone)]739 +pub struct Entry {740 +    pub id: String,741 +    pub kind: String,742 +    pub title: String,743 +    pub body: String,744 +    pub handle: String,745 +    pub contact: String,746 +    pub license_version: String,747 +    pub received_at: String,748 +    pub salted_ip_hash: String,749 +}750 +751 +fn read_entry(path: &Path) -> Option<Entry> {752 +    let text = std::fs::read_to_string(path).ok()?;753 +    let v: serde_json::Value = serde_json::from_str(&text).ok()?;754 +    let get = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();755 +    let id = path.file_stem()?.to_string_lossy().into_owned();756 +    Some(Entry {757 +        id,758 +        kind: get("type"),759 +        title: get("title"),760 +        body: get("body"),761 +        handle: get("handle"),762 +        contact: get("contact"),763 +        license_version: get("license_version"),764 +        received_at: get("received_at"),765 +        salted_ip_hash: get("salted_ip_hash"),766 +    })767 +}768 +769 +/// All entries sorted by `(received_at, id)`. Unparseable files are770 +/// skipped with a stderr warning (never fatal, never raw output).771 +pub fn list_entries(inbox_dir: &Path) -> Vec<Entry> {772 +    let mut out = Vec::new();773 +    let rd = match std::fs::read_dir(inbox_dir) {774 +        Ok(rd) => rd,775 +        Err(_) => return out,776 +    };777 +    for ent in rd.flatten() {778 +        let p = ent.path();779 +        if p.extension().is_none_or(|e| e != "json") || !p.is_file() {780 +            continue;781 +        }782 +        match read_entry(&p) {783 +            Some(e) => out.push(e),784 +            None => eprintln!("hu13 inbox: warning: skipping unparseable {}", p.display()),785 +        }786 +    }787 +    out.sort_by(|a, b| {788 +        (a.received_at.clone(), a.id.clone()).cmp(&(b.received_at.clone(), b.id.clone()))789 +    });790 +    out791 +}792 +793 +/// `hu13 inbox list`: one escaped line per entry.794 +pub fn cmd_list(inbox_dir: &Path) -> i32 {795 +    for e in list_entries(inbox_dir) {796 +        println!(797 +            "{} [{}] {} {}",798 +            e.id,799 +            escape_terminal(&e.kind),800 +            escape_terminal(&e.received_at),801 +            escape_terminal(&e.title)802 +        );803 +    }804 +    0805 +}806 +807 +/// `hu13 inbox show <id>`: every field, escaped. The id must be a bare808 +/// filename stem (`[A-Za-z0-9-]`, `.json` suffix optional) — anything else809 +/// is rejected so user input never becomes a path.810 +pub fn cmd_show(inbox_dir: &Path, id: &str) -> i32 {811 +    let stem = id.strip_suffix(".json").unwrap_or(id);812 +    if stem.is_empty()813 +        || stem.len() > 128814 +        || !stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')815 +    {816 +        eprintln!("hu13 inbox: bad id (use the uuid from `inbox list`)");817 +        return 2;818 +    }819 +    let path = inbox_dir.join(format!("{stem}.json"));820 +    match read_entry(&path) {821 +        Some(e) => {822 +            println!("id: {}", escape_terminal(&e.id));823 +            println!("type: {}", escape_terminal(&e.kind));824 +            println!("received_at: {}", escape_terminal(&e.received_at));825 +            println!("license_version: {}", escape_terminal(&e.license_version));826 +            println!("title: {}", escape_terminal(&e.title));827 +            println!("handle: {}", escape_terminal(&e.handle));828 +            println!("contact: {}", escape_terminal(&e.contact));829 +            println!("salted_ip_hash: {}", escape_terminal(&e.salted_ip_hash));830 +            println!("body:\n{}", escape_terminal(&e.body));831 +            0832 +        }833 +        None => {834 +            eprintln!("hu13 inbox: no such entry '{stem}'");835 +            1836 +        }837 +    }838 +}839 +840 +#[cfg(test)]841 +mod tests {842 +    use super::*;843 +844 +    #[test]845 +    fn sha256_known_vector() {846 +        assert_eq!(847 +            sha256_hex(b"abc"),848 +            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"849 +        );850 +        assert_eq!(851 +            sha256_hex(b""),852 +            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"853 +        );854 +    }855 +856 +    #[test]857 +    fn uuid4_shape_and_version_bits() {858 +        let u = new_uuid4();859 +        assert_eq!(u.len(), 36);860 +        for &i in &[8, 13, 18, 23] {861 +            assert_eq!(u.as_bytes()[i], b'-');862 +        }863 +        assert_eq!(u.as_bytes()[14], b'4');864 +        assert!(matches!(u.as_bytes()[19], b'8' | b'9' | b'a' | b'b'));865 +        assert_ne!(new_uuid4(), new_uuid4());866 +    }867 +868 +    #[test]869 +    fn urldecode_roundtrip_plus_and_percent() {870 +        let pairs = parse_form("title=a+b%26c&body=x%0Ay&license=agree").unwrap();871 +        assert_eq!(field(&pairs, "title"), Some("a b&c"));872 +        assert_eq!(field(&pairs, "body"), Some("x\ny"));873 +        assert!(parse_form("a=%ZZ").is_none(), "bad hex rejected");874 +        assert!(parse_form("a=%ff").is_none(), "lone non-UTF8 rejected");875 +    }876 +877 +    #[test]878 +    fn license_gate() {879 +        let base = vec![880 +            ("title".to_string(), "t".to_string()),881 +            ("body".to_string(), "b".to_string()),882 +        ];883 +        assert!(matches!(884 +            validate_form(InboxType::Issue, &base),885 +            Validation::Reject(400)886 +        ));887 +        let mut agree = base.clone();888 +        agree.push(("license".to_string(), "agree".to_string()));889 +        assert!(matches!(890 +            validate_form(InboxType::Issue, &agree),891 +            Validation::Accept(_)892 +        ));893 +        let mut on = base;894 +        on.push(("license".to_string(), "on".to_string()));895 +        assert!(matches!(896 +            validate_form(InboxType::Issue, &on),897 +            Validation::Accept(_)898 +        ));899 +    }900 +901 +    #[test]902 +    fn nul_rejected_before_strip() {903 +        let pairs = vec![904 +            ("title".to_string(), "a\0b".to_string()),905 +            ("body".to_string(), "c".to_string()),906 +            ("license".to_string(), "agree".to_string()),907 +        ];908 +        assert!(matches!(909 +            validate_form(InboxType::Issue, &pairs),910 +            Validation::Reject(400)911 +        ));912 +        assert_eq!(strip_controls("a\x01b\tc\nd"), "ab\tc\nd");913 +    }914 +915 +    #[test]916 +    fn honeypot_triggers_fake() {917 +        let pairs = vec![918 +            ("title".to_string(), "t".to_string()),919 +            ("body".to_string(), "b".to_string()),920 +            ("license".to_string(), "agree".to_string()),921 +            ("website".to_string(), "bot".to_string()),922 +        ];923 +        assert!(matches!(924 +            validate_form(InboxType::Issue, &pairs),925 +            Validation::Honeypot926 +        ));927 +    }928 +929 +    #[test]930 +    fn caps_rejected() {931 +        let mk = |t: String, b: String| {932 +            vec![933 +                ("title".to_string(), t),934 +                ("body".to_string(), b),935 +                ("license".to_string(), "agree".to_string()),936 +            ]937 +        };938 +        assert!(matches!(939 +            validate_form(InboxType::Issue, &mk("t".repeat(301), "b".into())),940 +            Validation::Reject(400)941 +        ));942 +        assert!(matches!(943 +            validate_form(InboxType::Issue, &mk("t".into(), "b".repeat(20001))),944 +            Validation::Reject(400)945 +        ));946 +        let pr_ok = vec![947 +            ("title".to_string(), "t".to_string()),948 +            ("prompt".to_string(), "p".to_string()),949 +            ("license".to_string(), "agree".to_string()),950 +        ];951 +        assert!(matches!(952 +            validate_form(InboxType::PrPrompt, &pr_ok),953 +            Validation::Accept(_)954 +        ));955 +    }956 +957 +    #[test]958 +    fn terminal_escape_kills_ansi_and_controls() {959 +        let evil = "a\x1b[31mRED\x1b[0m\x07b\rc\u{202e}d<script>";960 +        let got = escape_terminal(evil);961 +        assert!(!got.contains('\x1b'), "no raw ESC");962 +        assert!(!got.contains('\x07'), "no raw BEL");963 +        assert!(!got.contains('\r'), "no raw CR");964 +        assert!(got.contains("\\x1b[31mRED\\x1b[0m"), "ESC shown escaped");965 +        assert!(got.contains("\\x07"), "BEL shown escaped");966 +        assert!(got.contains("\\u{202e}"), "bidi override escaped");967 +        assert!(got.contains("<script>"), "plain text stays readable");968 +    }969 +970 +    #[test]971 +    fn received_page_reflects_nothing() {972 +        let marker = "MARKER-xyz-evil<script>alert(1)</script>";973 +        let html = received_page("11111111-2222-4333-8555-666666666666");974 +        assert!(html.contains("received"));975 +        assert!(html.contains("11111111-2222-4333-8555-666666666666"));976 +        assert!(!html.contains(marker));977 +        assert!(!html.contains("<script"), "zero JS, zero reflection");978 +    }979 +980 +    #[test]981 +    fn forms_have_honeypot_license_action_no_js() {982 +        let dir = std::env::temp_dir().join(format!(983 +            "hu13-inbox-forms-{}-{}",984 +            std::process::id(),985 +            std::time::SystemTime::now()986 +                .duration_since(std::time::UNIX_EPOCH)987 +                .map(|d| d.as_nanos())988 +                .unwrap_or(0)989 +        ));990 +        std::fs::create_dir_all(&dir).unwrap();991 +        render_forms(&dir, "test", "").unwrap();992 +        let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();993 +        let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();994 +        assert!(issue.contains("action=\"/inbox/issue\""));995 +        assert!(issue.contains("name=\"body\""));996 +        assert!(pr.contains("action=\"/inbox/pr-prompt\""));997 +        assert!(pr.contains("name=\"prompt\""));998 +        for html in [&issue, &pr] {999 +            assert!(html.contains("name=\"website\""), "honeypot named website");1000 +            assert!(html.contains("name=\"license\""), "license checkbox");1001 +            assert!(1002 +                html.contains("irrevocably give it to this project"),1003 +                "exact license text"1004 +            );1005 +            assert!(!html.contains("<script"), "zero JS");1006 +            assert!(html.contains("maxlength=\"300\""));1007 +            assert!(html.contains("maxlength=\"20000\""));1008 +        }1009 +        std::fs::remove_dir_all(&dir).ok();1010 +    }1011 +}1012 diff --git a/src/main.rs b/src/main.rs1013 index c2b1c2c..20feee5 1006441014 --- a/src/main.rs1015 +++ b/src/main.rs1016 @@ -5,8 +5,10 @@ mod commits;1017  mod feed;1018  mod forge;1019  mod gitx;1020 +mod inbox;1021  mod layout;1022  mod releases;1023 +mod serve;1024  mod wiki;1025  1026  const VERSION: &str = env!("CARGO_PKG_VERSION");1027 @@ -57,18 +59,19 @@ OPTIONS:1028  fn print_serve_help() {1029      print!(1030          "\1031 -hu13-serve — serve a built site plus the two inbox POSTs (stub)1032 +hu13-serve — serve a built site plus the two inbox POSTs1033  1034  USAGE:1035      hu13 serve [OPTIONS]1036  1037  OPTIONS:1038          --www <DIR>        Built site directory to serve1039 -        --inbox-dir <DIR>  Inbox directory for POST storage1040 +        --inbox-dir <DIR>  Inbox directory for POST storage (must sit OUTSIDE --www)1041          --listen <ADDR>    Listen address [default: 0.0.0.0:39313]1042      -h, --help             Print this help1043  1044 -NOTE: m1/scaffold stub — prints help and exits 0, nothing served yet.1045 +ENV: HU13_WWW, HU13_INBOX_DIR, HU13_LISTEN (flags win).1046 +POSTS: /inbox/issue + /inbox/pr-prompt only (urlencoded, 32KB cap, ack id).1047  "1048      );1049  }1050 @@ -76,21 +79,17 @@ NOTE: m1/scaffold stub — prints help and exits 0, nothing served yet.1051  fn print_inbox_help() {1052      print!(1053          "\1054 -hu13-inbox — inspect the local inbox (stub)1055 +hu13-inbox — inspect the local inbox (local only, never over HTTP)1056  1057  USAGE:1058 -    hu13 inbox <list|show> [OPTIONS]1059 -1060 -SUBCOMMANDS:1061 -    list    List inbox entries (escaped plain text)1062 -    show    Show one inbox entry (escaped plain text)1063 +    hu13 inbox list [--inbox-dir <DIR>]1064 +    hu13 inbox show <UUID> [--inbox-dir <DIR>]1065  1066  OPTIONS:1067 -        --inbox-dir <DIR>  Inbox directory to read1068 +        --inbox-dir <DIR>  Inbox directory to read (else HU13_INBOX_DIR)1069      -h, --help             Print this help1070  1071 -SAFETY: never paste inbox text into a shell. See gl0w.md.1072 -NOTE: m1/scaffold stub — prints help and exits 0.1073 +SAFETY: output is escaped plain text. Never paste inbox text into a shell. See gl0w.md.1074  "1075      );1076  }1077 @@ -202,16 +201,67 @@ fn run_build(args: &[String]) -> i32 {1078      }1079  }1080  1081 +fn env_or(opt: Option<String>, name: &str) -> Option<String> {1082 +    opt.or_else(|| std::env::var(name).ok().filter(|s| !s.trim().is_empty()))1083 +}1084 +1085  fn run_serve(args: &[String]) -> i32 {1086      if args.iter().any(|a| wants_help(a)) {1087          print_serve_help();1088          return 0;1089      }1090 -    // TODO(m4): serve `dist/` with `X-Content-Type-Options: nosniff` on every1091 -    // response (static files + inbox POSTs); never execute or list beyond1092 -    // what the build emitted. Stub for now — prints help and exits 0.1093 -    print_serve_help();1094 -    01095 +    let mut www: Option<String> = None;1096 +    let mut inbox_dir: Option<String> = None;1097 +    let mut listen: Option<String> = None;1098 +    let mut i = 0;1099 +    while i < args.len() {1100 +        match args[i].as_str() {1101 +            "--www" => {1102 +                i += 1;1103 +                if i >= args.len() {1104 +                    eprintln!("hu13 serve: --www needs a value");1105 +                    return 2;1106 +                }1107 +                www = Some(args[i].clone());1108 +            }1109 +            "--inbox-dir" => {1110 +                i += 1;1111 +                if i >= args.len() {1112 +                    eprintln!("hu13 serve: --inbox-dir needs a value");1113 +                    return 2;1114 +                }1115 +                inbox_dir = Some(args[i].clone());1116 +            }1117 +            "--listen" => {1118 +                i += 1;1119 +                if i >= args.len() {1120 +                    eprintln!("hu13 serve: --listen needs a value");1121 +                    return 2;1122 +                }1123 +                listen = Some(args[i].clone());1124 +            }1125 +            // TODO(m4-harden, issue #10): cap tunables land here:1126 +            // --max-title/--max-body/--max-bytes/--rate-per-hour/--quota1127 +            // (+ HU13_* env). No config file, ever.1128 +            s => {1129 +                eprintln!("hu13 serve: unknown option '{s}'");1130 +                return 2;1131 +            }1132 +        }1133 +        i += 1;1134 +    }1135 +    let www = env_or(www, "HU13_WWW");1136 +    let inbox_dir = env_or(inbox_dir, "HU13_INBOX_DIR");1137 +    let listen = env_or(listen, "HU13_LISTEN").unwrap_or_else(|| "0.0.0.0:39313".to_string());1138 +    let (Some(www), Some(inbox_dir)) = (www, inbox_dir) else {1139 +        eprintln!("hu13 serve: usage: hu13 serve --www <DIR> --inbox-dir <DIR> [--listen <ADDR>]");1140 +        return 2;1141 +    };1142 +    serve::run(&serve::ServeOptions {1143 +        www: std::path::PathBuf::from(www),1144 +        inbox_dir: std::path::PathBuf::from(inbox_dir),1145 +        listen,1146 +    })1147  }1148  1149  fn run_inbox(args: &[String]) -> i32 {1150 @@ -223,15 +273,59 @@ fn run_inbox(args: &[String]) -> i32 {1151          print_inbox_help();1152          return 0;1153      }1154 -    match args[0].as_str() {1155 -        "list" | "show" => {1156 -            print_inbox_help();1157 -            01158 +    let sub = args[0].as_str();1159 +    let mut inbox_dir: Option<String> = None;1160 +    let mut id: Option<String> = None;1161 +    let mut i = 1;1162 +    while i < args.len() {1163 +        match args[i].as_str() {1164 +            "--inbox-dir" => {1165 +                i += 1;1166 +                if i >= args.len() {1167 +                    eprintln!("hu13 inbox: --inbox-dir needs a value");1168 +                    return 2;1169 +                }1170 +                inbox_dir = Some(args[i].clone());1171 +            }1172 +            s if s.starts_with('-') => {1173 +                eprintln!("hu13 inbox: unknown option '{s}'");1174 +                return 2;1175 +            }1176 +            s => {1177 +                if id.is_some() {1178 +                    eprintln!("hu13 inbox: unexpected argument '{s}'");1179 +                    return 2;1180 +                }1181 +                id = Some(s.to_string());1182 +            }1183 +        }1184 +        i += 1;1185 +    }1186 +    let inbox_dir = env_or(inbox_dir, "HU13_INBOX_DIR");1187 +    let Some(inbox_dir) = inbox_dir else {1188 +        eprintln!("hu13 inbox: usage: hu13 inbox <list|show> --inbox-dir <DIR>");1189 +        return 2;1190 +    };1191 +    let dir = std::path::Path::new(&inbox_dir);1192 +    match sub {1193 +        "list" => {1194 +            if id.is_some() {1195 +                eprintln!("hu13 inbox: list takes no id");1196 +                return 2;1197 +            }1198 +            inbox::cmd_list(dir)1199 +        }1200 +        "show" => {1201 +            let Some(id) = id else {1202 +                eprintln!("hu13 inbox: usage: hu13 inbox show <UUID> --inbox-dir <DIR>");1203 +                return 2;1204 +            };1205 +            inbox::cmd_show(dir, &id)1206          }1207          _ => {1208 -            eprintln!("hu13: unknown inbox subcommand '{}'", args[0]);1209 +            eprintln!("hu13: unknown inbox subcommand '{sub}'");1210              print_inbox_help();1211 -            01212 +            21213          }1214      }1215  }1216 diff --git a/src/serve.rs b/src/serve.rs1217 new file mode 1006441218 index 0000000..53c61b91219 --- /dev/null1220 +++ b/src/serve.rs1221 @@ -0,0 +1,591 @@1222 +//! `hu13 serve` (m4, f0rge issue #9): std-only static file server1223 +//! (`TcpListener` + thread-per-connection, no new deps) plus exactly two1224 +//! POST routes (`/inbox/issue`, `/inbox/pr-prompt`).1225 +//!1226 +//! Rules enforced here, fail closed:1227 +//! - `/data/inbox` is NEVER under www: startup refuses unless the inbox1228 +//!   dir sits outside the served tree ([`ensure_inbox_outside_www`]).1229 +//! - Static: every response carries `nosniff`; directories never list1230 +//!   (only real files serve; anything else is a generic `404` that echoes1231 +//!   nothing); `..` / NUL / bad escapes never escape www.1232 +//! - POSTs: urlencoded-only (`multipart/*` → `400`), `Content-Length`1233 +//!   required (absent/unparsable → drop the connection), whole body capped1234 +//!   at [`crate::inbox::MAX_REQUEST_BYTES`] (`413`), 5s read timeout1235 +//!   (stall → drop), strict UTF-8 (`400`), NUL → `400`, controls stripped1236 +//!   except `\n\t`, caps validated, license required (`400`), honeypot →1237 +//!   fake `received` + zero files, fixed ~500ms delay before every1238 +//!   inbox-route response, `received` page shows the ack uuid once with1239 +//!   zero reflection. Wrong method on the two routes → `405`.1240 +//! - TODO(m4-harden, issue #10): rate bucket (`429`), quota (`503`),1241 +//!   `Origin` check hook at the top of [`handle_inbox`].1242 +1243 +use std::io::{Read, Write};1244 +use std::net::{TcpListener, TcpStream};1245 +use std::path::{Path, PathBuf};1246 +use std::time::Duration;1247 +1248 +use crate::inbox;1249 +1250 +/// Options for [`run`] (flags/env only, no config file).1251 +#[derive(Debug, Clone)]1252 +pub struct ServeOptions {1253 +    /// Built site directory to serve.1254 +    pub www: PathBuf,1255 +    /// Inbox directory for POST storage (must sit outside `www`).1256 +    pub inbox_dir: PathBuf,1257 +    /// Listen address, e.g. `0.0.0.0:39313` (`127.0.0.1:0` in tests).1258 +    pub listen: String,1259 +}1260 +1261 +/// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so1262 +/// missing paths still check correctly).1263 +fn absolutize(p: &Path) -> PathBuf {1264 +    let abs = if p.is_absolute() {1265 +        p.to_path_buf()1266 +    } else {1267 +        std::env::current_dir()1268 +            .unwrap_or_else(|_| PathBuf::from("/"))1269 +            .join(p)1270 +    };1271 +    let mut out = PathBuf::new();1272 +    for comp in abs.components() {1273 +        match comp {1274 +            std::path::Component::ParentDir => {1275 +                out.pop();1276 +            }1277 +            std::path::Component::CurDir => {}1278 +            _ => out.push(comp.as_os_str()),1279 +        }1280 +    }1281 +    if out.as_os_str().is_empty() {1282 +        PathBuf::from("/")1283 +    } else {1284 +        out1285 +    }1286 +}1287 +1288 +/// Refuse to start when the inbox dir is inside (or equal to) www.1289 +/// Inbox files must never be reachable over HTTP.1290 +pub fn ensure_inbox_outside_www(www: &Path, inbox_dir: &Path) -> Result<(), String> {1291 +    let www = absolutize(www);1292 +    let inbox = absolutize(inbox_dir);1293 +    if inbox == www || inbox.starts_with(&www) {1294 +        return Err(format!(1295 +            "hu13: refusing to start: inbox-dir {} is inside www {} (inbox must never be served)",1296 +            inbox.display(),1297 +            www.display()1298 +        ));1299 +    }1300 +    Ok(())1301 +}1302 +1303 +/// Block forever serving `www` + the two POSTs. Returns only on fatal1304 +/// startup errors (bind failure etc.).1305 +pub fn run(opts: &ServeOptions) -> i32 {1306 +    if let Err(e) = ensure_inbox_outside_www(&opts.www, &opts.inbox_dir) {1307 +        eprintln!("{e}");1308 +        return 1;1309 +    }1310 +    if !opts.www.is_dir() {1311 +        eprintln!(1312 +            "hu13: www dir {} missing (build first: hu13 build <repo> -o <dir>)",1313 +            opts.www.display()1314 +        );1315 +        return 1;1316 +    }1317 +    #[cfg(unix)]1318 +    {1319 +        use std::os::unix::fs::DirBuilderExt;1320 +        let mut b = std::fs::DirBuilder::new();1321 +        b.recursive(true).mode(0o700);1322 +        if let Err(e) = b.create(&opts.inbox_dir) {1323 +            eprintln!("hu13: create inbox dir: {e}");1324 +            return 1;1325 +        }1326 +    }1327 +    #[cfg(not(unix))]1328 +    {1329 +        if let Err(e) = std::fs::create_dir_all(&opts.inbox_dir) {1330 +            eprintln!("hu13: create inbox dir: {e}");1331 +            return 1;1332 +        }1333 +    }1334 +    let listener = match TcpListener::bind(&opts.listen) {1335 +        Ok(l) => l,1336 +        Err(e) => {1337 +            eprintln!("hu13: bind {}: {e}", opts.listen);1338 +            return 1;1339 +        }1340 +    };1341 +    let bound = listener1342 +        .local_addr()1343 +        .map(|a| a.to_string())1344 +        .unwrap_or_else(|_| opts.listen.clone());1345 +    // Integration tests spawn this binary with `--listen 127.0.0.1:0` and1346 +    // parse this exact line to learn the port. Flush: piped stdout blocks.1347 +    println!("hu13: listening on {bound}");1348 +    use std::io::Write as _;1349 +    let _ = std::io::stdout().flush();1350 +    let server_salt = inbox::new_server_salt();1351 +    for conn in listener.incoming() {1352 +        match conn {1353 +            Ok(stream) => {1354 +                let www = opts.www.clone();1355 +                let inbox_dir = opts.inbox_dir.clone();1356 +                let salt = server_salt.clone();1357 +                std::thread::spawn(move || handle_conn(stream, &www, &inbox_dir, &salt));1358 +            }1359 +            Err(e) => {1360 +                eprintln!("hu13: accept: {e}");1361 +            }1362 +        }1363 +    }1364 +    eprintln!("hu13: listener closed");1365 +    11366 +}1367 +1368 +// ---- HTTP plumbing (tiny, no deps) -------------------------------------1369 +1370 +struct Head {1371 +    method: String,1372 +    target: String,1373 +    headers: Vec<(String, String)>,1374 +}1375 +1376 +/// Read until `\r\n\r\n` (cap 16KiB of headers). `None` = stall/overflow/1377 +/// EOF → caller drops the connection.1378 +fn read_head(stream: &mut TcpStream) -> Option<(Head, Vec<u8>)> {1379 +    let mut buf: Vec<u8> = Vec::with_capacity(4096);1380 +    let mut chunk = [0u8; 4096];1381 +    loop {1382 +        if buf.len() > 16 * 1024 {1383 +            return None;1384 +        }1385 +        match stream.read(&mut chunk) {1386 +            Ok(0) => return None,1387 +            Ok(n) => {1388 +                buf.extend_from_slice(&chunk[..n]);1389 +                if let Some(pos) = find_double_crlf(&buf) {1390 +                    let rest = buf[pos + 4..].to_vec();1391 +                    let text = std::str::from_utf8(&buf[..pos]).ok()?;1392 +                    return parse_head(text).map(|h| (h, rest));1393 +                }1394 +            }1395 +            Err(_) => return None,1396 +        }1397 +    }1398 +}1399 +1400 +fn find_double_crlf(buf: &[u8]) -> Option<usize> {1401 +    buf.windows(4).position(|w| w == b"\r\n\r\n")1402 +}1403 +1404 +fn parse_head(text: &str) -> Option<Head> {1405 +    let mut lines = text.split("\r\n");1406 +    let request_line = lines.next()?;1407 +    let mut parts = request_line.split_whitespace();1408 +    let method = parts.next()?.to_string();1409 +    let target = parts.next()?.to_string();1410 +    if method.is_empty() || target.is_empty() || target.len() > 4096 {1411 +        return None;1412 +    }1413 +    let mut headers = Vec::new();1414 +    for line in lines {1415 +        if line.is_empty() {1416 +            continue;1417 +        }1418 +        let (k, v) = line.split_once(':')?;1419 +        headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));1420 +    }1421 +    Some(Head {1422 +        method,1423 +        target,1424 +        headers,1425 +    })1426 +}1427 +1428 +fn header<'a>(h: &'a Head, name: &str) -> Option<&'a str> {1429 +    h.headers1430 +        .iter()1431 +        .find(|(k, _)| k == name)1432 +        .map(|(_, v)| v.as_str())1433 +}1434 +1435 +fn reason(code: u16) -> &'static str {1436 +    match code {1437 +        200 => "OK",1438 +        400 => "Bad Request",1439 +        404 => "Not Found",1440 +        405 => "Method Not Allowed",1441 +        413 => "Content Too Large",1442 +        429 => "Too Many Requests",1443 +        503 => "Service Unavailable",1444 +        _ => "Error",1445 +    }1446 +}1447 +1448 +/// One response, always `nosniff` + `Connection: close`. Inbox-route1449 +/// responses add `no-store` via `extra`.1450 +fn respond(1451 +    stream: &mut TcpStream,1452 +    code: u16,1453 +    content_type: &str,1454 +    extra: &[(&str, &str)],1455 +    body: &[u8],1456 +) {1457 +    let mut out = format!(1458 +        "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n",1459 +        reason(code),1460 +        body.len()1461 +    );1462 +    for (k, v) in extra {1463 +        out.push_str(k);1464 +        out.push_str(": ");1465 +        out.push_str(v);1466 +        out.push_str("\r\n");1467 +    }1468 +    out.push_str("\r\n");1469 +    let _ = stream.write_all(out.as_bytes());1470 +    let _ = stream.write_all(body);1471 +    let _ = stream.flush();1472 +}1473 +1474 +fn static_error(stream: &mut TcpStream, code: u16) {1475 +    let msg: &str = match code {1476 +        404 => "not found",1477 +        405 => "method not allowed",1478 +        _ => "error",1479 +    };1480 +    let body = crate::layout::page(msg, "", &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));1481 +    let extra = if code == 405 {1482 +        &[("Allow", "GET, HEAD")][..]1483 +    } else {1484 +        &[][..]1485 +    };1486 +    respond(1487 +        stream,1488 +        code,1489 +        "text/html; charset=utf-8",1490 +        extra,1491 +        body.as_bytes(),1492 +    );1493 +}1494 +1495 +fn content_type_for(name: &str) -> &'static str {1496 +    let lower = name.to_ascii_lowercase();1497 +    if lower.ends_with(".html") {1498 +        "text/html; charset=utf-8"1499 +    } else if lower.ends_with(".css") {1500 +        "text/css; charset=utf-8"1501 +    } else if lower.ends_with(".json") {1502 +        "application/json"1503 +    } else if lower.ends_with(".atom") {1504 +        "application/atom+xml; charset=utf-8"1505 +    } else if lower.ends_with(".md") || lower.ends_with(".txt") {1506 +        "text/plain; charset=utf-8"1507 +    } else if lower.ends_with(".gz") {1508 +        "application/gzip"1509 +    } else {1510 +        "application/octet-stream"1511 +    }1512 +}1513 +1514 +/// Percent-decode a URL path (`+` stays literal in paths). `None` = bad1515 +/// escape / NUL / non-UTF-8 → caller answers `400`/drops.1516 +fn decode_path(s: &str) -> Option<String> {1517 +    let mut bytes: Vec<u8> = Vec::with_capacity(s.len());1518 +    let mut it = s.as_bytes().iter();1519 +    while let Some(&b) = it.next() {1520 +        if b == b'%' {1521 +            let hi = *it.next()?;1522 +            let lo = *it.next()?;1523 +            let hex = |c: u8| -> Option<u8> {1524 +                match c {1525 +                    b'0'..=b'9' => Some(c - b'0'),1526 +                    b'a'..=b'f' => Some(c - b'a' + 10),1527 +                    b'A'..=b'F' => Some(c - b'A' + 10),1528 +                    _ => None,1529 +                }1530 +            };1531 +            bytes.push((hex(hi)? << 4) | hex(lo)?);1532 +        } else {1533 +            bytes.push(b);1534 +        }1535 +    }1536 +    if bytes.contains(&0) {1537 +        return None;1538 +    }1539 +    String::from_utf8(bytes).ok()1540 +}1541 +1542 +fn handle_conn(mut stream: TcpStream, www: &Path, inbox_dir: &Path, server_salt: &str) {1543 +    let _ = stream.set_read_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));1544 +    let _ = stream.set_write_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));1545 +    let (head, rest) = match read_head(&mut stream) {1546 +        Some(v) => v,1547 +        None => return, // stall/overflow/EOF: drop, fail closed1548 +    };1549 +    let raw_path = head.target.split('?').next().unwrap_or("/");1550 +    let path = match decode_path(raw_path) {1551 +        Some(p) => p,1552 +        None => {1553 +            static_error(&mut stream, 400);1554 +            return;1555 +        }1556 +    };1557 +    if path == "/inbox/issue" || path == "/inbox/pr-prompt" {1558 +        let kind = if path == "/inbox/issue" {1559 +            inbox::InboxType::Issue1560 +        } else {1561 +            inbox::InboxType::PrPrompt1562 +        };1563 +        handle_inbox(&mut stream, &head, rest, kind, inbox_dir, server_salt);1564 +        return;1565 +    }1566 +    // Static: GET/HEAD only; never a listing (dirs → 404, files only).1567 +    if head.method != "GET" && head.method != "HEAD" {1568 +        static_error(&mut stream, 405);1569 +        return;1570 +    }1571 +    let rel = path.strip_prefix('/').unwrap_or(&path);1572 +    if rel.contains('\0')1573 +        || rel.split('/').any(|s| s == "..")1574 +        || (path.ends_with('/') && path != "/")1575 +    {1576 +        static_error(&mut stream, 404);1577 +        return;1578 +    }1579 +    let file_rel = if path == "/" {1580 +        "index.html".to_string()1581 +    } else {1582 +        rel.to_string()1583 +    };1584 +    let dest = absolutize(&www.join(&file_rel));1585 +    if dest != absolutize(www) && !dest.starts_with(absolutize(www)) {1586 +        static_error(&mut stream, 404);1587 +        return;1588 +    }1589 +    let bytes = match std::fs::read(&dest) {1590 +        Ok(b) if dest.is_file() => b,1591 +        _ => {1592 +            static_error(&mut stream, 404);1593 +            return;1594 +        }1595 +    };1596 +    let ctype = content_type_for(&file_rel);1597 +    if head.method == "HEAD" {1598 +        let out = format!(1599 +            "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",1600 +            bytes.len()1601 +        );1602 +        let _ = stream.write_all(out.as_bytes());1603 +        let _ = stream.flush();1604 +        return;1605 +    }1606 +    respond(&mut stream, 200, ctype, &[], &bytes);1607 +}1608 +1609 +/// Read-and-discard in-flight request bytes (bounded) so early rejects1610 +/// get a clean close instead of a reset. Bounded by a 256KiB cap plus the1611 +/// 5s read timeout; liars just burn one thread briefly, then we answer.1612 +fn settle(stream: &mut TcpStream, rest_len: usize, len: Option<usize>) {1613 +    let Some(total) = len else { return };1614 +    let mut n = total.saturating_sub(rest_len).min(256 * 1024);1615 +    let mut chunk = [0u8; 8192];1616 +    while n > 0 {1617 +        let want = n.min(chunk.len());1618 +        match stream.read(&mut chunk[..want]) {1619 +            Ok(0) => break,1620 +            Ok(k) => n -= k,1621 +            Err(_) => break,1622 +        }1623 +    }1624 +}1625 +1626 +fn content_length(head: &Head) -> Option<usize> {1627 +    header(head, "content-length")?.trim().parse().ok()1628 +}1629 +1630 +#[allow(clippy::too_many_lines)]1631 +fn handle_inbox(1632 +    stream: &mut TcpStream,1633 +    head: &Head,1634 +    rest: Vec<u8>,1635 +    kind: inbox::InboxType,1636 +    inbox_dir: &Path,1637 +    server_salt: &str,1638 +) {1639 +    // Inbox-route responses always carry `no-store` and always wait the1640 +    // fixed ack delay (success and failure alike — no timing oracle).1641 +    let answer = |stream: &mut TcpStream, code: u16, body: &str, extra: &[(&str, &str)]| {1642 +        std::thread::sleep(Duration::from_millis(inbox::ACK_DELAY_MS));1643 +        let mut merged: Vec<(&str, &str)> = vec![("Cache-Control", "no-store")];1644 +        merged.extend_from_slice(extra);1645 +        respond(1646 +            stream,1647 +            code,1648 +            "text/html; charset=utf-8",1649 +            &merged,1650 +            body.as_bytes(),1651 +        );1652 +    };1653 +    if head.method != "POST" {1654 +        settle(stream, rest.len(), content_length(head));1655 +        let body = inbox::inbox_error_page(1656 +            "method not allowed",1657 +            "method not allowed",1658 +            "This endpoint accepts POST only.",1659 +        );1660 +        answer(stream, 405, &body, &[("Allow", "POST")]);1661 +        return;1662 +    }1663 +    // TODO(m4-harden, issue #10): `Origin` check goes here (fail closed1664 +    // when a browser sends a mismatched Origin; curl sends none).1665 +    let ctype = header(head, "content-type")1666 +        .unwrap_or("")1667 +        .to_ascii_lowercase();1668 +    if ctype.starts_with("multipart/") || !ctype.starts_with("application/x-www-form-urlencoded") {1669 +        settle(stream, rest.len(), content_length(head));1670 +        let body = inbox::inbox_error_page(1671 +            "bad request",1672 +            "bad request",1673 +            "This endpoint accepts urlencoded forms only.",1674 +        );1675 +        answer(stream, 400, &body, &[]);1676 +        return;1677 +    }1678 +    // No (parsable) Content-Length → drop the connection, no response.1679 +    let len: usize = match header(head, "content-length") {1680 +        Some(v) => match v.trim().parse() {1681 +            Ok(n) => n,1682 +            Err(_) => return,1683 +        },1684 +        None => return,1685 +    };1686 +    if len > inbox::MAX_REQUEST_BYTES {1687 +        settle(stream, rest.len(), Some(len));1688 +        let body =1689 +            inbox::inbox_error_page("too large", "too large", "Submissions are capped at 32KB.");1690 +        answer(stream, 413, &body, &[]);1691 +        return;1692 +    }1693 +    let mut body_bytes = rest;1694 +    if body_bytes.len() > len {1695 +        body_bytes.truncate(len);1696 +    }1697 +    while body_bytes.len() < len {1698 +        let mut chunk = [0u8; 8192];1699 +        let want = (len - body_bytes.len()).min(chunk.len());1700 +        match stream.read(&mut chunk[..want]) {1701 +            Ok(0) => break,1702 +            Ok(n) => body_bytes.extend_from_slice(&chunk[..n]),1703 +            Err(_) => return, // stall → drop1704 +        }1705 +    }1706 +    if body_bytes.len() != len {1707 +        return; // client went away early → drop1708 +    }1709 +    let text = match String::from_utf8(body_bytes) {1710 +        Ok(t) => t,1711 +        Err(_) => {1712 +            let body = inbox::inbox_error_page(1713 +                "bad request",1714 +                "bad request",1715 +                "Submissions must be valid UTF-8 text.",1716 +            );1717 +            answer(stream, 400, &body, &[]);1718 +            return;1719 +        }1720 +    };1721 +    if text.contains('\0') {1722 +        let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1723 +        answer(stream, 400, &body, &[]);1724 +        return;1725 +    }1726 +    let pairs = match inbox::parse_form(&text) {1727 +        Some(p) => p,1728 +        None => {1729 +            let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1730 +            answer(stream, 400, &body, &[]);1731 +            return;1732 +        }1733 +    };1734 +    match inbox::validate_form(kind, &pairs) {1735 +        inbox::Validation::Honeypot => {1736 +            // Silent `received` with a fresh (unstored) uuid — no oracle.1737 +            let page = inbox::received_page(&inbox::new_uuid4());1738 +            answer(stream, 200, &page, &[]);1739 +        }1740 +        inbox::Validation::Reject(code) => {1741 +            let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1742 +            answer(stream, code, &body, &[]);1743 +        }1744 +        inbox::Validation::Accept(v) => {1745 +            // TODO(m4-harden, issue #10): stubs wired in — #10 fills them.1746 +            let ip = stream1747 +                .peer_addr()1748 +                .map(|a| a.ip().to_string())1749 +                .unwrap_or_else(|_| "unknown".to_string());1750 +            if !inbox::check_rate_limit(&ip) {1751 +                let body =1752 +                    inbox::inbox_error_page("too many requests", "too many requests", "Slow down.");1753 +                answer(stream, 429, &body, &[]);1754 +                return;1755 +            }1756 +            if !inbox::check_quota(inbox_dir) {1757 +                let body =1758 +                    inbox::inbox_error_page("unavailable", "unavailable", "Inbox full. Try later.");1759 +                answer(stream, 503, &body, &[]);1760 +                return;1761 +            }1762 +            match inbox::store_submission(inbox_dir, &v, &ip, server_salt) {1763 +                Ok(uuid) => {1764 +                    let page = inbox::received_page(&uuid);1765 +                    answer(stream, 200, &page, &[]);1766 +                }1767 +                Err(_) => {1768 +                    // Full disk / IO failure: lose one POST, never the repo.1769 +                    let body = inbox::inbox_error_page(1770 +                        "unavailable",1771 +                        "unavailable",1772 +                        "Could not store that. Try later.",1773 +                    );1774 +                    answer(stream, 503, &body, &[]);1775 +                }1776 +            }1777 +        }1778 +    }1779 +}1780 +1781 +#[cfg(test)]1782 +mod tests {1783 +    use super::*;1784 +1785 +    #[test]1786 +    fn inbox_inside_www_refused() {1787 +        let www = Path::new("/tmp/hu13-www");1788 +        assert!(ensure_inbox_outside_www(www, &www.join("inbox")).is_err());1789 +        assert!(ensure_inbox_outside_www(www, www).is_err());1790 +        assert!(ensure_inbox_outside_www(www, Path::new("/tmp/hu13-inbox")).is_ok());1791 +        // `..` tricks resolve before comparison.1792 +        assert!(ensure_inbox_outside_www(www, &www.join("sub/../inbox")).is_err());1793 +        assert!(ensure_inbox_outside_www(www, &www.join("../hu13-inbox")).is_ok());1794 +    }1795 +1796 +    #[test]1797 +    fn content_types_sane() {1798 +        assert!(content_type_for("a.html").starts_with("text/html"));1799 +        assert!(content_type_for("s.css").starts_with("text/css"));1800 +        assert!(content_type_for("x.json").starts_with("application/json"));1801 +        assert!(content_type_for("f.atom").contains("atom"));1802 +        assert_eq!(content_type_for("b.bundle"), "application/octet-stream");1803 +    }1804 +1805 +    #[test]1806 +    fn bad_paths_rejected() {1807 +        assert!(decode_path("/%2e%2e/x").is_some()); // decodes; `..` blocked later1808 +        assert!(decode_path("/a%00b").is_none(), "NUL rejected");1809 +        assert!(decode_path("/a%ZZ").is_none(), "bad escape rejected");1810 +        assert!(decode_path("/plain/path-1_~x").is_some());1811 +    }1812 +}1813 diff --git a/tests/inbox.rs b/tests/inbox.rs1814 new file mode 1006441815 index 0000000..c07f0151816 --- /dev/null1817 +++ b/tests/inbox.rs1818 @@ -0,0 +1,629 @@1819 +//! Inbox core tests (m4, f0rge issue #9): one test per rule.1820 +//!1821 +//! Each server test spawns the real binary (`hu13 serve --listen1822 +//! 127.0.0.1:0`), learns the bound port from its `listening on` line, and1823 +//! talks raw HTTP over `std::net::TcpStream` (no extra deps, no curl1824 +//! binary needed — TcpStream *is* the curl here).1825 +1826 +use std::io::{BufRead, BufReader, Read, Write};1827 +use std::net::TcpStream;1828 +use std::os::unix::fs::PermissionsExt;1829 +use std::path::{Path, PathBuf};1830 +use std::process::{Child, Command, Stdio};1831 +use std::sync::atomic::{AtomicU64, Ordering};1832 +use std::time::{Duration, Instant};1833 +1834 +static COUNTER: AtomicU64 = AtomicU64::new(0);1835 +1836 +fn unique(prefix: &str) -> PathBuf {1837 +    let id = COUNTER.fetch_add(1, Ordering::SeqCst);1838 +    std::env::temp_dir().join(format!(1839 +        "hu13-inbox-{prefix}-{}-{id}-{}",1840 +        std::process::id(),1841 +        std::time::SystemTime::now()1842 +            .duration_since(std::time::UNIX_EPOCH)1843 +            .map(|d| d.as_nanos())1844 +            .unwrap_or(0)1845 +    ))1846 +}1847 +1848 +struct TmpDir(PathBuf);1849 +impl TmpDir {1850 +    fn fresh(prefix: &str) -> Self {1851 +        let p = unique(prefix);1852 +        std::fs::create_dir_all(&p).unwrap();1853 +        Self(p)1854 +    }1855 +    fn path(&self) -> &Path {1856 +        &self.01857 +    }1858 +}1859 +impl Drop for TmpDir {1860 +    fn drop(&mut self) {1861 +        std::fs::remove_dir_all(&self.0).ok();1862 +    }1863 +}1864 +1865 +fn bin() -> &'static str {1866 +    env!("CARGO_BIN_EXE_hu13")1867 +}1868 +1869 +struct Server {1870 +    child: Child,1871 +    addr: String,1872 +    _www: TmpDir,1873 +    inbox: TmpDir,1874 +}1875 +impl Server {1876 +    fn inbox_files(&self) -> Vec<PathBuf> {1877 +        let mut out: Vec<PathBuf> = std::fs::read_dir(self.inbox.path())1878 +            .unwrap()1879 +            .flatten()1880 +            .map(|e| e.path())1881 +            .filter(|p| p.is_file())1882 +            .collect();1883 +        out.sort();1884 +        out1885 +    }1886 +}1887 +impl Drop for Server {1888 +    fn drop(&mut self) {1889 +        self.child.kill().ok();1890 +        self.child.wait().ok();1891 +    }1892 +}1893 +1894 +/// Spawn `hu13 serve` on loopback port 0; parse the bound addr.1895 +fn start_server() -> Server {1896 +    let www = TmpDir::fresh("www");1897 +    let inbox = TmpDir::fresh("inbox");1898 +    std::fs::write(1899 +        www.path().join("index.html"),1900 +        "<!DOCTYPE html><title>hi</title><p>hi</p>",1901 +    )1902 +    .unwrap();1903 +    std::fs::write(www.path().join("style.css"), "body{color:#fff}").unwrap();1904 +    let mut child = Command::new(bin())1905 +        .arg("serve")1906 +        .arg("--www")1907 +        .arg(www.path())1908 +        .arg("--inbox-dir")1909 +        .arg(inbox.path())1910 +        .arg("--listen")1911 +        .arg("127.0.0.1:0")1912 +        .stdout(Stdio::piped())1913 +        .stderr(Stdio::piped())1914 +        .spawn()1915 +        .expect("spawn hu13 serve");1916 +    let stdout = child.stdout.take().expect("piped stdout");1917 +    let mut lines = BufReader::new(stdout).lines();1918 +    let mut addr = String::new();1919 +    for _ in 0..50 {1920 +        match lines.next() {1921 +            Some(Ok(line)) if line.contains("listening on ") => {1922 +                addr = line1923 +                    .rsplit("listening on ")1924 +                    .next()1925 +                    .unwrap_or("")1926 +                    .trim()1927 +                    .to_string();1928 +                break;1929 +            }1930 +            Some(Ok(_)) => continue,1931 +            _ => break,1932 +        }1933 +    }1934 +    assert!(!addr.is_empty(), "serve printed no `listening on` line");1935 +    assert!(addr.starts_with("127.0.0.1:"), "loopback only, got {addr}");1936 +    // Give the accept loop a breath (bind is done; accept is next).1937 +    std::thread::sleep(Duration::from_millis(100));1938 +    Server {1939 +        child,1940 +        addr,1941 +        _www: www,1942 +        inbox,1943 +    }1944 +}1945 +1946 +/// Send one raw request, read until the server closes (it always does:1947 +/// `Connection: close`).1948 +fn transact(addr: &str, req: &[u8]) -> String {1949 +    let mut s = TcpStream::connect(addr).expect("connect");1950 +    s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();1951 +    s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();1952 +    s.write_all(req).expect("write");1953 +    let mut out = Vec::new();1954 +    s.read_to_end(&mut out).expect("read");1955 +    String::from_utf8_lossy(&out).into_owned()1956 +}1957 +1958 +fn status_line(resp: &str) -> &str {1959 +    resp.split("\r\n").next().unwrap_or("")1960 +}1961 +1962 +fn resp_body(resp: &str) -> &str {1963 +    match resp.find("\r\n\r\n") {1964 +        Some(i) => &resp[i + 4..],1965 +        None => "",1966 +    }1967 +}1968 +1969 +/// urlencode (tests are the client here): alnum + `-_.~` bare, space →1970 +// `+`, everything else `%XX`.1971 +fn enc(s: &str) -> String {1972 +    let mut o = String::new();1973 +    for b in s.bytes() {1974 +        match b {1975 +            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {1976 +                o.push(b as char);1977 +            }1978 +            b' ' => o.push('+'),1979 +            _ => o.push_str(&format!("%{b:02X}")),1980 +        }1981 +    }1982 +    o1983 +}1984 +1985 +fn post(path: &str, ctype: &str, body: &[u8]) -> Vec<u8> {1986 +    format!(1987 +        "POST {path} HTTP/1.1\r\nHost: x\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",1988 +        body.len()1989 +    )1990 +    .into_bytes()1991 +    .into_iter()1992 +    .chain(body.iter().copied())1993 +    .collect()1994 +}1995 +1996 +const URLENCODED: &str = "application/x-www-form-urlencoded";1997 +1998 +fn valid_issue_body(marker: &str) -> String {1999 +    format!(2000 +        "title={}&body={}&handle={}&contact={}&license=agree&website=",2001 +        enc(&format!("issue title {marker}")),2002 +        enc(&format!("issue body {marker} <b>bold</b>")),2003 +        enc("tester"),2004 +        enc("[email protected]"),2005 +    )2006 +}2007 +2008 +fn extract_ack(body: &str) -> String {2009 +    // `ack: <code>UUID</code>` — pull the 36-char uuid out.2010 +    let code = body.find("<code>").expect("ack code tag") + 6;2011 +    let end = body[code..].find("</code>").expect("ack close tag") + code;2012 +    body[code..end].to_string()2013 +}2014 +2015 +fn is_uuid(s: &str) -> bool {2016 +    s.len() == 362017 +        && s.as_bytes().iter().enumerate().all(|(i, &b)| {2018 +            if [8, 13, 18, 23].contains(&i) {2019 +                b == b'-'2020 +            } else {2021 +                b.is_ascii_hexdigit()2022 +            }2023 +        })2024 +}2025 +2026 +// ---- the rules, one test each ------------------------------------------2027 +2028 +#[test]2029 +fn valid_issue_stores_0600_json_ack_shown_nothing_echoed() {2030 +    let srv = start_server();2031 +    let marker = format!("MKR-{}-{}", std::process::id(), unique("m").display());2032 +    let t0 = Instant::now();2033 +    let resp = transact(2034 +        &srv.addr,2035 +        &post(2036 +            "/inbox/issue",2037 +            URLENCODED,2038 +            valid_issue_body(&marker).as_bytes(),2039 +        ),2040 +    );2041 +    let elapsed = t0.elapsed();2042 +    assert!(status_line(&resp).contains("200"), "got: {resp}");2043 +    assert!(resp.contains("received"), "received page");2044 +    assert!(2045 +        elapsed >= Duration::from_millis(350),2046 +        "fixed ~500ms delay present (took {elapsed:?})"2047 +    );2048 +    let ack = extract_ack(resp_body(&resp));2049 +    assert!(is_uuid(&ack), "ack is a uuid, got {ack}");2050 +    assert!(!resp.contains(&marker), "ZERO reflection of input");2051 +2052 +    let files = srv.inbox_files();2053 +    assert_eq!(files.len(), 1, "exactly one file stored");2054 +    assert_eq!(2055 +        files[0].file_stem().unwrap().to_string_lossy(),2056 +        ack,2057 +        "ack uuid == filename uuid"2058 +    );2059 +    #[cfg(unix)]2060 +    assert_eq!(2061 +        std::fs::metadata(&files[0]).unwrap().permissions().mode() & 0o777,2062 +        0o600,2063 +        "mode 0600"2064 +    );2065 +    let v: serde_json::Value =2066 +        serde_json::from_str(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();2067 +    assert_eq!(v["type"], "issue");2068 +    assert_eq!(v["title"], format!("issue title {marker}"));2069 +    assert_eq!(v["body"], format!("issue body {marker} <b>bold</b>"));2070 +    assert_eq!(v["handle"], "tester");2071 +    assert_eq!(v["contact"], "[email protected]");2072 +    assert_eq!(v["license_version"], "v1");2073 +    assert!(!v["received_at"].as_str().unwrap_or("").is_empty());2074 +    let hash = v["salted_ip_hash"].as_str().unwrap_or("");2075 +    assert_eq!(hash.len(), 64, "hex sha256");2076 +    assert!(!hash.contains('.'), "no dotted quad on disk");2077 +    assert!(!std::fs::read_to_string(&files[0])2078 +        .unwrap()2079 +        .contains("127.0.0.1"));2080 +}2081 +2082 +#[test]2083 +fn valid_pr_prompt_stores_with_prompt_field() {2084 +    let srv = start_server();2085 +    let marker = format!("PRM-{}-{}", std::process::id(), unique("m").display());2086 +    let body = format!(2087 +        "title={}&prompt={}&license=on&website=",2088 +        enc(&format!("pr title {marker}")),2089 +        enc(&format!("ONE prompt {marker}")),2090 +    );2091 +    let resp = transact(2092 +        &srv.addr,2093 +        &post("/inbox/pr-prompt", URLENCODED, body.as_bytes()),2094 +    );2095 +    assert!(status_line(&resp).contains("200"), "got: {resp}");2096 +    assert!(!resp.contains(&marker), "no reflection");2097 +    let files = srv.inbox_files();2098 +    assert_eq!(files.len(), 1);2099 +    let v: serde_json::Value =2100 +        serde_json::from_str(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();2101 +    assert_eq!(v["type"], "pr-prompt");2102 +    assert_eq!(v["body"], format!("ONE prompt {marker}"));2103 +}2104 +2105 +#[test]2106 +fn unchecked_license_is_400_and_stores_nothing() {2107 +    let srv = start_server();2108 +    let body = format!("title={}&body={}&website=", enc("t"), enc("b"));2109 +    let resp = transact(2110 +        &srv.addr,2111 +        &post("/inbox/issue", URLENCODED, body.as_bytes()),2112 +    );2113 +    assert!(status_line(&resp).contains("400"), "got: {resp}");2114 +    assert!(srv.inbox_files().is_empty());2115 +}2116 +2117 +#[test]2118 +fn multipart_is_400_and_stores_nothing() {2119 +    let srv = start_server();2120 +    let body = "--xyz\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nt\r\n--xyz--\r\n";2121 +    let resp = transact(2122 +        &srv.addr,2123 +        &post(2124 +            "/inbox/issue",2125 +            "multipart/form-data; boundary=xyz",2126 +            body.as_bytes(),2127 +        ),2128 +    );2129 +    assert!(status_line(&resp).contains("400"), "got: {resp}");2130 +    assert!(srv.inbox_files().is_empty());2131 +}2132 +2133 +#[test]2134 +fn oversize_is_413_and_stores_nothing() {2135 +    let srv = start_server();2136 +    let big = "b".repeat(33 * 1024);2137 +    let body = format!(2138 +        "title={}&body={}&license=agree&website=",2139 +        enc("t"),2140 +        enc(&big)2141 +    );2142 +    assert!(body.len() > 32 * 1024);2143 +    let resp = transact(2144 +        &srv.addr,2145 +        &post("/inbox/issue", URLENCODED, body.as_bytes()),2146 +    );2147 +    assert!(2148 +        status_line(&resp).contains("413"),2149 +        "got: {}",2150 +        &resp[..resp.len().min(200)]2151 +    );2152 +    assert!(srv.inbox_files().is_empty());2153 +}2154 +2155 +#[test]2156 +fn nul_byte_is_400_and_stores_nothing() {2157 +    let srv = start_server();2158 +    let body = "title=a%00b&body=c&license=agree&website=";2159 +    let resp = transact(2160 +        &srv.addr,2161 +        &post("/inbox/issue", URLENCODED, body.as_bytes()),2162 +    );2163 +    assert!(status_line(&resp).contains("400"), "got: {resp}");2164 +    assert!(srv.inbox_files().is_empty());2165 +}2166 +2167 +#[test]2168 +fn invalid_utf8_is_400_and_stores_nothing() {2169 +    let srv = start_server();2170 +    let mut body = b"title=t&body=".to_vec();2171 +    body.extend_from_slice(&[0xff, 0xfe, b'x']);2172 +    body.extend_from_slice(b"&license=agree&website=");2173 +    let resp = transact(&srv.addr, &post("/inbox/issue", URLENCODED, &body));2174 +    assert!(status_line(&resp).contains("400"), "got: {resp}");2175 +    assert!(srv.inbox_files().is_empty());2176 +}2177 +2178 +#[test]2179 +fn overlong_title_is_400() {2180 +    let srv = start_server();2181 +    let body = format!(2182 +        "title={}&body={}&license=agree&website=",2183 +        enc(&"t".repeat(301)),2184 +        enc("b")2185 +    );2186 +    let resp = transact(2187 +        &srv.addr,2188 +        &post("/inbox/issue", URLENCODED, body.as_bytes()),2189 +    );2190 +    assert!(status_line(&resp).contains("400"), "got: {resp}");2191 +    assert!(srv.inbox_files().is_empty());2192 +}2193 +2194 +#[test]2195 +fn honeypot_filled_says_received_but_stores_zero_files() {2196 +    let srv = start_server();2197 +    let marker = format!("HON-{}-{}", std::process::id(), unique("m").display());2198 +    let body = format!(2199 +        "title={}&body={}&license=agree&website=bot-{marker}",2200 +        enc("t"),2201 +        enc("b"),2202 +    );2203 +    let resp = transact(2204 +        &srv.addr,2205 +        &post("/inbox/issue", URLENCODED, body.as_bytes()),2206 +    );2207 +    assert!(2208 +        status_line(&resp).contains("200"),2209 +        "fake received, got: {resp}"2210 +    );2211 +    assert!(resp_body(&resp).contains("received"));2212 +    assert!(!resp.contains(&marker), "no reflection even on drop");2213 +    assert!(srv.inbox_files().is_empty(), "honeypot stores nothing");2214 +}2215 +2216 +#[test]2217 +fn controls_stripped_except_newline_tab() {2218 +    let srv = start_server();2219 +    let body = "title=a%01b%09c%0Ad&body=x%0Dy&license=agree&website=";2220 +    let resp = transact(2221 +        &srv.addr,2222 +        &post("/inbox/issue", URLENCODED, body.as_bytes()),2223 +    );2224 +    assert!(status_line(&resp).contains("200"), "got: {resp}");2225 +    let files = srv.inbox_files();2226 +    assert_eq!(files.len(), 1);2227 +    let v: serde_json::Value =2228 +        serde_json::from_str(&std::fs::read_to_string(&files[0]).unwrap()).unwrap();2229 +    assert_eq!(2230 +        v["title"], "ab\tc\nd",2231 +        "controls stripped, tab+newline kept"2232 +    );2233 +    assert_eq!(v["body"], "xy", "CR stripped");2234 +}2235 +2236 +#[test]2237 +fn wrong_method_on_routes_is_405() {2238 +    let srv = start_server();2239 +    for method in ["GET", "PUT", "DELETE"] {2240 +        for path in ["/inbox/issue", "/inbox/pr-prompt"] {2241 +            let req = format!("{method} {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");2242 +            let resp = transact(&srv.addr, req.as_bytes());2243 +            assert!(2244 +                status_line(&resp).contains("405"),2245 +                "{method} {path}: got: {resp}"2246 +            );2247 +        }2248 +    }2249 +    assert!(srv.inbox_files().is_empty());2250 +}2251 +2252 +#[test]2253 +fn static_serves_nosniff_and_never_lists() {2254 +    let srv = start_server();2255 +    let resp = transact(2256 +        &srv.addr,2257 +        b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",2258 +    );2259 +    assert!(status_line(&resp).contains("200"), "got: {resp}");2260 +    assert!(resp.contains("X-Content-Type-Options: nosniff"));2261 +    assert!(resp.contains("hi"));2262 +    let missing = transact(2263 +        &srv.addr,2264 +        b"GET /nope.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",2265 +    );2266 +    assert!(status_line(&missing).contains("404"), "got: {missing}");2267 +    assert!(missing.contains("X-Content-Type-Options: nosniff"));2268 +    // Traversal + odd paths never escape www.2269 +    for target in ["/..%2f..%2fetc%2fpasswd", "/%00", "/inbox/issue/"] {2270 +        let req = format!("GET {target} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");2271 +        let r = transact(&srv.addr, req.as_bytes());2272 +        assert!(2273 +            status_line(&r).contains("404") || status_line(&r).contains("400"),2274 +            "{target}: got: {r}"2275 +        );2276 +        assert!(!r.contains("root:"), "no file contents leak");2277 +    }2278 +    // Inbox files are unreachable over HTTP (inbox lives outside www, and2279 +    // no route serves it).2280 +    let files = srv.inbox_files();2281 +    assert!(files.is_empty());2282 +}2283 +2284 +#[test]2285 +fn inbox_inside_www_refuses_to_start() {2286 +    let www = TmpDir::fresh("wwwref");2287 +    std::fs::write(www.path().join("index.html"), "<p>hi</p>").unwrap();2288 +    let inside = www.path().join("inbox");2289 +    let mut child = Command::new(bin())2290 +        .arg("serve")2291 +        .arg("--www")2292 +        .arg(www.path())2293 +        .arg("--inbox-dir")2294 +        .arg(&inside)2295 +        .arg("--listen")2296 +        .arg("127.0.0.1:0")2297 +        .stdout(Stdio::piped())2298 +        .stderr(Stdio::piped())2299 +        .spawn()2300 +        .expect("spawn");2301 +    let mut exited = None;2302 +    for _ in 0..100 {2303 +        if let Ok(Some(st)) = child.try_wait() {2304 +            exited = Some(st);2305 +            break;2306 +        }2307 +        std::thread::sleep(Duration::from_millis(50));2308 +    }2309 +    match exited {2310 +        Some(st) => assert!(!st.success(), "refusal must exit non-zero"),2311 +        None => {2312 +            child.kill().ok();2313 +            panic!("serve started despite inbox inside www");2314 +        }2315 +    }2316 +}2317 +2318 +#[test]2319 +fn inbox_list_and_show_escape_terminal_output() {2320 +    let inbox = TmpDir::fresh("evil");2321 +    let evil_title = "hi\x1b[31mRED\x1b[0m\x07<script>alert(1)</script>\u{202e}RTL";2322 +    let doc = serde_json::json!({2323 +        "type": "issue",2324 +        "title": evil_title,2325 +        "body": "line1\r\nline2\x00stripped?",2326 +        "handle": "h",2327 +        "contact": "c",2328 +        "license_version": "v1",2329 +        "received_at": "2026-09-10T00:00:00Z",2330 +        "salted_ip_hash": "ab".repeat(32),2331 +    });2332 +    let id = "11111111-2222-4333-8555-666666666666";2333 +    std::fs::write(2334 +        inbox.path().join(format!("{id}.json")),2335 +        serde_json::to_string(&doc).unwrap(),2336 +    )2337 +    .unwrap();2338 +2339 +    let run = |args: &[&str]| -> (String, bool) {2340 +        let out = Command::new(bin())2341 +            .args(args)2342 +            .arg("--inbox-dir")2343 +            .arg(inbox.path())2344 +            .output()2345 +            .expect("run hu13 inbox");2346 +        (2347 +            String::from_utf8_lossy(&out.stdout).into_owned(),2348 +            out.status.success(),2349 +        )2350 +    };2351 +    let (list, ok) = run(&["inbox", "list"]);2352 +    assert!(ok, "list exits 0");2353 +    assert!(!list.contains('\x1b'), "no raw ESC in list");2354 +    assert!(!list.contains('\x07'), "no raw BEL in list");2355 +    assert!(2356 +        list.contains("\\x1b[31mRED\\x1b[0m"),2357 +        "ESC escaped, got: {list}"2358 +    );2359 +    assert!(2360 +        list.contains("<script>alert(1)</script>"),2361 +        "plain text stays text"2362 +    );2363 +    assert!(list.contains(id), "uuid listed");2364 +2365 +    let (show, ok) = run(&["inbox", "show", id]);2366 +    assert!(ok, "show exits 0");2367 +    assert!(!show.contains('\x1b'), "no raw ESC in show");2368 +    assert!(show.contains("\\x1b"), "ESC escaped in show");2369 +    assert!(show.contains("\\u{202e}"), "bidi escaped in show");2370 +    assert!(show.contains("\\x0d"), "CR escaped in show");2371 +    assert!(2372 +        show.contains("<script>alert(1)</script>"),2373 +        "plain text stays text"2374 +    );2375 +2376 +    // Traversal ids are rejected, never opened as paths.2377 +    let out = Command::new(bin())2378 +        .arg("inbox")2379 +        .arg("show")2380 +        .arg("../evil")2381 +        .arg("--inbox-dir")2382 +        .arg(inbox.path())2383 +        .output()2384 +        .expect("run");2385 +    assert!(!out.status.success(), "bad id rejected");2386 +}2387 +2388 +#[test]2389 +fn build_emits_forms_with_license_honeypot_actions_no_js() {2390 +    // Minimal fixture repo.2391 +    let repo = TmpDir::fresh("repo");2392 +    let git = |args: &[&str]| {2393 +        let st = Command::new("git")2394 +            .arg("-C")2395 +            .arg(repo.path())2396 +            .args(args)2397 +            .status()2398 +            .expect("spawn git");2399 +        assert!(st.success(), "git {args:?} failed");2400 +    };2401 +    git(&["init", "-b", "main"]);2402 +    git(&["config", "user.name", "hu13-test"]);2403 +    git(&["config", "user.email", "[email protected]"]);2404 +    git(&["config", "commit.gpgsign", "false"]);2405 +    std::fs::write(repo.path().join("README.md"), "# forms fixture\n").unwrap();2406 +    git(&["add", "-A"]);2407 +    git(&["commit", "-m", "init"]);2408 +    let out = unique("dist");2409 +    let st = Command::new(bin())2410 +        .arg("build")2411 +        .arg(repo.path())2412 +        .arg("-o")2413 +        .arg(&out)2414 +        .status()2415 +        .expect("spawn hu13 build");2416 +    assert!(st.success(), "build exits 0");2417 +    for (file, action, field) in [2418 +        ("new-issue.html", "/inbox/issue", "name=\"body\""),2419 +        ("new-pr.html", "/inbox/pr-prompt", "name=\"prompt\""),2420 +    ] {2421 +        let html = std::fs::read_to_string(out.join(file)).expect(file);2422 +        assert!(2423 +            html.contains(&format!("action=\"{action}\"")),2424 +            "{file} posts to {action}"2425 +        );2426 +        assert!(html.contains(field), "{file} has {field}");2427 +        assert!(html.contains("name=\"title\""), "{file} has title");2428 +        assert!(html.contains("name=\"handle\""), "{file} has handle");2429 +        assert!(html.contains("name=\"contact\""), "{file} has contact");2430 +        assert!(2431 +            html.contains("name=\"website\""),2432 +            "{file} honeypot named website"2433 +        );2434 +        assert!(html.contains("name=\"license\""), "{file} license checkbox");2435 +        assert!(2436 +            html.contains("By submitting this text you irrevocably give it to this project."),2437 +            "{file} exact license text"2438 +        );2439 +        assert!(2440 +            html.contains("If you cannot agree\nto that, do not submit.")2441 +                || html.contains("If you cannot agree"),2442 +            "{file} license tail"2443 +        );2444 +        assert!(!html.contains("<script"), "{file} zero JS");2445 +    }2446 +    std::fs::remove_dir_all(&out).ok();2447 +}2448 -- 2449 2.55.02450