krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

main: tests/serve.rs · raw

  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}