src/inbox.rs 41.4 KiB 1173 lines Raw
1 //! Inbox core (m4, f0rge issue #9): the ONLY public write surface.2 //!3 //! Two dumb text-only mailboxes (`issue` = title+body, `pr-prompt` =4 //! title+prompt). Hostile input by design: free text from strangers that is5 //! never rendered to HTML, never logged, never executed, never in PATH.6 //! Storage is `<uuid4>.json` (server-named, mode `0600`) under the inbox7 //! dir; the only read path is the local `hu13 inbox list|show` (escaped8 //! plain text to the terminal) or direct filesystem/SSH access.9 //!10 //! Hashing choice (std-only, no new deps per the hostile-surface rule):11 //! `salted_ip_hash` = hex(SHA-256(`"v1|" + ip + "|" + yyyy-mm-dd + "|" +12 //! server_salt`)). SHA-256 is vendored below (~60 lines) because `std`13 //! ships no SHA and no fixed-key SipHash; the daily date rotates the salt14 //! and a per-startup random `server_salt` keeps restarts unlinkable.15 //! UUIDs come from `/dev/urandom` (never from user input, never in a path16 //! except as the generated name).17 18 use std::path::Path;19 20 use crate::layout::{esc, page, page_site_with_provenance};21 22 /// Max chars for `title` (both kinds).23 pub const MAX_TITLE_CHARS: usize = 300;24 /// Max chars for `body` / `prompt`.25 pub const MAX_BODY_CHARS: usize = 20000;26 /// Max chars for optional `handle`.27 pub const MAX_HANDLE_CHARS: usize = 40;28 /// Max chars for optional `contact`.29 pub const MAX_CONTACT_CHARS: usize = 100;30 /// Max whole POST body bytes (else `413`).31 pub const MAX_REQUEST_BYTES: usize = 32 * 1024;32 /// Per-connection read timeout, seconds (else drop).33 pub const POST_READ_TIMEOUT_SECS: u64 = 5;34 /// Fixed delay before answering any inbox-route response, ms (no oracle).35 pub const ACK_DELAY_MS: u64 = 500;36 /// Stored `license_version` for every accepted submission.37 pub const LICENSE_VERSION: &str = "v1";38 39 /// Abuse controls (m4-harden, issue #10): per-IP token bucket + global40 /// quota + tunables. Flags/env only, no config file (see `hu13 serve41 /// --help`).42 /// Per-IP bucket capacity per hour (default; `--rate-per-hour` /43 /// `HU13_RATE_PER_HOUR` overrides per serve run).44 pub const RATE_PER_HOUR: u32 = 2;45 /// Global quota default (files; `--quota` / `HU13_QUOTA` overrides).46 pub const QUOTA_FILES: usize = 500;47 48 /// Per-IP token bucket (sliding 1h window, capacity = `limit_per_hour`).49 /// Keys are daily-salted hashes only (`salted_ip_hash(ip, date, salt)`)50 /// — plaintext IPs never touch disk or logs; memory holds hashes +51 /// timestamps. A per-startup random `server_salt` keeps restarts52 /// unlinkable; the `yyyy-mm-dd` date rotates the key daily.53 /// `429` when the bucket is empty.54 pub struct RateLimiter {55     limit_per_hour: u32,56     state: std::sync::Mutex<RateState>,57 }58 59 /// Seconds between full-map sweeps. Buckets are also pruned on access,60 /// but only the accessed key; without a sweep, source-IP rotation grows61 /// the map without bound (f0rge #65).62 pub const RATE_SWEEP_SECS: u64 = 300;63 64 /// Hard cap on distinct keys between sweeps. At the cap a new key is65 /// rejected (`429`) rather than inserted, so memory stays bounded even66 /// under a flood (fail closed).67 pub const RATE_MAX_KEYS: usize = 100_000;68 69 struct RateState {70     buckets: std::collections::HashMap<String, Vec<u64>>,71     last_sweep: u64,72 }73 74 impl RateLimiter {75     #[must_use]76     pub fn new(limit_per_hour: u32) -> Self {77         Self {78             limit_per_hour,79             state: std::sync::Mutex::new(RateState {80                 buckets: std::collections::HashMap::new(),81                 last_sweep: 0,82             }),83         }84     }85 86     fn check_at(&self, ip: &str, date: &str, server_salt: &str, now: u64) -> bool {87         let key = salted_ip_hash(ip, date, server_salt);88         let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());89         if now.saturating_sub(st.last_sweep) >= RATE_SWEEP_SECS {90             st.buckets.retain(|_, times| {91                 times.retain(|&t| now.wrapping_sub(t) < 3600);92                 !times.is_empty()93             });94             st.last_sweep = now;95         }96         if !st.buckets.contains_key(&key) && st.buckets.len() >= RATE_MAX_KEYS {97             return false;98         }99         let entry = st.buckets.entry(key).or_default();100         entry.retain(|&t| now.wrapping_sub(t) < 3600);101         if (entry.len() as u32) >= self.limit_per_hour {102             return false;103         }104         entry.push(now);105         true106     }107 108     /// Record one accepted POST from `ip`; `false` = over budget (`429`).109     #[must_use]110     pub fn check(&self, ip: &str, server_salt: &str) -> bool {111         self.check_at(ip, &utc_today(), server_salt, unix_secs())112     }113 }114 115 /// Count stored files (any regular file counts toward the quota).116 #[must_use]117 pub fn count_inbox_files(inbox_dir: &Path) -> usize {118     match std::fs::read_dir(inbox_dir) {119         Ok(rd) => rd.flatten().filter(|e| e.path().is_file()).count(),120         Err(_) => 0,121     }122 }123 124 /// `true` = room left (files < quota); `false` = full (`503` until a125 /// human drains the dir by hand).126 #[must_use]127 pub fn check_quota_with(inbox_dir: &Path, quota: usize) -> bool {128     count_inbox_files(inbox_dir) < quota129 }130 131 // `Origin`/`Referer` check + `SameSite=Lax` live at the HTTP layer132 // (`serve.rs`): we set no cookies and send no CORS headers, so `SameSite`133 // is vacuously satisfied (no `Set-Cookie` ever); the Origin gate fails134 // closed on mismatched browser Origins while curl (no Origin) passes.135 136 /// License grant — EXACT final text from `DRAFT.md` (receiver-friendly v1).137 /// The forms, the agents page, and storage all derive from this one138 /// constant (deferred owner re-read pre-v1 tracks wording drift).139 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.";140 141 /// Checkbox values accepted as "license checked". Browsers send `on` by142 /// default; our forms + agents-page curl examples send `agree`.143 fn license_checked(value: Option<&str>) -> bool {144     match value.map(|v| v.trim().to_ascii_lowercase()) {145         Some(v) => matches!(146             v.as_str(),147             "on" | "agree" | "yes" | "true" | "1" | "checked" | "accept"148         ),149         None => false,150     }151 }152 153 /// Which of the two mailboxes a submission targets.154 #[derive(Debug, Clone, Copy, PartialEq, Eq)]155 pub enum InboxType {156     Issue,157     PrPrompt,158 }159 160 impl InboxType {161     #[must_use]162     pub fn as_str(self) -> &'static str {163         match self {164             InboxType::Issue => "issue",165             InboxType::PrPrompt => "pr-prompt",166         }167     }168 169     /// Body field name on the wire: `body` for issues, `prompt` for170     /// pr-prompts (a `body` alias is accepted for robustness).171     #[must_use]172     pub fn body_field(self) -> &'static str {173         match self {174             InboxType::Issue => "body",175             InboxType::PrPrompt => "prompt",176         }177     }178 179     #[must_use]180     pub fn route(self) -> &'static str {181         match self {182             InboxType::Issue => "/inbox/issue",183             InboxType::PrPrompt => "/inbox/pr-prompt",184         }185     }186 }187 188 /// Validated, control-stripped submission, ready to store.189 #[derive(Debug, Clone)]190 pub struct Validated {191     pub kind: InboxType,192     pub title: String,193     pub body: String,194     pub handle: String,195     pub contact: String,196 }197 198 /// Outcome of [`validate_form`]: store it, fake it (honeypot), or reject.199 #[derive(Debug)]200 pub enum Validation {201     /// Real submission — store + ack with the file uuid.202     Accept(Validated),203     /// Honeypot filled — say `received` with a fresh uuid, store nothing.204     Honeypot,205     /// Fail closed with this HTTP status (400/413). Generic page, no echo.206     Reject(u16),207 }208 209 /// Percent-decode one `application/x-www-form-urlencoded` component210 /// (`+` = space). Rejects malformed `%` escapes and non-UTF-8.211 fn urldecode_component(s: &str) -> Option<String> {212     let mut bytes: Vec<u8> = Vec::with_capacity(s.len());213     let mut it = s.as_bytes().iter().peekable();214     while let Some(&b) = it.next() {215         match b {216             b'+' => bytes.push(b' '),217             b'%' => {218                 let hi = *it.next()?;219                 let lo = *it.next()?;220                 let hex = |c: u8| -> Option<u8> {221                     match c {222                         b'0'..=b'9' => Some(c - b'0'),223                         b'a'..=b'f' => Some(c - b'a' + 10),224                         b'A'..=b'F' => Some(c - b'A' + 10),225                         _ => None,226                     }227                 };228                 bytes.push((hex(hi)? << 4) | hex(lo)?);229             }230             _ => bytes.push(b),231         }232     }233     String::from_utf8(bytes).ok()234 }235 236 /// Parse a urlencoded body into ordered pairs. `None` = malformed237 /// (bad `%` escape or non-UTF-8) → caller answers `400`.238 pub fn parse_form(body: &str) -> Option<Vec<(String, String)>> {239     if body.is_empty() {240         return Some(Vec::new());241     }242     let mut out = Vec::new();243     for pair in body.split('&') {244         let (k, v) = match pair.split_once('=') {245             Some((k, v)) => (k, v),246             None => (pair, ""),247         };248         out.push((urldecode_component(k)?, urldecode_component(v)?));249     }250     Some(out)251 }252 253 /// First occurrence of `name` in parsed pairs.254 fn field<'a>(pairs: &'a [(String, String)], name: &str) -> Option<&'a str> {255     pairs256         .iter()257         .find(|(k, _)| k == name)258         .map(|(_, v)| v.as_str())259 }260 261 /// `true` when the honeypot is filled (either the canonical `website`262 /// field or the legacy `honeypot` alias — any non-empty value).263 fn honeypot_filled(pairs: &[(String, String)]) -> bool {264     field(pairs, "website").is_some_and(|v| !v.is_empty())265         || field(pairs, "honeypot").is_some_and(|v| !v.is_empty())266 }267 268 /// Strip control chars except `\n` and `\t`. Callers must reject NUL269 /// *before* this (NUL would otherwise vanish instead of failing closed).270 #[must_use]271 pub fn strip_controls(s: &str) -> String {272     s.chars()273         .filter(|&c| c == '\n' || c == '\t' || !c.is_control())274         .collect()275 }276 277 fn char_len_ok(s: &str, max: usize) -> bool {278     s.chars().count() <= max279 }280 281 /// Validate parsed pairs for `kind` with tuned caps: honeypot →282 /// [`Validation::Honeypot`]; bad input → `Reject(400)`; oversize whole283 /// shape is enforced by the HTTP layer (`413`) before we are called.284 #[must_use]285 pub fn validate_form_with_limits(286     kind: InboxType,287     pairs: &[(String, String)],288     max_title: usize,289     max_body: usize,290 ) -> Validation {291     if honeypot_filled(pairs) {292         return Validation::Honeypot;293     }294     // Fail closed on NUL anywhere (checked before stripping, or it would295     // silently disappear).296     if pairs297         .iter()298         .any(|(k, v)| k.contains('\0') || v.contains('\0'))299     {300         return Validation::Reject(400);301     }302     let raw_title = field(pairs, "title").unwrap_or("");303     // Primary body field per kind (`prompt` for pr-prompts, with a `body`304     // alias for robustness); issues take `body`.305     let raw_body = field(pairs, kind.body_field())306         .or_else(|| {307             if kind == InboxType::PrPrompt {308                 field(pairs, "body")309             } else {310                 None311             }312         })313         .unwrap_or("");314     let raw_handle = field(pairs, "handle").unwrap_or("");315     let raw_contact = field(pairs, "contact").unwrap_or("");316     let title = strip_controls(raw_title);317     let body = strip_controls(raw_body);318     let handle = strip_controls(raw_handle);319     let contact = strip_controls(raw_contact);320     if title.trim().is_empty() || body.trim().is_empty() {321         return Validation::Reject(400);322     }323     if !char_len_ok(&title, max_title)324         || !char_len_ok(&body, max_body)325         || !char_len_ok(&handle, MAX_HANDLE_CHARS)326         || !char_len_ok(&contact, MAX_CONTACT_CHARS)327     {328         return Validation::Reject(400);329     }330     if !license_checked(field(pairs, "license")) {331         return Validation::Reject(400);332     }333     Validation::Accept(Validated {334         kind,335         title,336         body,337         handle,338         contact,339     })340 }341 342 /// Validate parsed pairs for `kind` with default caps (wrapper over343 /// [`validate_form_with_limits`]). Used by unit tests + external344 /// callers; the POST path threads tuned caps via `with_limits`.345 #[allow(dead_code)]346 #[must_use]347 pub fn validate_form(kind: InboxType, pairs: &[(String, String)]) -> Validation {348     validate_form_with_limits(kind, pairs, MAX_TITLE_CHARS, MAX_BODY_CHARS)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 id=\"license-full\"><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 #[allow(clippy::too_many_arguments)]637 fn form_page(638     site_name: &str,639     nav: &str,640     heading: &str,641     intro: &str,642     action: &str,643     body_name: &str,644     body_label: &str,645     branch: &str,646     short_sha: &str,647     description: Option<&str>,648 ) -> String {649     let body = format!(650         "<div class=\"page-head\"><h1>{heading}</h1></div>\n<p>{intro}</p>\n\651          <p>What happens: your text goes to a private inbox only the maintainer reads — \652          never published on this site. No account needed. The server answers a one-time \653          ack id and shows nothing back.</p>\n\654          <form method=\"post\" action=\"{action}\">\n\655          <p><label for=\"f-title\">Title (required, up to 300 chars)</label><br>\n\656          <input type=\"text\" id=\"f-title\" name=\"title\" maxlength=\"300\" required size=\"60\"></p>\n\657          <p><label for=\"f-body\">{body_label} (required, up to 20000 chars)</label><br>\n\658          <textarea id=\"f-body\" name=\"{body_name}\" rows=\"12\" cols=\"72\" maxlength=\"20000\" required></textarea></p>\n\659          <p><label for=\"f-handle\">Handle (optional, up to 40 chars)</label><br>\n\660          <input type=\"text\" id=\"f-handle\" name=\"handle\" maxlength=\"40\" size=\"40\" autocomplete=\"off\"></p>\n\661          <p><label for=\"f-contact\">Contact (optional, up to 100 chars)</label><br>\n\662          <input type=\"text\" id=\"f-contact\" name=\"contact\" maxlength=\"100\" size=\"60\" autocomplete=\"off\"></p>\n\663          <div style=\"display:none\" aria-hidden=\"true\">\n\664          <p><label>Leave this empty: <input type=\"text\" name=\"website\" value=\"\" autocomplete=\"off\" tabindex=\"-1\"></label></p>\n\665          </div>\n\666          {license}\667          <p>In plain words: you hand your text over completely — the project can keep, share, \668          or change it for any purpose, and you keep no rights and are owed nothing. \669          See the <a href=\"#license-full\">full grant text</a> above.</p>\n\670          <p><label><input type=\"checkbox\" name=\"license\" value=\"agree\" required> \671          I agree to the grant above (required — unchecked submissions are rejected).</label></p>\n\672          <p><button type=\"submit\">Submit</button></p>\n\673          </form>\n\674          <p class=\"muted\">Plain POST, no JavaScript. The server answers a one-time ack id \675          and reflects nothing back. Inbox contents never appear on this site.</p>\n",676         heading = esc(heading),677         intro = esc(intro),678         action = esc(action),679         body_label = esc(body_label),680         license = license_block(),681     );682     page_site_with_provenance(683         &format!("{heading} — {site_name}"),684         nav,685         &body,686         site_name,687         description,688         branch,689         short_sha,690     )691 }692 693 /// Write `new-issue.html` + `new-pr.html` into the built site.694 pub fn render_forms(695     out: &Path,696     site_name: &str,697     nav: &str,698     branch: &str,699     short_sha: &str,700     description: Option<&str>,701 ) -> Result<(), String> {702     let issue = form_page(703         site_name,704         nav,705         "Suggest an issue",706         "Spotted something worth fixing? Write it up here in plain words — say what a maintainer can act on.",707         InboxType::Issue.route(),708         "body",709         "Body",710         branch,711         short_sha,712         description,713     );714     let pr = form_page(715         site_name,716         nav,717         "Suggest a change",718         "Have an idea for a fix? Send ONE prompt as plain text — no files, no diff, no branch. Code words inside stay text: never applied, never executed.",719         InboxType::PrPrompt.route(),720         "prompt",721         "Prompt",722         branch,723         short_sha,724         description,725     );726     std::fs::write(out.join("new-issue.html"), issue)727         .map_err(|e| format!("hu13: write new-issue.html: {e}"))?;728     std::fs::write(out.join("new-pr.html"), pr)729         .map_err(|e| format!("hu13: write new-pr.html: {e}"))?;730     Ok(())731 }732 733 // ---- terminal escaping + local read commands ---------------------------734 735 /// Escape a string for plain-text terminal output: ANSI introducers die736 /// with the ESC byte (`\x1b`), other C0/C1 controls + DEL become `\xNN` /737 /// `\u{...}`, bidi/format trickery (`U+202A–U+202E`, `U+2066–U+2069`,738 /// `U+200B–U+200F`, `U+FEFF`, `U+00AD`) becomes `\u{...}`. `\n` and `\t`739 /// pass through so entries stay readable.740 #[must_use]741 pub fn escape_terminal(s: &str) -> String {742     let mut out = String::with_capacity(s.len());743     for c in s.chars() {744         match c {745             '\n' | '\t' => out.push(c),746             '\u{1b}' => out.push_str("\\x1b"),747             c if c.is_control() => {748                 let n = c as u32;749                 if n < 0x100 {750                     out.push_str(&format!("\\x{n:02x}"));751                 } else {752                     out.push_str(&format!("\\u{{{n:x}}}"));753                 }754             }755             '\u{200b}'..='\u{200f}'756             | '\u{202a}'..='\u{202e}'757             | '\u{2066}'..='\u{2069}'758             | '\u{00ad}'759             | '\u{feff}' => {760                 out.push_str(&format!("\\u{{{:x}}}", c as u32));761             }762             _ => out.push(c),763         }764     }765     out766 }767 768 /// One stored submission, as read back locally.769 #[derive(Debug, Clone)]770 pub struct Entry {771     pub id: String,772     pub kind: String,773     pub title: String,774     pub body: String,775     pub handle: String,776     pub contact: String,777     pub license_version: String,778     pub received_at: String,779     pub salted_ip_hash: String,780 }781 782 fn read_entry(path: &Path) -> Option<Entry> {783     let text = std::fs::read_to_string(path).ok()?;784     let v: serde_json::Value = serde_json::from_str(&text).ok()?;785     let get = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();786     let id = path.file_stem()?.to_string_lossy().into_owned();787     Some(Entry {788         id,789         kind: get("type"),790         title: get("title"),791         body: get("body"),792         handle: get("handle"),793         contact: get("contact"),794         license_version: get("license_version"),795         received_at: get("received_at"),796         salted_ip_hash: get("salted_ip_hash"),797     })798 }799 800 /// All entries sorted by `(received_at, id)`. Unparseable files are801 /// skipped with a stderr warning (never fatal, never raw output).802 pub fn list_entries(inbox_dir: &Path) -> Vec<Entry> {803     let mut out = Vec::new();804     let rd = match std::fs::read_dir(inbox_dir) {805         Ok(rd) => rd,806         Err(_) => return out,807     };808     for ent in rd.flatten() {809         let p = ent.path();810         if p.extension().is_none_or(|e| e != "json") || !p.is_file() {811             continue;812         }813         match read_entry(&p) {814             Some(e) => out.push(e),815             None => eprintln!("hu13 inbox: warning: skipping unparseable {}", p.display()),816         }817     }818     out.sort_by(|a, b| {819         (a.received_at.clone(), a.id.clone()).cmp(&(b.received_at.clone(), b.id.clone()))820     });821     out822 }823 824 /// `hu13 inbox list`: one escaped line per entry.825 pub fn cmd_list(inbox_dir: &Path) -> i32 {826     for e in list_entries(inbox_dir) {827         println!(828             "{} [{}] {} {}",829             escape_terminal(&e.id),830             escape_terminal(&e.kind),831             escape_terminal(&e.received_at),832             escape_terminal(&e.title)833         );834     }835     0836 }837 838 /// `hu13 inbox show <id>`: every field, escaped. The id must be a bare839 /// filename stem (`[A-Za-z0-9-]`, `.json` suffix optional) — anything else840 /// is rejected so user input never becomes a path.841 pub fn cmd_show(inbox_dir: &Path, id: &str) -> i32 {842     let stem = id.strip_suffix(".json").unwrap_or(id);843     if stem.is_empty()844         || stem.len() > 128845         || !stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')846     {847         eprintln!("hu13 inbox: bad id (use the uuid from `inbox list`)");848         return 2;849     }850     let path = inbox_dir.join(format!("{stem}.json"));851     match read_entry(&path) {852         Some(e) => {853             println!("id: {}", escape_terminal(&e.id));854             println!("type: {}", escape_terminal(&e.kind));855             println!("received_at: {}", escape_terminal(&e.received_at));856             println!("license_version: {}", escape_terminal(&e.license_version));857             println!("title: {}", escape_terminal(&e.title));858             println!("handle: {}", escape_terminal(&e.handle));859             println!("contact: {}", escape_terminal(&e.contact));860             println!("salted_ip_hash: {}", escape_terminal(&e.salted_ip_hash));861             println!("body:\n{}", escape_terminal(&e.body));862             0863         }864         None => {865             eprintln!("hu13 inbox: no such entry '{stem}'");866             1867         }868     }869 }870 871 #[cfg(test)]872 mod tests {873     use super::*;874 875     #[test]876     fn sha256_known_vector() {877         assert_eq!(878             sha256_hex(b"abc"),879             "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"880         );881         assert_eq!(882             sha256_hex(b""),883             "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"884         );885     }886 887     #[test]888     fn uuid4_shape_and_version_bits() {889         let u = new_uuid4();890         assert_eq!(u.len(), 36);891         for &i in &[8, 13, 18, 23] {892             assert_eq!(u.as_bytes()[i], b'-');893         }894         assert_eq!(u.as_bytes()[14], b'4');895         assert!(matches!(u.as_bytes()[19], b'8' | b'9' | b'a' | b'b'));896         assert_ne!(new_uuid4(), new_uuid4());897     }898 899     #[test]900     fn urldecode_roundtrip_plus_and_percent() {901         let pairs = parse_form("title=a+b%26c&body=x%0Ay&license=agree").unwrap();902         assert_eq!(field(&pairs, "title"), Some("a b&c"));903         assert_eq!(field(&pairs, "body"), Some("x\ny"));904         assert!(parse_form("a=%ZZ").is_none(), "bad hex rejected");905         assert!(parse_form("a=%ff").is_none(), "lone non-UTF8 rejected");906     }907 908     #[test]909     fn license_gate() {910         let base = vec![911             ("title".to_string(), "t".to_string()),912             ("body".to_string(), "b".to_string()),913         ];914         assert!(matches!(915             validate_form(InboxType::Issue, &base),916             Validation::Reject(400)917         ));918         let mut agree = base.clone();919         agree.push(("license".to_string(), "agree".to_string()));920         assert!(matches!(921             validate_form(InboxType::Issue, &agree),922             Validation::Accept(_)923         ));924         let mut on = base;925         on.push(("license".to_string(), "on".to_string()));926         assert!(matches!(927             validate_form(InboxType::Issue, &on),928             Validation::Accept(_)929         ));930     }931 932     #[test]933     fn nul_rejected_before_strip() {934         let pairs = vec![935             ("title".to_string(), "a\0b".to_string()),936             ("body".to_string(), "c".to_string()),937             ("license".to_string(), "agree".to_string()),938         ];939         assert!(matches!(940             validate_form(InboxType::Issue, &pairs),941             Validation::Reject(400)942         ));943         assert_eq!(strip_controls("a\x01b\tc\nd"), "ab\tc\nd");944     }945 946     #[test]947     fn honeypot_triggers_fake() {948         let pairs = vec![949             ("title".to_string(), "t".to_string()),950             ("body".to_string(), "b".to_string()),951             ("license".to_string(), "agree".to_string()),952             ("website".to_string(), "bot".to_string()),953         ];954         assert!(matches!(955             validate_form(InboxType::Issue, &pairs),956             Validation::Honeypot957         ));958     }959 960     #[test]961     fn caps_rejected() {962         let mk = |t: String, b: String| {963             vec![964                 ("title".to_string(), t),965                 ("body".to_string(), b),966                 ("license".to_string(), "agree".to_string()),967             ]968         };969         assert!(matches!(970             validate_form(InboxType::Issue, &mk("t".repeat(301), "b".into())),971             Validation::Reject(400)972         ));973         assert!(matches!(974             validate_form(InboxType::Issue, &mk("t".into(), "b".repeat(20001))),975             Validation::Reject(400)976         ));977         let pr_ok = vec![978             ("title".to_string(), "t".to_string()),979             ("prompt".to_string(), "p".to_string()),980             ("license".to_string(), "agree".to_string()),981         ];982         assert!(matches!(983             validate_form(InboxType::PrPrompt, &pr_ok),984             Validation::Accept(_)985         ));986     }987 988     #[test]989     fn terminal_escape_kills_ansi_and_controls() {990         let evil = "a\x1b[31mRED\x1b[0m\x07b\rc\u{202e}d<script>";991         let got = escape_terminal(evil);992         assert!(!got.contains('\x1b'), "no raw ESC");993         assert!(!got.contains('\x07'), "no raw BEL");994         assert!(!got.contains('\r'), "no raw CR");995         assert!(got.contains("\\x1b[31mRED\\x1b[0m"), "ESC shown escaped");996         assert!(got.contains("\\x07"), "BEL shown escaped");997         assert!(got.contains("\\u{202e}"), "bidi override escaped");998         assert!(got.contains("<script>"), "plain text stays readable");999     }1000 1001     #[test]1002     fn received_page_reflects_nothing() {1003         let marker = "MARKER-xyz-evil<script>alert(1)</script>";1004         let html = received_page("11111111-2222-4333-8555-666666666666");1005         assert!(html.contains("received"));1006         assert!(html.contains("11111111-2222-4333-8555-666666666666"));1007         assert!(!html.contains(marker));1008         assert!(!html.contains("<script"), "zero JS, zero reflection");1009     }1010 1011     #[test]1012     fn rate_limiter_two_per_hour_sliding_window() {1013         let lim = RateLimiter::new(2);1014         // Same ip+salt+date: first two pass, third blocked.1015         assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_000));1016         assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_001));1017         assert!(!lim.check_at("1.2.3.4", "2026-09-10", "s", 1_002));1018         // Other IP unaffected; window slides after an hour.1019         assert!(lim.check_at("5.6.7.8", "2026-09-10", "s", 1_003));1020         assert!(lim.check_at("1.2.3.4", "2026-09-10", "s", 1_000 + 3601));1021         // Daily rotation + restart salt both re-key (no cross-link).1022         assert!(lim.check_at("1.2.3.4", "2026-09-11", "s", 1_004));1023         assert!(lim.check_at("1.2.3.4", "2026-09-10", "other-salt", 1_005));1024     }1025 1026     #[test]1027     fn rate_limiter_evicts_stale_keys() {1028         let lim = RateLimiter::new(10);1029         for i in 0..500 {1030             assert!(lim.check_at(1031                 &format!("10.0.{}.{}", i / 250, i % 250),1032                 "2026-09-10",1033                 "s",1034                 1_0001035             ));1036         }1037         assert_eq!(lim.state.lock().unwrap().buckets.len(), 500);1038         // Sweep interval elapsed: all 500 buckets are stale and dropped.1039         // > 1h later: the 500 old timestamps are outside the window.1040         assert!(lim.check_at("9.9.9.9", "2026-09-10", "s", 1_000 + 3_601));1041         assert_eq!(1042             lim.state.lock().unwrap().buckets.len(),1043             1,1044             "only the fresh bucket survives the sweep"1045         );1046     }1047 1048     #[test]1049     fn rate_limiter_caps_key_count() {1050         let lim = RateLimiter::new(10);1051         // Bypass the sweep window so every key lands in the same map.1052         for i in 0..RATE_MAX_KEYS {1053             assert!(lim.check_at(&format!("ip-{i}"), "2026-09-10", "s", 1_000));1054         }1055         assert_eq!(lim.state.lock().unwrap().buckets.len(), RATE_MAX_KEYS);1056         assert!(1057             !lim.check_at("one-more", "2026-09-10", "s", 1_000),1058             "new keys fail closed at the cap"1059         );1060         assert!(1061             lim.check_at("ip-0", "2026-09-10", "s", 1_000),1062             "existing keys still work"1063         );1064     }1065 1066     #[test]1067     fn quota_counts_files_until_drained() {1068         let dir = std::env::temp_dir().join(format!(1069             "hu13-quota-{}-{}",1070             std::process::id(),1071             std::time::SystemTime::now()1072                 .duration_since(std::time::UNIX_EPOCH)1073                 .map(|d| d.as_nanos())1074                 .unwrap_or(0)1075         ));1076         std::fs::create_dir_all(&dir).unwrap();1077         assert!(check_quota_with(&dir, 2));1078         std::fs::write(dir.join("a.json"), "{}").unwrap();1079         assert!(check_quota_with(&dir, 2));1080         std::fs::write(dir.join("b.json"), "{}").unwrap();1081         assert!(!check_quota_with(&dir, 2), "full at quota");1082         std::fs::remove_file(dir.join("a.json")).unwrap();1083         assert!(check_quota_with(&dir, 2), "drained by hand → room again");1084         std::fs::remove_dir_all(&dir).ok();1085     }1086 1087     #[test]1088     fn tuned_caps_enforced() {1089         let mk = || {1090             vec![1091                 ("title".to_string(), "123456".to_string()),1092                 ("body".to_string(), "b".to_string()),1093                 ("license".to_string(), "agree".to_string()),1094             ]1095         };1096         assert!(matches!(1097             validate_form_with_limits(InboxType::Issue, &mk(), 5, MAX_BODY_CHARS),1098             Validation::Reject(400)1099         ));1100         assert!(matches!(1101             validate_form_with_limits(InboxType::Issue, &mk(), 6, MAX_BODY_CHARS),1102             Validation::Accept(_)1103         ));1104     }1105 1106     #[test]1107     fn forms_have_honeypot_license_action_no_js() {1108         let dir = std::env::temp_dir().join(format!(1109             "hu13-inbox-forms-{}-{}",1110             std::process::id(),1111             std::time::SystemTime::now()1112                 .duration_since(std::time::UNIX_EPOCH)1113                 .map(|d| d.as_nanos())1114                 .unwrap_or(0)1115         ));1116         std::fs::create_dir_all(&dir).unwrap();1117         render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();1118         let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();1119         let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();1120         assert!(issue.contains("action=\"/inbox/issue\""));1121         assert!(issue.contains("name=\"body\""));1122         assert!(pr.contains("action=\"/inbox/pr-prompt\""));1123         assert!(pr.contains("name=\"prompt\""));1124         for html in [&issue, &pr] {1125             assert!(html.contains("name=\"website\""), "honeypot named website");1126             assert!(html.contains("name=\"license\""), "license checkbox");1127             assert!(1128                 html.contains("irrevocably give it to this project"),1129                 "exact license text"1130             );1131             assert!(!html.contains("<script"), "zero JS");1132             assert!(html.contains("maxlength=\"300\""));1133             assert!(html.contains("maxlength=\"20000\""));1134         }1135         std::fs::remove_dir_all(&dir).ok();1136     }1137 1138     /// f0rge #25: submission pages explain the deal in human words1139     /// (private inbox, ack id, no account) plus a plain license summary1140     /// linking to the full grant text — with the full checkbox kept.1141     #[test]1142     fn forms_explain_deal_and_summarize_license() {1143         let dir = std::env::temp_dir().join(format!(1144             "hu13-inbox-forms-deal-{}-{}",1145             std::process::id(),1146             std::time::SystemTime::now()1147                 .duration_since(std::time::UNIX_EPOCH)1148                 .map(|d| d.as_nanos())1149                 .unwrap_or(0)1150         ));1151         std::fs::create_dir_all(&dir).unwrap();1152         render_forms(&dir, "test", "", "main", "abc1234", None).unwrap();1153         let issue = std::fs::read_to_string(dir.join("new-issue.html")).unwrap();1154         let pr = std::fs::read_to_string(dir.join("new-pr.html")).unwrap();1155         for html in [&issue, &pr] {1156             assert!(html.contains("private inbox"), "private suggestion");1157             assert!(html.contains("ack id"), "ack id explained");1158             assert!(html.contains("No account needed"), "no account needed");1159             assert!(html.contains("In plain words:"), "license summary");1160             assert!(1161                 html.contains("href=\"#license-full\""),1162                 "summary links to full text"1163             );1164             assert!(html.contains("id=\"license-full\""), "full text anchor");1165             assert!(html.contains("name=\"license\""), "checkbox kept");1166             assert!(1167                 html.contains("irrevocably give it to this project"),1168                 "full grant text kept"1169             );1170         }1171         std::fs::remove_dir_all(&dir).ok();1172     }1173 }