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 }