| 1 | //! The site as shipped: a code's page, the web build's files, nothing outside them, and crash |
| 2 | //! reports kept. |
| 3 | |
| 4 | use std::{ |
| 5 | io::{BufRead, BufReader, Read, Write}, |
| 6 | net::{SocketAddr, TcpStream}, |
| 7 | process::{Child, Command, Stdio}, |
| 8 | }; |
| 9 | |
| 10 | struct Site { |
| 11 | child: Child, |
| 12 | address: SocketAddr, |
| 13 | } |
| 14 | |
| 15 | impl Site { |
| 16 | fn start(root: &std::path::Path, options: &[&str]) -> Site { |
| 17 | let mut child = Command::new(env!("CARGO_BIN_EXE_snowbound-site")) |
| 18 | .args(["--listen", "127.0.0.1:0", "--root"]) |
| 19 | .arg(root) |
| 20 | .args(options) |
| 21 | .env_clear() |
| 22 | .stdout(Stdio::piped()) |
| 23 | .spawn() |
| 24 | .unwrap(); |
| 25 | let mut line = String::new(); |
| 26 | BufReader::new(child.stdout.take().unwrap()) |
| 27 | .read_line(&mut line) |
| 28 | .unwrap(); |
| 29 | let address = line |
| 30 | .split(' ') |
| 31 | .nth(4) |
| 32 | .unwrap() |
| 33 | .trim_end_matches(',') |
| 34 | .parse() |
| 35 | .unwrap(); |
| 36 | Site { child, address } |
| 37 | } |
| 38 | |
| 39 | fn get(&self, path: &str) -> String { |
| 40 | self.send(format!("GET {path} HTTP/1.1\r\nHost: site\r\n\r\n").as_bytes()) |
| 41 | } |
| 42 | |
| 43 | /// The status line answering a crash report of `body` as `kind`, with `extra` headers. |
| 44 | fn report(&self, kind: &str, body: &str, extra: &str) -> String { |
| 45 | let request = format!( |
| 46 | "POST /crash HTTP/1.1\r\nHost: site\r\nContent-Type: {kind}\r\nContent-Length: {}\r\n{extra}\r\n{body}", |
| 47 | body.len() |
| 48 | ); |
| 49 | let response = self.send(request.as_bytes()); |
| 50 | assert!(!response.contains(body), "{response}"); |
| 51 | response.lines().next().unwrap().to_owned() |
| 52 | } |
| 53 | |
| 54 | fn send(&self, request: &[u8]) -> String { |
| 55 | let mut stream = TcpStream::connect(self.address).unwrap(); |
| 56 | stream.write_all(request).unwrap(); |
| 57 | let mut response = Vec::new(); |
| 58 | let _ = stream.read_to_end(&mut response); |
| 59 | String::from_utf8_lossy(&response).into_owned() |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | impl Drop for Site { |
| 64 | fn drop(&mut self) { |
| 65 | let _ = self.child.kill(); |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | #[test] |
| 70 | fn a_code_gets_its_page_and_the_web_build_its_files() { |
| 71 | let folder = tempfile::tempdir().unwrap(); |
| 72 | let root = folder.path().join("web"); |
| 73 | std::fs::create_dir_all(root.join("b/0f3a")).unwrap(); |
| 74 | std::fs::create_dir_all(root.join("fonts")).unwrap(); |
| 75 | std::fs::write(root.join("index.html"), "<p>the app</p>").unwrap(); |
| 76 | std::fs::write(root.join("b/0f3a/snowbound_bg.wasm"), b"\0asm").unwrap(); |
| 77 | std::fs::write(root.join("fonts/Face.ttf"), b"font").unwrap(); |
| 78 | std::fs::write(folder.path().join("secret.txt"), "secret").unwrap(); |
| 79 | let code = relay::code::format(412, "4MZ9XR").unwrap(); |
| 80 | let site = Site::start(&root, &[]); |
| 81 | let page = site.get(&format!("/{code}")); |
| 82 | assert!(page.starts_with("HTTP/1.1 200"), "{page}"); |
| 83 | assert!(page.contains(&format!("snowbound://join/{code}")), "{page}"); |
| 84 | assert!(!page.contains("Open in Web"), "no web build joins yet"); |
| 85 | // Typed loosely, the code's page names it as shown. |
| 86 | let loose = site.get(&format!("/{}", code.to_lowercase().replace('-', ""))); |
| 87 | assert!(loose.contains(&format!("<code>{code}</code>")), "{loose}"); |
| 88 | // The page that names a build is checked every load; a build is kept for good. |
| 89 | let index = site.get("/"); |
| 90 | assert!( |
| 91 | index.ends_with("<p>the app</p>") && index.contains("no-cache"), |
| 92 | "{index}" |
| 93 | ); |
| 94 | let wasm = site.get("/b/0f3a/snowbound_bg.wasm"); |
| 95 | assert!(wasm.contains("Content-Type: application/wasm"), "{wasm}"); |
| 96 | assert!(wasm.contains("max-age=31536000, immutable"), "{wasm}"); |
| 97 | assert!(site.get("/fonts/Face.ttf").contains("max-age=86400")); |
| 98 | let missing = site.get("/b/9999/snowbound_bg.wasm"); |
| 99 | assert!( |
| 100 | missing.starts_with("HTTP/1.1 404") && missing.contains("no-cache"), |
| 101 | "{missing}" |
| 102 | ); |
| 103 | for outside in [ |
| 104 | "/../secret.txt", |
| 105 | "/%2e%2e/secret.txt", |
| 106 | "/b/../../secret.txt", |
| 107 | ] { |
| 108 | assert!(site.get(outside).starts_with("HTTP/1.1 404"), "{outside}"); |
| 109 | } |
| 110 | // A mistyped code is no code: it is looked for as a file. |
| 111 | let typo = format!("/8{}", &code[1..]); |
| 112 | assert!(site.get(&typo).starts_with("HTTP/1.1 404")); |
| 113 | drop(site); |
| 114 | let site = Site::start( |
| 115 | &root, |
| 116 | &["--web", "https://snowbound.paperclover.net/?join="], |
| 117 | ); |
| 118 | let page = site.get(&format!("/{code}")); |
| 119 | assert!( |
| 120 | page.contains(&format!("https://snowbound.paperclover.net/?join={code}")), |
| 121 | "{page}" |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn crash_reports_are_kept_small_and_few_and_never_echoed() { |
| 127 | let folder = tempfile::tempdir().unwrap(); |
| 128 | let root = folder.path().join("web"); |
| 129 | std::fs::create_dir_all(&root).unwrap(); |
| 130 | let site = Site::start(&root, &["--trust-forwarded", "true"]); |
| 131 | let report = "Snowbound 2026-10-03-r46\npanicked at crates/canvas/src/view.rs:1:1"; |
| 132 | assert_eq!( |
| 133 | site.report("text/plain; charset=utf-8", report, ""), |
| 134 | "HTTP/1.1 200 OK" |
| 135 | ); |
| 136 | // Kept beside the root, as sent, under a name that sorts by when it came. |
| 137 | let crashes = folder.path().join("crashes"); |
| 138 | let kept: Vec<_> = std::fs::read_dir(&crashes) |
| 139 | .unwrap() |
| 140 | .map(|entry| entry.unwrap().path()) |
| 141 | .collect(); |
| 142 | assert_eq!(kept.len(), 1); |
| 143 | assert_eq!(std::fs::read_to_string(&kept[0]).unwrap(), report); |
| 144 | let name = kept[0].file_name().unwrap().to_str().unwrap(); |
| 145 | assert!(name.starts_with("20") && name.ends_with(".txt"), "{name}"); |
| 146 | |
| 147 | assert_eq!( |
| 148 | site.report("application/json", r#"{"panic":"x"}"#, ""), |
| 149 | "HTTP/1.1 200 OK" |
| 150 | ); |
| 151 | assert_eq!( |
| 152 | site.report("text/html", "<p>no</p>", ""), |
| 153 | "HTTP/1.1 415 Unsupported Media Type" |
| 154 | ); |
| 155 | // Refused from its head alone, before any of it is read. |
| 156 | let large = site.send( |
| 157 | format!( |
| 158 | "POST /crash HTTP/1.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n", |
| 159 | relay::site::REPORT + 1 |
| 160 | ) |
| 161 | .as_bytes(), |
| 162 | ); |
| 163 | assert!(large.starts_with("HTTP/1.1 413"), "{large}"); |
| 164 | let chunked = site.send( |
| 165 | b"POST /crash HTTP/1.1\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n", |
| 166 | ); |
| 167 | assert!(chunked.starts_with("HTTP/1.1 411"), "{chunked}"); |
| 168 | assert_eq!(site.report("text/plain", "\u{fffd}", ""), "HTTP/1.1 200 OK"); |
| 169 | let invalid = site.send( |
| 170 | b"POST /crash HTTP/1.1\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\n\xff\xfe", |
| 171 | ); |
| 172 | assert!(invalid.starts_with("HTTP/1.1 400"), "{invalid}"); |
| 173 | let empty = |
| 174 | site.send(b"POST /crash HTTP/1.1\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n"); |
| 175 | assert!(empty.starts_with("HTTP/1.1 400"), "{empty}"); |
| 176 | |
| 177 | // One sender is held to a few an hour; another, by the proxy's word, still gets in. |
| 178 | let sent = (0..relay::site::PER_ADDRESS) |
| 179 | .map(|_| site.report("text/plain", "again", "")) |
| 180 | .filter(|status| status == "HTTP/1.1 200 OK") |
| 181 | .count(); |
| 182 | assert_eq!(sent, relay::site::PER_ADDRESS - 4); |
| 183 | assert_eq!( |
| 184 | site.report("text/plain", "again", ""), |
| 185 | "HTTP/1.1 429 Too Many Requests" |
| 186 | ); |
| 187 | assert_eq!( |
| 188 | site.report( |
| 189 | "text/plain", |
| 190 | "elsewhere", |
| 191 | "X-Forwarded-For: 203.0.113.9\r\n" |
| 192 | ), |
| 193 | "HTTP/1.1 200 OK" |
| 194 | ); |
| 195 | assert_eq!( |
| 196 | std::fs::read_dir(&crashes).unwrap().count(), |
| 197 | relay::site::PER_ADDRESS |
| 198 | ); |
| 199 | assert!(site.get("/crash").starts_with("HTTP/1.1 404")); |
| 200 | } |