1//! The site as shipped: a code's page, the web build's files, nothing outside them, and crash
2//! reports kept.
3
4use std::{
5 io::{BufRead, BufReader, Read, Write},
6 net::{SocketAddr, TcpStream},
7 process::{Child, Command, Stdio},
8};
9
10struct Site {
11 child: Child,
12 address: SocketAddr,
13}
14
15impl 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
63impl Drop for Site {
64 fn drop(&mut self) {
65 let _ = self.child.kill();
66 }
67}
68
69#[test]
70fn 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]
126fn 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}