krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
1//! `serve`: URL resolution, reload-script injection, and one live server.
2//!
3//! `resolve` is the server's security boundary. A URL is attacker-controlled input even
4//! on a development server — a page someone is previewing can contain a link, an image
5//! or a fetch to anything — so it gets tested as the pure function it deliberately is,
6//! rather than only through a running server.
7
8use std::io::{Read, Write};
9use std::net::TcpStream;
10use std::sync::atomic::{AtomicU32, Ordering};
11use std::time::{Duration, Instant};
12
13use camino::{Utf8Path, Utf8PathBuf};
14
15use orgo::serve::{inject_reload_script, resolve, since_parameter};
16use orgo::site::BuildOptions;
17
18fn tmpdir(tag: &str) -> Utf8PathBuf {
19 static N: AtomicU32 = AtomicU32::new(0);
20 let n = N.fetch_add(1, Ordering::Relaxed);
21 let base = Utf8PathBuf::from_path_buf(std::env::temp_dir())
22 .expect("utf-8 temp dir")
23 .join(format!("orgo-serve-{}-{tag}-{n}", std::process::id()));
24 let _ = std::fs::remove_dir_all(&base);
25 std::fs::create_dir_all(&base).unwrap();
26 base
27}
28
29/// An output tree to serve.
30fn write_output(out: &Utf8PathBuf) {
31 std::fs::create_dir_all(out.join("blog")).unwrap();
32 std::fs::write(out.join("index.html"), "<html><body>home</body></html>").unwrap();
33 std::fs::write(out.join("blog/index.html"), "<html><body>blog</body></html>").unwrap();
34 std::fs::write(out.join("syntax.css"), "body{}").unwrap();
35 std::fs::write(out.join("a file.html"), "<html><body>spaced</body></html>").unwrap();
36}
37
38// ---------------------------------------------------------------------------
39// URL resolution
40// ---------------------------------------------------------------------------
41
42#[test]
43fn urls_resolve_to_files_and_directory_indexes() {
44 let out = tmpdir("resolve");
45 write_output(&out);
46
47 let at = |url: &str| resolve(&out, url).map(|p| p.strip_prefix(&out).unwrap().to_string());
48 assert_eq!(at("/"), Some("index.html".into()), "the root serves its index");
49 assert_eq!(at("/index.html"), Some("index.html".into()));
50 assert_eq!(at("/blog/"), Some("blog/index.html".into()), "a directory serves its index");
51 assert_eq!(at("/blog"), Some("blog/index.html".into()), "even without the slash");
52 assert_eq!(at("/syntax.css"), Some("syntax.css".into()));
53 assert_eq!(at("/index.html?v=1#frag"), Some("index.html".into()), "query and fragment");
54 assert_eq!(at("/a%20file.html"), Some("a file.html".into()), "percent-decoded");
55 assert_eq!(at("/nope.html"), None, "a file that does not exist");
56}
57
58/// The one that matters. A dev server sits on a laptop with a home directory behind it.
59#[test]
60fn no_url_can_escape_the_output_directory() {
61 let root = tmpdir("traversal");
62 let out = root.join("out");
63 write_output(&out);
64 // A file next to the output that must stay unreachable.
65 std::fs::write(root.join("secret.txt"), "private").unwrap();
66
67 for attack in [
68 "/../secret.txt",
69 "/../../etc/passwd",
70 "/blog/../../secret.txt",
71 "/%2e%2e/secret.txt",
72 "/%2E%2E/secret.txt",
73 "/..%2fsecret.txt",
74 "/....//secret.txt",
75 "/\\../secret.txt",
76 "//../secret.txt",
77 "/./../secret.txt",
78 "/blog/%2e%2e/%2e%2e/secret.txt",
79 ] {
80 assert_eq!(resolve(&out, attack), None, "{attack} must not resolve");
81 }
82 // And the file really was reachable by its true path, so the test is not vacuous.
83 assert!(root.join("secret.txt").is_file());
84}
85
86/// A percent-encoded NUL is a classic way to truncate a path in a C-backed API.
87#[test]
88fn embedded_nul_bytes_are_rejected() {
89 let out = tmpdir("nul");
90 write_output(&out);
91 assert_eq!(resolve(&out, "/index.html%00.txt"), None);
92 assert_eq!(resolve(&out, "/%00"), None);
93}
94
95/// `+` means a space in a query string, not in a path — decoding it would break the
96/// perfectly ordinary filename `c++.html`.
97#[test]
98fn plus_is_not_decoded_as_a_space() {
99 let out = tmpdir("plus");
100 write_output(&out);
101 std::fs::write(out.join("c++.html"), "<html><body>cpp</body></html>").unwrap();
102 assert_eq!(
103 resolve(&out, "/c++.html").map(|p| p.strip_prefix(&out).unwrap().to_string()),
104 Some("c++.html".into())
105 );
106}
107
108#[test]
109fn the_poll_parameter_is_read_from_the_query() {
110 assert_eq!(since_parameter("/__orgo/reload?since=7"), 7);
111 assert_eq!(since_parameter("/__orgo/reload?x=1&since=42"), 42);
112 assert_eq!(since_parameter("/__orgo/reload"), 0, "absent means start from zero");
113 assert_eq!(since_parameter("/__orgo/reload?since=nope"), 0, "unparseable means zero");
114}
115
116// ---------------------------------------------------------------------------
117// Reload script injection
118// ---------------------------------------------------------------------------
119
120/// The built site is what gets deployed. A dev server's JavaScript must never be in it,
121/// which is why injection happens on the way out rather than at build time.
122#[test]
123fn the_reload_script_is_injected_before_the_closing_body_tag() {
124 let page = b"<html><body><p>hi</p></body></html>";
125 let served = String::from_utf8(inject_reload_script(page, 3)).unwrap();
126
127 assert!(served.contains("<p>hi</p>"), "content is preserved");
128 assert!(served.contains("})(3)"), "the generation is baked in: {served}");
129 let script_at = served.find("<script>").unwrap();
130 let body_at = served.rfind("</body>").unwrap();
131 assert!(script_at < body_at, "the script goes inside the body: {served}");
132}
133
134/// A fragment with no `</body>` — a partial, or a hand-written page — still gets it.
135#[test]
136fn injection_falls_back_to_appending() {
137 let served = String::from_utf8(inject_reload_script(b"<p>bare</p>", 1)).unwrap();
138 assert!(served.starts_with("<p>bare</p>"));
139 assert!(served.contains("<script>"));
140}
141
142/// Binary content that happens to be served as HTML must not be corrupted into garbage.
143#[test]
144fn non_utf8_content_is_passed_through_untouched() {
145 let bytes = vec![0xff, 0xfe, 0x00, 0x42];
146 assert_eq!(inject_reload_script(&bytes, 1), bytes);
147}
148
149// ---------------------------------------------------------------------------
150// A live server
151// ---------------------------------------------------------------------------
152
153/// Minimal HTTP client: send a request, return the whole response.
154fn get(port: u16, path: &str, timeout: Duration) -> Option<String> {
155 let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?;
156 stream.set_read_timeout(Some(timeout)).ok()?;
157 write!(stream, "GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").ok()?;
158 let mut response = Vec::new();
159 stream.read_to_end(&mut response).ok()?;
160 Some(String::from_utf8_lossy(&response).into_owned())
161}
162
163fn start_server(src: &Utf8Path, out: &Utf8Path) -> u16 {
164 // Unique per test as well as per process: these tests run in parallel, and two of
165 // them sharing a port means one silently queries the other's site.
166 static NEXT: AtomicU32 = AtomicU32::new(0);
167 let port = 20000 + ((std::process::id() % 10000) as u16) + NEXT.fetch_add(1, Ordering::Relaxed) as u16;
168 let (s, o) = (src.to_owned(), out.to_owned());
169 std::thread::spawn(move || {
170 let _ = orgo::serve::run(&s, &o, &BuildOptions::default(), "127.0.0.1", port);
171 });
172 let deadline = Instant::now() + Duration::from_secs(20);
173 while Instant::now() < deadline {
174 if get(port, "/", Duration::from_millis(500)).is_some() {
175 return port;
176 }
177 std::thread::sleep(Duration::from_millis(100));
178 }
179 panic!("server did not start on port {port}");
180}
181
182/// Serve a real site, and confirm an edit both rebuilds and answers a waiting poll —
183/// which together are the whole point of the command.
184#[test]
185fn serving_a_site_reloads_the_browser_when_a_source_changes() {
186 let root = tmpdir("live");
187 let src = root.join("src");
188 std::fs::create_dir_all(&src).unwrap();
189 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap();
190 // Output outside the source, so the test's own writes cannot be mistaken for edits.
191 let out = root.join("out");
192 let port = start_server(&src, &out);
193
194 let home = get(port, "/", Duration::from_secs(5)).expect("a response");
195 assert!(home.contains("200 OK"), "{home}");
196 assert!(home.contains("First version."), "the page is served: {home}");
197 assert!(home.contains("__orgo/reload"), "with the reload script: {home}");
198 assert!(
199 !std::fs::read_to_string(out.join("index.html")).unwrap().contains("__orgo"),
200 "but the file on disk stays clean"
201 );
202
203 // A poll for a generation we already have must block, not answer immediately.
204 let poller = std::thread::spawn(move || {
205 let started = Instant::now();
206 let body = get(port, "/__orgo/reload?since=0", Duration::from_secs(30));
207 (started.elapsed(), body)
208 });
209 std::thread::sleep(Duration::from_millis(400));
210 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
211
212 let (waited, body) = poller.join().expect("poller");
213 let body = body.expect("poll response");
214 assert!(
215 waited >= Duration::from_millis(300),
216 "the poll should have waited for the edit, not returned at once ({waited:?})"
217 );
218 assert!(body.trim_end().ends_with('1'), "it reports the new generation: {body}");
219
220 let updated = get(port, "/", Duration::from_secs(5)).expect("a response");
221 assert!(updated.contains("Second version."), "and the rebuild is served: {updated}");
222}
223
224/// A traversal attempt against the running server, not just the resolver.
225#[test]
226fn the_running_server_refuses_to_escape_its_root() {
227 let root = tmpdir("livetraversal");
228 let src = root.join("src");
229 std::fs::create_dir_all(&src).unwrap();
230 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nBody.\n").unwrap();
231 std::fs::write(root.join("secret.txt"), "private").unwrap();
232 let out = root.join("out");
233 let port = start_server(&src, &out);
234
235 for attack in ["/../secret.txt", "/%2e%2e/secret.txt", "/../../etc/passwd"] {
236 let response = get(port, attack, Duration::from_secs(5)).expect("a response");
237 assert!(response.contains("404"), "{attack} should 404: {response}");
238 assert!(!response.contains("private"), "{attack} leaked the file: {response}");
239 }
240}