src/serve.rs 47.4 KiB 1407 lines Raw
1 //! `hu13 serve` (m4, f0rge issue #9): std-only static file server2 //! (`TcpListener` + thread-per-connection, no new deps) plus exactly two3 //! POST routes (`/inbox/issue`, `/inbox/pr-prompt`).4 //!5 //! Rules enforced here, fail closed:6 //! - `/data/inbox` is NEVER under www: startup refuses unless the inbox7 //!   dir sits outside the served tree ([`ensure_inbox_outside_www`]).8 //! - Static: every response carries `nosniff`; directories never list9 //!   (only real files serve; anything else is a generic `404` that echoes10 //!   nothing); `..` / NUL / bad escapes never escape www.11 //! - POSTs: urlencoded-only (`multipart/*` → `400`), `Content-Length`12 //!   required (absent/unparsable → drop the connection), whole body13 //!   capped (`413`, default 32KB, tunable), 5s read timeout (stall →14 //!   drop), strict UTF-8 (`400`), NUL → `400`, controls stripped except15 //!   newline/tab, caps validated, license required (`400`), honeypot →16 //!   fake `received` + zero files, per-IP token bucket (`429`), global17 //!   quota (`503` until drained), `Origin` gate (`403`, curl with no18 //!   Origin passes), fixed ~500ms delay before every inbox-route19 //!   response (success and failure alike — no timing oracle), `received`20 //!   page shows the ack uuid once with zero reflection. Wrong method →21 //!   `405`.22 //! - Every inbox-route response carries `nosniff` + `no-store`; we set23 //!   no cookies and send no CORS headers, so `SameSite=Lax` is24 //!   vacuously satisfied (no `Set-Cookie` ever). Full-disk/IO failure →25 //!   `503` (lose one POST, never the repo). Caps tunable via flags/env,26 //!   no config.27 28 use std::io::{Read, Write};29 use std::net::{TcpListener, TcpStream};30 use std::path::{Path, PathBuf};31 use std::time::{Duration, Instant};32 33 use crate::inbox;34 35 /// Options for [`run`] (flags/env only, no config file).36 #[derive(Debug, Clone)]37 pub struct ServeOptions {38     /// Built site directory to serve.39     pub www: PathBuf,40     /// Inbox directory for POST storage (must sit outside `www`).41     pub inbox_dir: PathBuf,42     /// Listen address, e.g. `0.0.0.0:39313` (`127.0.0.1:0` in tests).43     pub listen: String,44     /// Max chars for `title` (`--max-title` / `HU13_MAX_TITLE`).45     pub max_title: usize,46     /// Max chars for `body`/`prompt` (`--max-body` / `HU13_MAX_BODY`).47     pub max_body: usize,48     /// Max whole POST body bytes (`--max-bytes` / `HU13_MAX_BYTES`).49     pub max_bytes: usize,50     /// Per-IP POSTs per hour (`--rate-per-hour` / `HU13_RATE_PER_HOUR`).51     pub rate_per_hour: u32,52     /// Global quota in files (`--quota` / `HU13_QUOTA`).53     pub quota: usize,54 }55 56 impl ServeOptions {57     /// Defaults matching [`crate::inbox`] constants (m4 core caps).58     #[must_use]59     pub fn with_defaults(www: PathBuf, inbox_dir: PathBuf, listen: String) -> Self {60         Self {61             www,62             inbox_dir,63             listen,64             max_title: inbox::MAX_TITLE_CHARS,65             max_body: inbox::MAX_BODY_CHARS,66             max_bytes: inbox::MAX_REQUEST_BYTES,67             rate_per_hour: inbox::RATE_PER_HOUR,68             quota: inbox::QUOTA_FILES,69         }70     }71 }72 73 /// Per-connection shared state (tuned caps + rate buckets + daily salt).74 /// Max concurrent connections. Each connection owns one thread and75 /// inbox answers sleep ~500ms by design, so an unbounded accept loop is76 /// a trivial thread-exhaustion DoS (f0rge #63). Over the cap: 503 now.77 pub const MAX_CONNS: usize = 64;78 79 /// Max concurrent `git upload-pack` runs (f0rge #64).80 pub const MAX_GIT_UPLOADS: usize = 4;81 82 /// Hard timeout for one `git upload-pack` run. Larger than the 10s83 /// metadata timeout: a clone legitimately streams the whole pack.84 pub const GIT_UPLOAD_TIMEOUT: Duration = Duration::from_secs(120);85 86 /// Total time allowed to read one request head (slowloris bound).87 pub const HEAD_DEADLINE: Duration = Duration::from_secs(10);88 89 struct Shared {90     www: PathBuf,91     inbox_dir: PathBuf,92     salt: String,93     limiter: inbox::RateLimiter,94     max_title: usize,95     max_body: usize,96     max_bytes: usize,97     quota: usize,98     /// Bare repo materialized from `www/repo.bundle` for git smart HTTP99     /// (`GET /repo.bundle/info/refs`, `POST /repo.bundle/git-upload-pack`),100     /// so `git clone http://host:port/repo.bundle` works (m6, issue #32).101     /// `None` when no bundle was present at startup (static-only mode).102     git_dir: Option<PathBuf>,103     /// In-flight upload-pack runs (bounded by [`MAX_GIT_UPLOADS`]).104     git_uploads: std::sync::Arc<std::sync::atomic::AtomicUsize>,105 }106 107 /// Materialize `www/repo.bundle` into a bare repo for smart HTTP.108 ///109 /// Runs `git clone --bare --quiet <bundle> <tmp>/repo.git` once at serve110 /// startup. Returns `None` when no bundle exists or git fails (serve111 /// continues static-only; `/repo.bundle` still downloads via GET).112 fn prepare_git_dir(www: &Path) -> Option<PathBuf> {113     // Static serving already rejects an outside symlink; smart HTTP must114     // use the same resolved-path check (f0rge #117). A symlink pointing115     // inside www is allowed, but a symlink to a private bundle is not.116     let www_root = resolve_existing(www);117     let bundle = resolve_existing(&www.join("repo.bundle"));118     if bundle == www_root || !bundle.starts_with(&www_root) || !bundle.is_file() {119         return None;120     }121     let dir = std::env::temp_dir().join(format!(122         "hu13-git-{}-{}",123         std::process::id(),124         std::time::SystemTime::now()125             .duration_since(std::time::UNIX_EPOCH)126             .map(|d| d.as_nanos())127             .unwrap_or(0)128     ));129     let git_dir = dir.join("repo.git");130     let st = std::process::Command::new("git")131         .arg("clone")132         .arg("--bare")133         .arg("--quiet")134         .arg(&bundle)135         .arg(&git_dir)136         .status();137     match st {138         Ok(s) if s.success() && git_dir.is_dir() => Some(git_dir),139         Ok(s) => {140             eprintln!("hu13: prepare git dir from bundle failed ({s}); smart HTTP disabled");141             None142         }143         Err(e) => {144             eprintln!("hu13: spawn git for smart HTTP: {e}; smart HTTP disabled");145             None146         }147     }148 }149 150 /// Lexically absolutize + normalize `p` (`.`/`..` resolved, no fs touch so151 /// missing paths still check correctly).152 fn absolutize(p: &Path) -> PathBuf {153     let abs = if p.is_absolute() {154         p.to_path_buf()155     } else {156         std::env::current_dir()157             .unwrap_or_else(|_| PathBuf::from("/"))158             .join(p)159     };160     let mut out = PathBuf::new();161     for comp in abs.components() {162         match comp {163             std::path::Component::ParentDir => {164                 out.pop();165             }166             std::path::Component::CurDir => {}167             _ => out.push(comp.as_os_str()),168         }169     }170     if out.as_os_str().is_empty() {171         PathBuf::from("/")172     } else {173         out174     }175 }176 177 /// Resolve symlinks in the deepest existing ancestor of `p`, then append178 /// the non-existent suffix. Startup may run before the inbox dir exists.179 fn resolve_existing(p: &Path) -> PathBuf {180     let abs = absolutize(p);181     let mut suffix: Vec<std::ffi::OsString> = Vec::new();182     let mut cur = abs.as_path();183     loop {184         if let Ok(mut resolved) = std::fs::canonicalize(cur) {185             for name in suffix.iter().rev() {186                 resolved.push(name);187             }188             return resolved;189         }190         let Some(name) = cur.file_name() else {191             return abs;192         };193         suffix.push(name.to_os_string());194         let Some(parent) = cur.parent() else {195             return abs;196         };197         cur = parent;198     }199 }200 201 /// Refuse to start when the inbox dir is inside (or equal to) www.202 /// Inbox files must never be reachable over HTTP. Resolve symlinks: a203 /// lexical-only check lets `--inbox-dir link-to-www/private` through.204 pub fn ensure_inbox_outside_www(www: &Path, inbox_dir: &Path) -> Result<(), String> {205     let www = resolve_existing(www);206     let inbox = resolve_existing(inbox_dir);207     if inbox == www || inbox.starts_with(&www) {208         return Err(format!(209             "hu13: refusing to start: inbox-dir {} is inside www {} (inbox must never be served)",210             inbox.display(),211             www.display()212         ));213     }214     Ok(())215 }216 217 /// Block forever serving `www` + the two POSTs. Returns only on fatal218 /// startup errors (bind failure etc.).219 pub fn run(opts: &ServeOptions) -> i32 {220     if let Err(e) = ensure_inbox_outside_www(&opts.www, &opts.inbox_dir) {221         eprintln!("{e}");222         return 1;223     }224     if !opts.www.is_dir() {225         eprintln!(226             "hu13: www dir {} missing (build first: hu13 build <repo> -o <dir>)",227             opts.www.display()228         );229         return 1;230     }231     #[cfg(unix)]232     {233         use std::os::unix::fs::DirBuilderExt;234         let mut b = std::fs::DirBuilder::new();235         b.recursive(true).mode(0o700);236         if let Err(e) = b.create(&opts.inbox_dir) {237             eprintln!("hu13: create inbox dir: {e}");238             return 1;239         }240     }241     #[cfg(not(unix))]242     {243         if let Err(e) = std::fs::create_dir_all(&opts.inbox_dir) {244             eprintln!("hu13: create inbox dir: {e}");245             return 1;246         }247     }248     // Re-check after creation: a dangling symlink may only reveal that its249     // target is inside www once `create_dir_all` materializes it.250     if let Err(e) = ensure_inbox_outside_www(&opts.www, &opts.inbox_dir) {251         eprintln!("{e}");252         return 1;253     }254     let listener = match TcpListener::bind(&opts.listen) {255         Ok(l) => l,256         Err(e) => {257             eprintln!("hu13: bind {}: {e}", opts.listen);258             return 1;259         }260     };261     let bound = listener262         .local_addr()263         .map(|a| a.to_string())264         .unwrap_or_else(|_| opts.listen.clone());265     // Integration tests spawn this binary with `--listen 127.0.0.1:0` and266     // parse this exact line to learn the port. Flush: piped stdout blocks.267     println!("hu13: listening on {bound}");268     use std::io::Write as _;269     let _ = std::io::stdout().flush();270     let shared = std::sync::Arc::new(Shared {271         www: opts.www.clone(),272         inbox_dir: opts.inbox_dir.clone(),273         salt: inbox::new_server_salt(),274         limiter: inbox::RateLimiter::new(opts.rate_per_hour),275         max_title: opts.max_title,276         max_body: opts.max_body,277         max_bytes: opts.max_bytes,278         quota: opts.quota,279         git_dir: prepare_git_dir(&opts.www),280         git_uploads: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),281     });282     let live = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));283     for conn in listener.incoming() {284         match conn {285             Ok(mut stream) => {286                 use std::sync::atomic::Ordering;287                 if live.fetch_add(1, Ordering::Relaxed) >= MAX_CONNS {288                     live.fetch_sub(1, Ordering::Relaxed);289                     reject_overloaded(&mut stream);290                     continue;291                 }292                 let shared = std::sync::Arc::clone(&shared);293                 let slot = ConnSlot(std::sync::Arc::clone(&live));294                 std::thread::spawn(move || {295                     let _slot = slot;296                     handle_conn(stream, &shared);297                 });298             }299             Err(e) => {300                 eprintln!("hu13: accept: {e}");301             }302         }303     }304     eprintln!("hu13: listener closed");305     1306 }307 308 // ---- HTTP plumbing (tiny, no deps) -------------------------------------309 310 /// Decrements the live-connection counter on drop, whatever the thread311 /// does (panic included).312 struct ConnSlot(std::sync::Arc<std::sync::atomic::AtomicUsize>);313 314 impl Drop for ConnSlot {315     fn drop(&mut self) {316         self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);317     }318 }319 320 /// Answer 503 without spawning a thread (the pool is already full).321 fn reject_overloaded(stream: &mut TcpStream) {322     let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));323     let body = b"overloaded324 ";325     let head = format!(326         "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nRetry-After: 1\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n",327         body.len()328     );329     let _ = stream.write_all(head.as_bytes());330     let _ = stream.write_all(body);331     let _ = stream.flush();332     // Drain the request bytes we never read: closing with unread data333     // makes the kernel send RST, which can destroy the 503 the client334     // has not read yet. Bounded by a short timeout, no thread.335     let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));336     let mut buf = [0u8; 4096];337     for _ in 0..4 {338         match stream.read(&mut buf) {339             Ok(0) | Err(_) => break,340             Ok(_) => {}341         }342     }343 }344 345 struct Head {346     method: String,347     target: String,348     headers: Vec<(String, String)>,349 }350 351 /// Read until `\r\n\r\n` (cap 16KiB of headers). `None` = stall/overflow/352 /// EOF → caller drops the connection.353 fn read_head(stream: &mut TcpStream) -> Option<(Head, Vec<u8>)> {354     let start = Instant::now();355     let mut buf: Vec<u8> = Vec::with_capacity(4096);356     let mut chunk = [0u8; 4096];357     loop {358         if buf.len() > 16 * 1024 || start.elapsed() > HEAD_DEADLINE {359             return None;360         }361         match stream.read(&mut chunk) {362             Ok(0) => return None,363             Ok(n) => {364                 buf.extend_from_slice(&chunk[..n]);365                 if let Some(pos) = find_double_crlf(&buf) {366                     let rest = buf[pos + 4..].to_vec();367                     let text = std::str::from_utf8(&buf[..pos]).ok()?;368                     return parse_head(text).map(|h| (h, rest));369                 }370             }371             Err(_) => return None,372         }373     }374 }375 376 fn find_double_crlf(buf: &[u8]) -> Option<usize> {377     buf.windows(4).position(|w| w == b"\r\n\r\n")378 }379 380 fn parse_head(text: &str) -> Option<Head> {381     let mut lines = text.split("\r\n");382     let request_line = lines.next()?;383     let mut parts = request_line.split_whitespace();384     let method = parts.next()?.to_string();385     let target = parts.next()?.to_string();386     if method.is_empty() || target.is_empty() || target.len() > 4096 {387         return None;388     }389     let mut headers = Vec::new();390     for line in lines {391         if line.is_empty() {392             continue;393         }394         let (k, v) = line.split_once(':')?;395         headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));396     }397     Some(Head {398         method,399         target,400         headers,401     })402 }403 404 fn header<'a>(h: &'a Head, name: &str) -> Option<&'a str> {405     h.headers406         .iter()407         .find(|(k, _)| k == name)408         .map(|(_, v)| v.as_str())409 }410 411 fn reason(code: u16) -> &'static str {412     match code {413         200 => "OK",414         400 => "Bad Request",415         403 => "Forbidden",416         404 => "Not Found",417         405 => "Method Not Allowed",418         413 => "Content Too Large",419         429 => "Too Many Requests",420         503 => "Service Unavailable",421         _ => "Error",422     }423 }424 425 /// One response, always `nosniff` + `Connection: close`. Inbox-route426 /// responses add `no-store` via `extra`.427 fn respond(428     stream: &mut TcpStream,429     code: u16,430     content_type: &str,431     extra: &[(&str, &str)],432     body: &[u8],433 ) {434     let mut out = format!(435         "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: strict-origin-when-cross-origin\r\nConnection: close\r\n",436         reason(code),437         body.len()438     );439     for (k, v) in extra {440         out.push_str(k);441         out.push_str(": ");442         out.push_str(v);443         out.push_str("\r\n");444     }445     out.push_str("\r\n");446     let _ = stream.write_all(out.as_bytes());447     let _ = stream.write_all(body);448     let _ = stream.flush();449 }450 451 fn static_error(stream: &mut TcpStream, code: u16) {452     let msg: &str = match code {453         404 => "not found",454         405 => "method not allowed",455         _ => "error",456     };457     // Error pages keep the top-level nav so a lost visitor can click out.458     let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \459          <a href=\"/issues/index.html\">issues</a> · \460          <a href=\"/pulls/index.html\">pulls</a> · \461          <a href=\"/tasks/index.html\">tasks</a> · \462          <a href=\"/ci/index.html\">ci</a> · \463          <a href=\"/releases/index.html\">releases</a> · \464          <a href=\"/wiki/index.html\">wiki</a> · \465          <a href=\"/agents.html\">agents</a>";466     let body = crate::layout::page(msg, nav, &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));467     let extra = if code == 405 {468         &[("Allow", "GET, HEAD")][..]469     } else {470         &[][..]471     };472     respond(473         stream,474         code,475         "text/html; charset=utf-8",476         extra,477         body.as_bytes(),478     );479 }480 481 fn content_type_for(name: &str) -> &'static str {482     let lower = name.to_ascii_lowercase();483     if lower.ends_with(".html") {484         "text/html; charset=utf-8"485     } else if lower.ends_with(".css") {486         "text/css; charset=utf-8"487     } else if lower.ends_with(".svg") {488         "image/svg+xml"489     } else if lower.ends_with(".png") {490         "image/png"491     } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {492         "image/jpeg"493     } else if lower.ends_with(".gif") {494         "image/gif"495     } else if lower.ends_with(".webp") {496         "image/webp"497     } else if lower.ends_with(".ico") {498         "image/x-icon"499     } else if lower.ends_with(".avif") {500         "image/avif"501     } else if lower.ends_with(".webmanifest") {502         "application/manifest+json"503     } else if lower.ends_with(".json") {504         "application/json"505     } else if lower.ends_with(".atom") {506         "application/atom+xml; charset=utf-8"507     } else if lower.ends_with(".md") || lower.ends_with(".txt") {508         "text/plain; charset=utf-8"509     } else if lower.ends_with(".git/head")510         || (lower.contains(".git/")511             && (lower.ends_with("/info/refs")512                 || lower.ends_with("/info/packs")513                 || lower.ends_with("/info/alternates")514                 || lower.ends_with("/packed-refs")))515     {516         // Dumb-HTTP git files (#54): plain text so any static server517         // (Pages/nginx/serve) serves `repo.git` identically. Git ignores518         // mime for dumb reads, but explicit beats octet-stream guessing.519         "text/plain; charset=utf-8"520     } else if lower.ends_with(".gz") {521         "application/gzip"522     } else {523         "application/octet-stream"524     }525 }526 527 /// Extensions that browsers may render as an active document. Committed528 /// files are untrusted (a repo can contain `raw/evil.html`), so these are529 /// always downloaded, never rendered (f0rge #61).530 const RAW_ACTIVE_EXTS: &[&str] = &[531     "html", "htm", "shtml", "xhtml", "xht", "svg", "svgz", "xml", "xsl", "xslt", "mht", "mhtml",532     "pdf", "swf", "hta", "jar", "crx", "xpi",533 ];534 535 /// Text-ish extensions that keep a text MIME type. They still download:536 /// `_headers` sends `attachment` for every `/raw/*` path, so `serve` uses537 /// the same disposition instead of rendering text inline (f0rge #93).538 const RAW_TEXT_EXTS: &[&str] = &[539     "txt",540     "text",541     "md",542     "markdown",543     "rst",544     "adoc",545     "rs",546     "js",547     "mjs",548     "cjs",549     "jsx",550     "ts",551     "tsx",552     "json",553     "jsonl",554     "yaml",555     "yml",556     "toml",557     "ini",558     "cfg",559     "conf",560     "css",561     "scss",562     "less",563     "c",564     "h",565     "cc",566     "cpp",567     "hpp",568     "go",569     "py",570     "rb",571     "sh",572     "bash",573     "zsh",574     "fish",575     "lua",576     "sql",577     "log",578     "diff",579     "patch",580     "csv",581     "tsv",582     "atom",583     "rss",584     "lock",585     "gitignore",586     "gitattributes",587     "editorconfig",588 ];589 590 fn extension(name: &str) -> String {591     let base = name.rsplit('/').next().unwrap_or(name);592     match base.rsplit_once('.') {593         Some((stem, ext)) if !stem.is_empty() => ext.to_ascii_lowercase(),594         _ => String::new(),595     }596 }597 598 /// `(content_type, content_disposition)` for a `/raw/<path>` response.599 ///600 /// Never `text/html`/`image/svg+xml`: a hostile repo could otherwise run601 /// script in the site's origin (where the inbox endpoints live). Active602 /// types become an octet-stream download; text keeps `text/plain` but603 /// also downloads; everything downloads with `attachment` (f0rge #93).604 fn raw_content_type(name: &str) -> (&'static str, &'static str) {605     let ext = extension(name);606     if RAW_ACTIVE_EXTS.contains(&ext.as_str()) {607         ("application/octet-stream", "attachment")608     } else if RAW_TEXT_EXTS.contains(&ext.as_str()) {609         ("text/plain; charset=utf-8", "attachment")610     } else {611         ("application/octet-stream", "attachment")612     }613 }614 615 /// Percent-decode a URL path (`+` stays literal in paths). `None` = bad616 /// escape / NUL / non-UTF-8 → caller answers `400`/drops.617 fn decode_path(s: &str) -> Option<String> {618     let mut bytes: Vec<u8> = Vec::with_capacity(s.len());619     let mut it = s.as_bytes().iter();620     while let Some(&b) = it.next() {621         if b == b'%' {622             let hi = *it.next()?;623             let lo = *it.next()?;624             let hex = |c: u8| -> Option<u8> {625                 match c {626                     b'0'..=b'9' => Some(c - b'0'),627                     b'a'..=b'f' => Some(c - b'a' + 10),628                     b'A'..=b'F' => Some(c - b'A' + 10),629                     _ => None,630                 }631             };632             bytes.push((hex(hi)? << 4) | hex(lo)?);633         } else {634             bytes.push(b);635         }636     }637     if bytes.contains(&0) {638         return None;639     }640     String::from_utf8(bytes).ok()641 }642 643 /// Normalized authority (`host` + effective port) of an `Origin`/`Referer`644 /// URL (`scheme://host[:port][/...]`) or a `Host` header (`host[:port]`).645 /// Scheme defaults matter: `http` → 80, `https` → 443, and a bare `Host`646 /// on this plain-HTTP server → 80. `None` = unparsable/unsupported → fail647 /// closed when supplied by the browser.648 #[derive(Debug, PartialEq, Eq)]649 struct Authority {650     scheme: String,651     host: String,652     port: u16,653 }654 655 fn authority_of_url(value: &str, default_scheme: &str) -> Option<Authority> {656     let v = value.trim();657     if v.is_empty() || v.eq_ignore_ascii_case("null") {658         return None;659     }660     let (scheme, rest) = match v.split_once("://") {661         Some((scheme, rest)) => {662             let scheme = scheme.to_ascii_lowercase();663             if scheme != "http" && scheme != "https" {664                 return None;665             }666             (scheme, rest)667         }668         // A bare `Host` has no scheme; use the request scheme (plain HTTP,669         // or `X-Forwarded-Proto` when behind a TLS proxy).670         None => (default_scheme.to_string(), v),671     };672     let hostport = rest.split('/').next().unwrap_or("");673     let hostport = hostport.split('@').next_back().unwrap_or("");674     if hostport.is_empty() {675         return None;676     }677     let (host, port) = if let Some(stripped) = hostport.strip_prefix('[') {678         let (host, after) = stripped.split_once(']')?;679         let port = if after.is_empty() {680             None681         } else {682             Some(after.strip_prefix(':')?)683         };684         (host, port)685     } else if hostport.matches(':').count() == 1 {686         let (host, port) = hostport.split_once(':')?;687         if port.is_empty() {688             return None;689         }690         (host, Some(port))691     } else {692         (hostport, None)693     };694     let host = host.trim().trim_matches('.').to_ascii_lowercase();695     if host.is_empty() {696         return None;697     }698     let port = match port {699         Some(port) => port.parse::<u16>().ok()?,700         None => match scheme.as_str() {701             "https" => 443,702             "http" => 80,703             _ => return None,704         },705     };706     Some(Authority { scheme, host, port })707 }708 709 /// Same-origin gate for the two POST routes: no `Origin` and no710 /// `Referer` (curl/agents) → allow; browser-supplied `Origin` (else711 /// `Referer`) must match the `Host` authority, including port → else712 /// `403`. Comparing only hostnames lets another local port forge a713 /// same-origin request (f0rge #101). Never echoes.714 #[must_use]715 fn origin_allowed(head: &Head) -> bool {716     let origin = header(head, "origin");717     let referer = header(head, "referer");718     let claimed = match origin {719         Some(o) if !o.trim().is_empty() => Some(o),720         _ => match referer {721             Some(r) if !r.trim().is_empty() => Some(r),722             _ => None,723         },724     };725     let Some(claimed) = claimed else {726         return true; // curl sends none727     };728     let scheme = header(head, "x-forwarded-proto")729         .and_then(|v| v.split(',').next())730         .map(str::trim)731         .map(str::to_ascii_lowercase)732         .filter(|v| v == "http" || v == "https")733         .unwrap_or_else(|| "http".to_string());734     let (Some(want), Some(got)) = (735         header(head, "host").and_then(|h| authority_of_url(h, &scheme)),736         authority_of_url(claimed, &scheme),737     ) else {738         return false; // unparsable Host or Origin → fail closed739     };740     want == got741 }742 743 fn handle_conn(mut stream: TcpStream, shared: &Shared) {744     let _ = stream.set_read_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));745     let _ = stream.set_write_timeout(Some(Duration::from_secs(inbox::POST_READ_TIMEOUT_SECS)));746     let (head, rest) = match read_head(&mut stream) {747         Some(v) => v,748         None => return, // stall/overflow/EOF: drop, fail closed749     };750     let raw_path = head.target.split('?').next().unwrap_or("/");751     let path = match decode_path(raw_path) {752         Some(p) => p,753         None => {754             static_error(&mut stream, 400);755             return;756         }757     };758     if path == "/inbox/issue" || path == "/inbox/pr-prompt" {759         let kind = if path == "/inbox/issue" {760             inbox::InboxType::Issue761         } else {762             inbox::InboxType::PrPrompt763         };764         handle_inbox(&mut stream, &head, rest, kind, shared);765         return;766     }767     // Git smart HTTP for the bundle (m6, issue #32): `git clone768     // http://host:port/repo.bundle` speaks smart HTTP (`info/refs` +769     // `git-upload-pack`), not a raw bundle download. Serve it from a770     // bare repo materialized from `www/repo.bundle` at startup.771     if path == "/repo.bundle/info/refs" || path == "/repo.bundle/git-upload-pack" {772         handle_git_smart(&mut stream, &head, rest, &path, shared);773         return;774     }775     // Static: files only, never a listing (dirs → 404). Unknown paths776     // are 404 for ANY method (including POST); a known file with the777     // wrong method is 405. Fail closed, echo nothing.778     let rel = path.strip_prefix('/').unwrap_or(&path);779     if rel.contains('\0')780         || rel.split('/').any(|s| s == "..")781         || (path.ends_with('/') && path != "/")782     {783         static_error(&mut stream, 404);784         return;785     }786     let file_rel = if path == "/" {787         "index.html".to_string()788     } else {789         rel.to_string()790     };791     let www = resolve_existing(&shared.www);792     let dest = resolve_existing(&shared.www.join(&file_rel));793     if dest != www && !dest.starts_with(&www) {794         static_not_found(&mut stream, &head, shared);795         return;796     }797     // Stat, then stream. Never read a static file into memory: a raw798     // blob can be hundreds of MB and MAX_CONNS=64 (f0rge #80). The799     // method check stays after existence so an unknown POST is still800     // 404 (fail closed, no route discovery).801     let meta = match std::fs::metadata(&dest) {802         Ok(m) if m.is_file() => m,803         _ => {804             static_not_found(&mut stream, &head, shared);805             return;806         }807     };808     if head.method != "GET" && head.method != "HEAD" {809         static_error(&mut stream, 405);810         return;811     }812     // Classify by the resolved path, not the raw request string: a request813     // like `/releases//v1/evil.html` or `/./releases/...` maps to the same814     // file but would bypass a string-prefix check (f0rge #97).815     let served_rel = dest816         .strip_prefix(&www)817         .map(|p| p.to_string_lossy().replace('\\', "/"))818         .unwrap_or_else(|_| file_rel.clone());819     let is_raw = served_rel.starts_with("raw/");820     let is_artifact = crate::releases::is_artifact_path(&served_rel);821     let (ctype, disposition) = if is_raw || is_artifact {822         raw_content_type(&served_rel)823     } else {824         (content_type_for(&served_rel), "inline")825     };826     let extra: &[(&str, &str)] = if is_raw || is_artifact {827         &[("Content-Disposition", disposition)]828     } else {829         &[]830     };831     if head.method == "HEAD" {832         respond_file_head(&mut stream, 200, ctype, extra, meta.len());833         return;834     }835     respond_file(&mut stream, 200, ctype, extra, &dest, meta.len());836 }837 838 /// Read-and-discard in-flight request bytes (bounded) so early rejects839 /// get a clean close instead of a reset. Bounded by a 256KiB cap plus the840 /// 5s read timeout; liars just burn one thread briefly, then we answer.841 fn settle(stream: &mut TcpStream, rest_len: usize, len: Option<usize>) {842     let Some(total) = len else { return };843     let mut n = total.saturating_sub(rest_len).min(256 * 1024);844     let mut chunk = [0u8; 8192];845     while n > 0 {846         let want = n.min(chunk.len());847         match stream.read(&mut chunk[..want]) {848             Ok(0) => break,849             Ok(k) => n -= k,850             Err(_) => break,851         }852     }853 }854 855 fn content_length(head: &Head) -> Option<usize> {856     header(head, "content-length")?.trim().parse().ok()857 }858 859 /// Git smart HTTP for `/repo.bundle` (m6, issue #32).860 ///861 /// `git clone http://host:port/repo.bundle` never GETs the bundle bytes862 /// directly: `git-remote-http` first asks863 /// `GET /repo.bundle/info/refs?service=git-upload-pack`, then864 /// `POST /repo.bundle/git-upload-pack`. Answer both from the bare repo865 /// materialized at startup (`Shared::git_dir`); anything else (wrong866 /// method, missing bundle, git failure) is 404/405 with no echo.867 /// Bounded slot for one upload-pack run (f0rge #64).868 struct GitSlot(std::sync::Arc<std::sync::atomic::AtomicUsize>);869 870 impl GitSlot {871     fn acquire(shared: &Shared) -> Option<Self> {872         use std::sync::atomic::Ordering;873         let n = shared.git_uploads.fetch_add(1, Ordering::Relaxed);874         if n >= MAX_GIT_UPLOADS {875             shared.git_uploads.fetch_sub(1, Ordering::Relaxed);876             return None;877         }878         Some(Self(std::sync::Arc::clone(&shared.git_uploads)))879     }880 }881 882 impl Drop for GitSlot {883     fn drop(&mut self) {884         self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);885     }886 }887 888 /// Headers for a file response, without opening the file body.889 fn respond_file_head(890     stream: &mut TcpStream,891     code: u16,892     content_type: &str,893     extra: &[(&str, &str)],894     len: u64,895 ) {896     let mut out = format!(897         "HTTP/1.1 {code} {}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: strict-origin-when-cross-origin\r\nConnection: close\r\n",898         reason(code)899     );900     for (k, v) in extra {901         out.push_str(k);902         out.push_str(": ");903         out.push_str(v);904         out.push_str("\r\n");905     }906     out.push_str("\r\n");907     let _ = stream.write_all(out.as_bytes());908     let _ = stream.flush();909 }910 911 /// Serve the built `404.html` (same chrome as every page) with status912 /// 404. Falls back to the tiny in-binary stub when the file is absent.913 /// POSTs keep the old non-echoing stub (`f0rge #85`).914 fn static_not_found(stream: &mut TcpStream, head: &Head, shared: &Shared) {915     if head.method != "GET" && head.method != "HEAD" {916         static_error(stream, 404);917         return;918     }919     let www = resolve_existing(&shared.www);920     let dest = resolve_existing(&shared.www.join("404.html"));921     if dest == www || !dest.starts_with(&www) {922         static_error(stream, 404);923         return;924     }925     let Ok(meta) = std::fs::metadata(&dest) else {926         static_error(stream, 404);927         return;928     };929     if !meta.is_file() {930         static_error(stream, 404);931         return;932     }933     if head.method == "HEAD" {934         respond_file_head(stream, 404, "text/html; charset=utf-8", &[], meta.len());935     } else {936         respond_file(937             stream,938             404,939             "text/html; charset=utf-8",940             &[],941             &dest,942             meta.len(),943         );944     }945 }946 947 /// Stream a file body with `Content-Length` (no full-pack buffering).948 fn respond_file(949     stream: &mut TcpStream,950     code: u16,951     content_type: &str,952     extra: &[(&str, &str)],953     path: &Path,954     len: u64,955 ) {956     respond_file_head(stream, code, content_type, extra, len);957     if let Ok(mut f) = std::fs::File::open(path) {958         let _ = std::io::copy(&mut f, stream);959     }960     let _ = stream.flush();961 }962 963 fn handle_git_smart(964     stream: &mut TcpStream,965     head: &Head,966     rest: Vec<u8>,967     path: &str,968     shared: &Shared,969 ) {970     let Some(git_dir) = shared.git_dir.as_ref() else {971         static_error(stream, 404);972         return;973     };974     if path == "/repo.bundle/info/refs" {975         if head.method != "GET" {976             static_error(stream, 405);977             return;978         }979         let query = head.target.split('?').nth(1).unwrap_or("");980         if !query.contains("service=git-upload-pack") {981             static_error(stream, 404);982             return;983         }984         let out = std::process::Command::new("git")985             .arg("upload-pack")986             .arg("--stateless-rpc")987             .arg("--advertise-refs")988             .arg(git_dir)989             .output();990         match out {991             Ok(o) if o.status.success() => {992                 let mut body = b"001e# service=git-upload-pack\n0000".to_vec();993                 body.extend_from_slice(&o.stdout);994                 respond(995                     stream,996                     200,997                     "application/x-git-upload-pack-advertisement",998                     &[("Cache-Control", "no-cache")],999                     &body,1000                 );1001             }1002             _ => static_error(stream, 404),1003         }1004         return;1005     }1006     // POST /repo.bundle/git-upload-pack1007     if head.method != "POST" {1008         settle(stream, rest.len(), content_length(head));1009         static_error(stream, 405);1010         return;1011     }1012     let len: usize = match header(head, "content-length") {1013         Some(v) => match v.trim().parse() {1014             Ok(n) => n,1015             Err(_) => return,1016         },1017         None => return,1018     };1019     // Wants are small; cap the request at 1MiB (fail closed).1020     const MAX_GIT_REQUEST: usize = 1024 * 1024;1021     if len > MAX_GIT_REQUEST {1022         settle(stream, rest.len(), Some(len));1023         static_error(stream, 413);1024         return;1025     }1026     let mut req_body = rest;1027     if req_body.len() > len {1028         req_body.truncate(len);1029     }1030     while req_body.len() < len {1031         let mut chunk = [0u8; 8192];1032         let want = (len - req_body.len()).min(chunk.len());1033         match stream.read(&mut chunk[..want]) {1034             Ok(0) => break,1035             Ok(n) => req_body.extend_from_slice(&chunk[..n]),1036             Err(_) => return,1037         }1038     }1039     if req_body.len() != len {1040         return;1041     }1042     let Some(_slot) = GitSlot::acquire(shared) else {1043         static_error(stream, 503);1044         return;1045     };1046     // Spool the pack to a temp file and stream it out: a big repo's pack1047     // used to sit in RAM in full (`wait_with_output`), and there was no1048     // deadline at all (f0rge #64).1049     let tmp = std::env::temp_dir().join(format!(1050         "hu13-pack-{}-{}",1051         std::process::id(),1052         std::time::SystemTime::now()1053             .duration_since(std::time::UNIX_EPOCH)1054             .map(|d| d.as_nanos())1055             .unwrap_or(0)1056     ));1057     let pack_file = match std::fs::File::create(&tmp) {1058         Ok(f) => f,1059         Err(_) => {1060             static_error(stream, 503);1061             return;1062         }1063     };1064     let mut child = match std::process::Command::new("git")1065         .arg("upload-pack")1066         .arg("--stateless-rpc")1067         .arg(git_dir)1068         .stdin(std::process::Stdio::piped())1069         .stdout(std::process::Stdio::from(pack_file))1070         .stderr(std::process::Stdio::null())1071         .spawn()1072     {1073         Ok(c) => c,1074         Err(_) => {1075             let _ = std::fs::remove_file(&tmp);1076             static_error(stream, 503);1077             return;1078         }1079     };1080     if let Some(mut stdin) = child.stdin.take() {1081         let _ = stdin.write_all(&req_body);1082         // Drop closes the pipe so upload-pack sees EOF.1083     }1084     let start = Instant::now();1085     let status = loop {1086         match child.try_wait() {1087             Ok(Some(st)) => break Some(st),1088             Ok(None) => {1089                 if start.elapsed() > GIT_UPLOAD_TIMEOUT {1090                     let _ = child.kill();1091                     let _ = child.wait();1092                     break None;1093                 }1094                 std::thread::sleep(Duration::from_millis(10));1095             }1096             Err(_) => break None,1097         }1098     };1099     let ok = status.is_some_and(|st| st.success());1100     if !ok {1101         let _ = std::fs::remove_file(&tmp);1102         static_error(stream, 503);1103         return;1104     }1105     let len = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0);1106     respond_file(1107         stream,1108         200,1109         "application/x-git-upload-pack-result",1110         &[("Cache-Control", "no-cache")],1111         &tmp,1112         len,1113     );1114     let _ = std::fs::remove_file(&tmp);1115 }1116 1117 #[allow(clippy::too_many_lines)]1118 fn handle_inbox(1119     stream: &mut TcpStream,1120     head: &Head,1121     rest: Vec<u8>,1122     kind: inbox::InboxType,1123     shared: &Shared,1124 ) {1125     // Inbox-route responses always carry `no-store` and always wait the1126     // fixed ack delay (success and failure alike — no timing oracle).1127     let answer = |stream: &mut TcpStream, code: u16, body: &str, extra: &[(&str, &str)]| {1128         std::thread::sleep(Duration::from_millis(inbox::ACK_DELAY_MS));1129         let mut merged: Vec<(&str, &str)> = vec![("Cache-Control", "no-store")];1130         merged.extend_from_slice(extra);1131         respond(1132             stream,1133             code,1134             "text/html; charset=utf-8",1135             &merged,1136             body.as_bytes(),1137         );1138     };1139     if head.method != "POST" {1140         settle(stream, rest.len(), content_length(head));1141         let body = inbox::inbox_error_page(1142             "method not allowed",1143             "method not allowed",1144             "This endpoint accepts POST only.",1145         );1146         answer(stream, 405, &body, &[("Allow", "POST")]);1147         return;1148     }1149     // Same-origin gate: browsers send Origin/Referer — mismatch fails1150     // closed; curl/agents send neither and pass. Generic page, no echo.1151     if !origin_allowed(head) {1152         settle(stream, rest.len(), content_length(head));1153         let body = inbox::inbox_error_page("forbidden", "forbidden", "Bad origin.");1154         answer(stream, 403, &body, &[]);1155         return;1156     }1157     let ctype = header(head, "content-type")1158         .unwrap_or("")1159         .to_ascii_lowercase();1160     if ctype.starts_with("multipart/") || !ctype.starts_with("application/x-www-form-urlencoded") {1161         settle(stream, rest.len(), content_length(head));1162         let body = inbox::inbox_error_page(1163             "bad request",1164             "bad request",1165             "This endpoint accepts urlencoded forms only.",1166         );1167         answer(stream, 400, &body, &[]);1168         return;1169     }1170     // No (parsable) Content-Length → drop the connection, no response.1171     let len: usize = match header(head, "content-length") {1172         Some(v) => match v.trim().parse() {1173             Ok(n) => n,1174             Err(_) => return,1175         },1176         None => return,1177     };1178     if len > shared.max_bytes {1179         settle(stream, rest.len(), Some(len));1180         let body = inbox::inbox_error_page("too large", "too large", "Submission too large.");1181         answer(stream, 413, &body, &[]);1182         return;1183     }1184     let mut body_bytes = rest;1185     if body_bytes.len() > len {1186         body_bytes.truncate(len);1187     }1188     while body_bytes.len() < len {1189         let mut chunk = [0u8; 8192];1190         let want = (len - body_bytes.len()).min(chunk.len());1191         match stream.read(&mut chunk[..want]) {1192             Ok(0) => break,1193             Ok(n) => body_bytes.extend_from_slice(&chunk[..n]),1194             Err(_) => return, // stall → drop1195         }1196     }1197     if body_bytes.len() != len {1198         return; // client went away early → drop1199     }1200     let text = match String::from_utf8(body_bytes) {1201         Ok(t) => t,1202         Err(_) => {1203             let body = inbox::inbox_error_page(1204                 "bad request",1205                 "bad request",1206                 "Submissions must be valid UTF-8 text.",1207             );1208             answer(stream, 400, &body, &[]);1209             return;1210         }1211     };1212     if text.contains('\0') {1213         let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1214         answer(stream, 400, &body, &[]);1215         return;1216     }1217     let pairs = match inbox::parse_form(&text) {1218         Some(p) => p,1219         None => {1220             let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1221             answer(stream, 400, &body, &[]);1222             return;1223         }1224     };1225     match inbox::validate_form_with_limits(kind, &pairs, shared.max_title, shared.max_body) {1226         inbox::Validation::Honeypot => {1227             // Silent `received` with a fresh (unstored) uuid — no oracle.1228             let page = inbox::received_page(&inbox::new_uuid4());1229             answer(stream, 200, &page, &[]);1230         }1231         inbox::Validation::Reject(code) => {1232             let body = inbox::inbox_error_page("bad request", "bad request", "Bad input.");1233             answer(stream, code, &body, &[]);1234         }1235         inbox::Validation::Accept(v) => {1236             let ip = stream1237                 .peer_addr()1238                 .map(|a| a.ip().to_string())1239                 .unwrap_or_else(|_| "unknown".to_string());1240             if !shared.limiter.check(&ip, &shared.salt) {1241                 let body =1242                     inbox::inbox_error_page("too many requests", "too many requests", "Slow down.");1243                 answer(stream, 429, &body, &[]);1244                 return;1245             }1246             if !inbox::check_quota_with(&shared.inbox_dir, shared.quota) {1247                 let body =1248                     inbox::inbox_error_page("unavailable", "unavailable", "Inbox full. Try later.");1249                 answer(stream, 503, &body, &[]);1250                 return;1251             }1252             match inbox::store_submission(&shared.inbox_dir, &v, &ip, &shared.salt) {1253                 Ok(uuid) => {1254                     let page = inbox::received_page(&uuid);1255                     answer(stream, 200, &page, &[]);1256                 }1257                 Err(_) => {1258                     // Full disk / IO failure: lose one POST, never the repo.1259                     let body = inbox::inbox_error_page(1260                         "unavailable",1261                         "unavailable",1262                         "Could not store that. Try later.",1263                     );1264                     answer(stream, 503, &body, &[]);1265                 }1266             }1267         }1268     }1269 }1270 1271 #[cfg(test)]1272 mod tests {1273     use super::*;1274 1275     #[test]1276     fn inbox_inside_www_refused() {1277         let www = Path::new("/tmp/hu13-www");1278         assert!(ensure_inbox_outside_www(www, &www.join("inbox")).is_err());1279         assert!(ensure_inbox_outside_www(www, www).is_err());1280         assert!(ensure_inbox_outside_www(www, Path::new("/tmp/hu13-inbox")).is_ok());1281         // `..` tricks resolve before comparison.1282         assert!(ensure_inbox_outside_www(www, &www.join("sub/../inbox")).is_err());1283         assert!(ensure_inbox_outside_www(www, &www.join("../hu13-inbox")).is_ok());1284     }1285 1286     #[test]1287     fn content_types_sane() {1288         assert!(content_type_for("a.html").starts_with("text/html"));1289         assert!(content_type_for("s.css").starts_with("text/css"));1290         assert!(content_type_for("x.json").starts_with("application/json"));1291         assert!(content_type_for("f.atom").contains("atom"));1292         // m6 favicon (f0rge issue #16): svg icon + web app manifest.1293         assert_eq!(content_type_for("favicon.svg"), "image/svg+xml");1294         assert_eq!(content_type_for("og.png"), "image/png");1295         assert_eq!(content_type_for("photo.JPG"), "image/jpeg");1296         assert_eq!(content_type_for("icon.ico"), "image/x-icon");1297         assert_eq!(1298             content_type_for("manifest.webmanifest"),1299             "application/manifest+json"1300         );1301         assert_eq!(content_type_for("b.bundle"), "application/octet-stream");1302     }1303 1304     #[cfg(unix)]1305     #[test]1306     fn inbox_symlink_into_www_refused() {1307         use std::os::unix::fs::symlink;1308         let base = std::env::temp_dir().join(format!(1309             "hu13-inbox-symlink-{}-{}",1310             std::process::id(),1311             std::time::SystemTime::now()1312                 .duration_since(std::time::UNIX_EPOCH)1313                 .map(|d| d.as_nanos())1314                 .unwrap_or(0)1315         ));1316         let www = base.join("www");1317         let private = www.join("private");1318         std::fs::create_dir_all(&private).unwrap();1319         let inbox = base.join("inbox-link");1320         symlink(&private, &inbox).unwrap();1321         assert!(ensure_inbox_outside_www(&www, &inbox).is_err());1322 1323         let parent = base.join("linked-parent");1324         symlink(&www, &parent).unwrap();1325         assert!(ensure_inbox_outside_www(&www, &parent.join("inbox")).is_err());1326 1327         let www_link = base.join("www-link");1328         symlink(&www, &www_link).unwrap();1329         assert!(ensure_inbox_outside_www(&www_link, &www.join("inbox")).is_err());1330         std::fs::remove_dir_all(&base).ok();1331     }1332 1333     #[test]1334     fn bad_paths_rejected() {1335         assert!(decode_path("/%2e%2e/x").is_some()); // decodes; `..` blocked later1336         assert!(decode_path("/a%00b").is_none(), "NUL rejected");1337         assert!(decode_path("/a%ZZ").is_none(), "bad escape rejected");1338         assert!(decode_path("/plain/path-1_~x").is_some());1339     }1340 1341     fn head_with(host: &str, origin: Option<&str>, referer: Option<&str>) -> Head {1342         let mut headers = vec![("host".to_string(), host.to_string())];1343         if let Some(o) = origin {1344             headers.push(("origin".to_string(), o.to_string()));1345         }1346         if let Some(r) = referer {1347             headers.push(("referer".to_string(), r.to_string()));1348         }1349         Head {1350             method: "POST".to_string(),1351             target: "/inbox/issue".to_string(),1352             headers,1353         }1354     }1355 1356     #[test]1357     fn origin_gate_allows_curl_blocks_forgery() {1358         assert!(origin_allowed(&head_with("127.0.0.1:1", None, None)));1359         assert!(origin_allowed(&head_with(1360             "127.0.0.1:39313",1361             Some("http://127.0.0.1:39313"),1362             None1363         )));1364         assert!(!origin_allowed(&head_with(1365             "127.0.0.1:39313",1366             Some("https://evil.example"),1367             None1368         )));1369         // Different port or scheme is a different origin (f0rge #101).1370         assert!(!origin_allowed(&head_with(1371             "127.0.0.1:39313",1372             Some("http://127.0.0.1:9999"),1373             None1374         )));1375         assert!(!origin_allowed(&head_with(1376             "127.0.0.1:39313",1377             Some("https://127.0.0.1:39313"),1378             None1379         )));1380         assert!(!origin_allowed(&head_with(1381             "a.example:443",1382             Some("https://a.example"),1383             None1384         )));1385         let mut proxied = head_with("a.example", Some("https://a.example"), None);1386         proxied1387             .headers1388             .push(("x-forwarded-proto".to_string(), "https".to_string()));1389         assert!(origin_allowed(&proxied), "TLS proxy can declare https");1390         assert!(!origin_allowed(&head_with(1391             "127.0.0.1:1",1392             Some("null"),1393             None1394         )));1395         // Referer fallback when Origin absent.1396         assert!(origin_allowed(&head_with(1397             "a.example",1398             None,1399             Some("http://a.example/page")1400         )));1401         assert!(!origin_allowed(&head_with(1402             "a.example",1403             None,1404             Some("http://b.example/page")1405         )));1406     }1407 }