krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
20a54d84ca28c93ebb442f224df7398c348938dc
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T06:06:46Z
Cargo.lock | 33 ++++++- Cargo.toml | 3 +- README.md | 33 ++++++- src/lib.rs | 1 + src/main.rs | 39 ++++++++ src/serve.rs | 300 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/watch.rs | 15 ++- tests/serve.rs | 240 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 659 insertions(+), 5 deletions(-) @@ -85,6 +85,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "autocfg" version = "1.5.1" @@ -171,6 +177,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clap" version = "4.6.6" @@ -401,6 +413,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -657,7 +675,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.13.0" +version = "0.14.0" dependencies = [ "anyhow", "blake3", @@ -672,6 +690,7 @@ dependencies = [ "serde_json", "syntect", "thiserror", + "tiny_http", "toml", "walkdir", ] @@ -982,6 +1001,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.13.0" +version = "0.14.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -33,6 +33,7 @@ thiserror = "2" rayon = "1.12.0" toml = "1.1.4" notify = "8.2.0" +tiny_http = "0.12.0" [dev-dependencies] insta = { version = "1", features = ["json"] } @@ -356,6 +356,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | | **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** | | **15** | **Table of contents, section numbers, and org's `#+OPTIONS:` per-file switches** | **done** | +| **16** | **`serve`: development server with long-poll live reload, loopback-bound** | **done** | ### v0.2 in / out @@ -452,6 +453,32 @@ types keep their content verbatim. (`SCHEDULED:`/`DEADLINE:`), which render as ordinary paragraphs; and fixed-width `: ` lines. +## Serving + +```bash +cargo run -- serve my-site -o _site # http://127.0.0.1:3000 +``` + +Builds, watches, serves, and reloads the browser when a rebuild lands — the loop `watch` +leaves half-open. + +- **Loopback by default.** A dev server serves unreviewed drafts off your laptop, so + reaching the local network is something you ask for with `--host 0.0.0.0`, never + something you get. +- **The reload script is injected on the way out**, never written to disk. What you + deploy is the built site, and it must not carry a dev server's JavaScript. +- **Long-polling, not WebSockets or SSE.** The browser asks "anything since generation + N?" and the server holds the request until there is. Instant like a push, no protocol + beyond ordinary HTTP, and no dependency. A streamed response would have been more + elegant and does not work: tiny_http buffers a response until its body ends, so a body + that never ends never reaches the client. +- A reload only follows a **successful** rebuild. Reloading onto a stale page because the + build just failed tells you nothing; the error is already on your terminal. + +URL resolution is the server's security boundary and is written as a pure function with +its own tests: `..`, percent-encoded `..`, backslashes, absolute paths and embedded NULs +all resolve to nothing rather than to somewhere outside the output directory. + ## Watching ```bash @@ -636,20 +663,22 @@ Parser is hand-written recursive descent (not `nom`/`chumsky`/`pest` — org is line-oriented and context-sensitive, not clean CFG). Key crates: `syntect` (syntax highlighting, behind a `Highlighter` trait so tree-sitter can be swapped in later), `minijinja` (runtime templates), `blake3` (content/cache hashing), `rayon` (parallel -PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. +PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `tiny_http` (the `serve` +development server), `toml` (config), `chrono`, `camino`, `walkdir`, `clap`, `anyhow`/`thiserror`. `insta` for snapshot tests, and `emacs --batch` — optional, and only for the oracle. ## Build & test ``` cargo build -cargo test # 142 tests +cargo test # 152 tests cargo run -- init my-site # scaffold a new site cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) cargo run -- audit fixtures/site # corpus audit (Phase 0) cargo run -- build fixtures/site -o _site --no-cache # force a full rebuild cargo run -- watch fixtures/site -o _site # rebuild on filesystem events +cargo run -- serve fixtures/site -o _site # ... and serve with live reload cargo run -- clean _site # remove output + cache ``` @@ -16,6 +16,7 @@ pub mod model; pub mod parser; pub mod render; pub mod resolve; +pub mod serve; pub mod site; pub mod template; pub mod util; @@ -64,6 +64,27 @@ enum Command { #[arg(long)] drafts: bool, }, + /// Serve the built site locally, rebuilding and reloading the browser on change. + Serve { + /// Source directory to build and watch. + input: Utf8PathBuf, + /// Output directory to serve. + #[arg(short, long)] + output: Utf8PathBuf, + /// Port to listen on. + #[arg(short, long, default_value_t = 3000)] + port: u16, + /// Address to bind. Defaults to loopback; set `0.0.0.0` to expose the server to + /// your network, which also exposes any drafts you are building. + #[arg(long, default_value = "127.0.0.1")] + host: String, + /// Include pages marked `#+DRAFT:`. + #[arg(long)] + drafts: bool, + /// Config file to use, overriding `org-ssg.toml` in the source directory. + #[arg(long, value_name = "FILE")] + config: Option<Utf8PathBuf>, + }, /// Remove the build output directory (which holds the cache manifest). Clean { /// Output directory to remove. @@ -145,6 +166,24 @@ fn main() -> Result<()> { print!("{}", org_ssg::audit::report(&result)); Ok(()) } + Command::Serve { + input, + output, + port, + host, + drafts, + config, + } => org_ssg::serve::run( + &input, + &output, + &BuildOptions { + drafts, + config_path: config, + ..Default::default() + }, + &host, + port, + ), Command::Init { directory } => init(&directory), Command::Clean { output } => { if output.exists() { new file mode 100644 @@ -0,0 +1,300 @@ +//! `serve`: a development server over the built site, with browser live reload. +//! +//! `watch` rebuilds but leaves you to serve the output and press reload yourself. This +//! closes that loop: build, watch, serve, and push a reload to the browser when a +//! rebuild lands. +//! +//! Three decisions shape it: +//! +//! 1. **Loopback by default.** A development server binds `127.0.0.1`, not `0.0.0.0`. +//! It serves unreviewed drafts off someone's laptop, and exposing that to the local +//! network should be a thing you ask for (`--host`), never a thing you get. +//! 2. **The reload script is injected at serve time**, never written to disk. The built +//! site is what you deploy, and it must not carry a dev server's JavaScript. +//! 3. **Long-polling, not WebSockets or SSE.** The browser asks "has anything changed +//! since generation N?" and the server holds the request open until something has. +//! That is instant like a push, needs no protocol beyond ordinary HTTP, and — unlike +//! a streamed response — completes, which is what makes it work at all: tiny_http +//! buffers a response until its body ends, so a body that never ends never reaches +//! the client. Long-polling was the version of this that worked. + +use std::io; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; + +use anyhow::Result; +use camino::{Utf8Path, Utf8PathBuf}; +use tiny_http::{Header, Request, Response, Server, StatusCode}; + +use crate::site::{build_site, BuildOptions}; + +/// Where the browser subscribes for reload events. Namespaced so it cannot collide with +/// a real page. +pub const RELOAD_PATH: &str = "/__org-ssg/reload"; + +/// How long a poll waits before answering "nothing yet". Long enough that an idle tab is +/// nearly silent, short enough to stay under any proxy or browser idle timeout. +const POLL_TIMEOUT: Duration = Duration::from_secs(25); + +/// The script injected into served HTML, carrying the generation the page was built +/// from. +/// +/// Baking the generation in is what makes this race-free: if a rebuild lands between the +/// page being served and the first poll going out, the server answers immediately rather +/// than the tab sitting on stale content until the *next* edit. +fn reload_script(generation: u64) -> String { + format!( + "\n<script>(function p(n){{fetch(\"{RELOAD_PATH}?since=\"+n)\ + .then(function(r){{return r.json()}})\ + .then(function(g){{g>n?location.reload():p(g)}})\ + .catch(function(){{setTimeout(function(){{p(n)}},1000)}})}})({generation})</script>\n" + ) +} + +/// A build counter that event streams wait on. +#[derive(Default)] +struct BuildSignal { + generation: Mutex<u64>, + changed: Condvar, +} + +impl BuildSignal { + fn bump(&self) { + *self.generation.lock().expect("build signal") += 1; + self.changed.notify_all(); + } +} + +/// Run the development server until interrupted. +pub fn run( + src: &Utf8Path, + out: &Utf8Path, + opts: &BuildOptions, + host: &str, + port: u16, +) -> Result<()> { + if !src.is_dir() { + anyhow::bail!("serve requires a source directory: serve <src-dir> -o <out-dir>"); + } + let report = build_site(src, out, opts)?; + + let address = format!("{host}:{port}"); + let server = Server::http(&address).map_err(|e| { + anyhow::anyhow!("cannot listen on {address}: {e}. Is something already using port {port}?") + })?; + let server = Arc::new(server); + let signal = Arc::new(BuildSignal::default()); + let root: Utf8PathBuf = out.to_owned(); + + println!( + "serving {} page(s) from {out} at http://{address}/ — Ctrl-C to stop.", + report.pages.len() + ); + + // Rebuild in the background; the main thread serves. + { + let (src, out, opts, signal) = ( + src.to_owned(), + out.to_owned(), + opts.clone(), + Arc::clone(&signal), + ); + std::thread::spawn(move || { + let result = crate::watch::run_with(&src, &out, &opts, |built| { + // Reload on a *successful* rebuild only. Reloading onto a stale page + // because the build just failed tells the author nothing; the error is + // already on their terminal. + if built.is_ok() { + signal.bump(); + } + }); + if let Err(e) = result { + eprintln!("watch stopped: {e:#}"); + } + }); + } + + // A thread per request. The volume is one developer's browser, and an event stream + // occupies its thread for as long as the tab is open — which a fixed pool would let + // starve everything else. + for request in server.incoming_requests() { + let root = root.clone(); + let signal = Arc::clone(&signal); + std::thread::spawn(move || { + if let Err(e) = handle(request, &root, &signal) { + // A browser closing a tab mid-response is routine, not a problem. + if e.kind() != io::ErrorKind::BrokenPipe { + eprintln!("serve: {e}"); + } + } + }); + } + Ok(()) +} + +fn handle(request: Request, root: &Utf8Path, signal: &Arc<BuildSignal>) -> io::Result<()> { + let url = request.url().to_string(); + if url.split(['?', '#']).next() == Some(RELOAD_PATH) { + let since = since_parameter(&url); + return serve_poll(request, signal, since); + } + match resolve(root, &url) { + Some(path) => serve_file(request, &path, signal), + None => request.respond( + Response::from_string("404 not found") + .with_status_code(StatusCode(404)) + .with_header(header("Content-Type", "text/plain; charset=utf-8")), + ), + } +} + +fn serve_file(request: Request, path: &Utf8Path, signal: &Arc<BuildSignal>) -> io::Result<()> { + let Ok(bytes) = std::fs::read(path) else { + return request.respond( + Response::from_string("404 not found").with_status_code(StatusCode(404)), + ); + }; + let mime = mime_type(path); + let bytes = if mime.starts_with("text/html") { + let generation = *signal.generation.lock().expect("build signal"); + inject_reload_script(&bytes, generation) + } else { + bytes + }; + request.respond( + Response::from_data(bytes) + .with_header(header("Content-Type", mime)) + // A dev server must never be cached, or an edit appears not to have landed. + .with_header(header("Cache-Control", "no-store")), + ) +} + +/// Put the reload script just before `</body>`, or at the end if there is none. +pub fn inject_reload_script(bytes: &[u8], generation: u64) -> Vec<u8> { + let Ok(text) = std::str::from_utf8(bytes) else { + return bytes.to_vec(); + }; + let script = reload_script(generation); + match text.rfind("</body>") { + Some(at) => format!("{}{script}{}", &text[..at], &text[at..]).into_bytes(), + None => format!("{text}{script}").into_bytes(), + } +} + +/// Answer a poll: block until the build generation passes `since`, then report it. +/// +/// A timeout answers with the *current* generation, which the client compares itself — +/// so a slow answer is indistinguishable from a fast one and no event can be missed. +fn serve_poll(request: Request, signal: &Arc<BuildSignal>, since: u64) -> io::Result<()> { + let guard = signal.generation.lock().expect("build signal"); + let (guard, _) = signal + .changed + .wait_timeout_while(guard, POLL_TIMEOUT, |generation| *generation <= since) + .expect("build signal"); + let generation = *guard; + drop(guard); + + request.respond( + Response::from_string(generation.to_string()) + .with_header(header("Content-Type", "application/json")) + .with_header(header("Cache-Control", "no-store")), + ) +} + +/// The `since=N` parameter of a poll request. +pub fn since_parameter(url: &str) -> u64 { + url.split_once('?') + .map(|(_, query)| query) + .into_iter() + .flat_map(|query| query.split('&')) + .find_map(|pair| pair.strip_prefix("since=")) + .and_then(|value| value.parse().ok()) + .unwrap_or(0) +} + +/// Map a request URL onto a file inside `root`, or `None` if it does not name one. +/// +/// This is the server's security boundary, so it is a pure function with its own tests. +/// A URL is attacker-controlled input even on a development server: `..` segments, +/// percent-encoded `..`, absolute paths and backslashes all have to resolve to nothing +/// rather than to somewhere outside the output directory. +pub fn resolve(root: &Utf8Path, url: &str) -> Option<Utf8PathBuf> { + let path = url.split(['?', '#']).next().unwrap_or(""); + let decoded = percent_decode(path); + + // Build the path from scratch out of accepted segments. Normalizing a joined path + // afterwards is the version of this that has bugs: it is far easier to reason about + // a list that never contained a `..` than about removing one correctly. + let mut segments: Vec<&str> = Vec::new(); + for segment in decoded.split(['/', '\\']) { + match segment { + "" | "." => {} + ".." => return None, + // A NUL or a path separator that survived decoding is not a filename. + s if s.contains('\0') => return None, + s => segments.push(s), + } + } + + let mut candidate = root.to_owned(); + for segment in &segments { + candidate.push(segment); + } + // Directories, and the bare root, serve their index. + if decoded.ends_with('/') || segments.is_empty() || candidate.is_dir() { + candidate.push("index.html"); + } + // Defence in depth: whatever the segment logic did, the result must be inside root. + if !candidate.starts_with(root) { + return None; + } + candidate.is_file().then_some(candidate) +} + +/// Decode `%XX` escapes. `+` is left alone: it means a space in a query string, not in a +/// path, and turning `a+b.html` into `a b.html` would break a real filename. +fn percent_decode(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out: Vec<u8> = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn mime_type(path: &Utf8Path) -> &'static str { + match path.extension().unwrap_or("").to_ascii_lowercase().as_str() { + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" => "text/javascript; charset=utf-8", + "json" => "application/json", + "xml" | "rss" | "atom" => "application/xml; charset=utf-8", + "txt" => "text/plain; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + "ico" => "image/x-icon", + "woff2" => "font/woff2", + "woff" => "font/woff", + "ttf" => "font/ttf", + "pdf" => "application/pdf", + _ => "application/octet-stream", + } +} + +fn header(name: &str, value: &str) -> Header { + Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("static header is valid") +} @@ -150,6 +150,17 @@ fn is_editor_scratch(name: &str) -> bool { /// Build once, then rebuild whenever the source changes. Runs until interrupted. pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> { + run_with(src, out, opts, |_| {}) +} + +/// As [`run`], calling `on_rebuild` after each rebuild attempt — which is how `serve` +/// learns that it has something new to tell the browser. +pub fn run_with( + src: &Utf8Path, + out: &Utf8Path, + opts: &BuildOptions, + mut on_rebuild: impl FnMut(&Result<crate::site::SiteReport>), +) -> Result<()> { if !src.is_dir() { anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>"); } @@ -186,7 +197,8 @@ pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> { } let summary = summarize(&changed); - match build_site(src, out, opts) { + let result = build_site(src, out, opts); + match &result { Ok(report) => println!( "{summary}: {} rendered, {} cached", report.rendered.len(), @@ -196,6 +208,7 @@ pub fn run(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<()> { // half-saved file, and the next keystroke fixes it. Err(e) => eprintln!("{summary}: build failed: {e:#}"), } + on_rebuild(&result); } } new file mode 100644 @@ -0,0 +1,240 @@ +//! `serve`: URL resolution, reload-script injection, and one live server. +//! +//! `resolve` is the server's security boundary. A URL is attacker-controlled input even +//! on a development server — a page someone is previewing can contain a link, an image +//! or a fetch to anything — so it gets tested as the pure function it deliberately is, +//! rather than only through a running server. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use camino::{Utf8Path, Utf8PathBuf}; + +use org_ssg::serve::{inject_reload_script, resolve, since_parameter}; +use org_ssg::site::BuildOptions; + +fn tmpdir(tag: &str) -> Utf8PathBuf { + static N: AtomicU32 = AtomicU32::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let base = Utf8PathBuf::from_path_buf(std::env::temp_dir()) + .expect("utf-8 temp dir") + .join(format!("org-ssg-serve-{}-{tag}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + base +} + +/// An output tree to serve. +fn write_output(out: &Utf8PathBuf) { + std::fs::create_dir_all(out.join("blog")).unwrap(); + std::fs::write(out.join("index.html"), "<html><body>home</body></html>").unwrap(); + std::fs::write(out.join("blog/index.html"), "<html><body>blog</body></html>").unwrap(); + std::fs::write(out.join("syntax.css"), "body{}").unwrap(); + std::fs::write(out.join("a file.html"), "<html><body>spaced</body></html>").unwrap(); +} + +// --------------------------------------------------------------------------- +// URL resolution +// --------------------------------------------------------------------------- + +#[test] +fn urls_resolve_to_files_and_directory_indexes() { + let out = tmpdir("resolve"); + write_output(&out); + + let at = |url: &str| resolve(&out, url).map(|p| p.strip_prefix(&out).unwrap().to_string()); + assert_eq!(at("/"), Some("index.html".into()), "the root serves its index"); + assert_eq!(at("/index.html"), Some("index.html".into())); + assert_eq!(at("/blog/"), Some("blog/index.html".into()), "a directory serves its index"); + assert_eq!(at("/blog"), Some("blog/index.html".into()), "even without the slash"); + assert_eq!(at("/syntax.css"), Some("syntax.css".into())); + assert_eq!(at("/index.html?v=1#frag"), Some("index.html".into()), "query and fragment"); + assert_eq!(at("/a%20file.html"), Some("a file.html".into()), "percent-decoded"); + assert_eq!(at("/nope.html"), None, "a file that does not exist"); +} + +/// The one that matters. A dev server sits on a laptop with a home directory behind it. +#[test] +fn no_url_can_escape_the_output_directory() { + let root = tmpdir("traversal"); + let out = root.join("out"); + write_output(&out); + // A file next to the output that must stay unreachable. + std::fs::write(root.join("secret.txt"), "private").unwrap(); + + for attack in [ + "/../secret.txt", + "/../../etc/passwd", + "/blog/../../secret.txt", + "/%2e%2e/secret.txt", + "/%2E%2E/secret.txt", + "/..%2fsecret.txt", + "/....//secret.txt", + "/\\../secret.txt", + "//../secret.txt", + "/./../secret.txt", + "/blog/%2e%2e/%2e%2e/secret.txt", + ] { + assert_eq!(resolve(&out, attack), None, "{attack} must not resolve"); + } + // And the file really was reachable by its true path, so the test is not vacuous. + assert!(root.join("secret.txt").is_file()); +} + +/// A percent-encoded NUL is a classic way to truncate a path in a C-backed API. +#[test] +fn embedded_nul_bytes_are_rejected() { + let out = tmpdir("nul"); + write_output(&out); + assert_eq!(resolve(&out, "/index.html%00.txt"), None); + assert_eq!(resolve(&out, "/%00"), None); +} + +/// `+` means a space in a query string, not in a path — decoding it would break the +/// perfectly ordinary filename `c++.html`. +#[test] +fn plus_is_not_decoded_as_a_space() { + let out = tmpdir("plus"); + write_output(&out); + std::fs::write(out.join("c++.html"), "<html><body>cpp</body></html>").unwrap(); + assert_eq!( + resolve(&out, "/c++.html").map(|p| p.strip_prefix(&out).unwrap().to_string()), + Some("c++.html".into()) + ); +} + +#[test] +fn the_poll_parameter_is_read_from_the_query() { + assert_eq!(since_parameter("/__org-ssg/reload?since=7"), 7); + assert_eq!(since_parameter("/__org-ssg/reload?x=1&since=42"), 42); + assert_eq!(since_parameter("/__org-ssg/reload"), 0, "absent means start from zero"); + assert_eq!(since_parameter("/__org-ssg/reload?since=nope"), 0, "unparseable means zero"); +} + +// --------------------------------------------------------------------------- +// Reload script injection +// --------------------------------------------------------------------------- + +/// The built site is what gets deployed. A dev server's JavaScript must never be in it, +/// which is why injection happens on the way out rather than at build time. +#[test] +fn the_reload_script_is_injected_before_the_closing_body_tag() { + let page = b"<html><body><p>hi</p></body></html>"; + let served = String::from_utf8(inject_reload_script(page, 3)).unwrap(); + + assert!(served.contains("<p>hi</p>"), "content is preserved"); + assert!(served.contains("})(3)"), "the generation is baked in: {served}"); + let script_at = served.find("<script>").unwrap(); + let body_at = served.rfind("</body>").unwrap(); + assert!(script_at < body_at, "the script goes inside the body: {served}"); +} + +/// A fragment with no `</body>` — a partial, or a hand-written page — still gets it. +#[test] +fn injection_falls_back_to_appending() { + let served = String::from_utf8(inject_reload_script(b"<p>bare</p>", 1)).unwrap(); + assert!(served.starts_with("<p>bare</p>")); + assert!(served.contains("<script>")); +} + +/// Binary content that happens to be served as HTML must not be corrupted into garbage. +#[test] +fn non_utf8_content_is_passed_through_untouched() { + let bytes = vec![0xff, 0xfe, 0x00, 0x42]; + assert_eq!(inject_reload_script(&bytes, 1), bytes); +} + +// --------------------------------------------------------------------------- +// A live server +// --------------------------------------------------------------------------- + +/// Minimal HTTP client: send a request, return the whole response. +fn get(port: u16, path: &str, timeout: Duration) -> Option<String> { + let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?; + stream.set_read_timeout(Some(timeout)).ok()?; + write!(stream, "GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").ok()?; + let mut response = Vec::new(); + stream.read_to_end(&mut response).ok()?; + Some(String::from_utf8_lossy(&response).into_owned()) +} + +fn start_server(src: &Utf8Path, out: &Utf8Path) -> u16 { + // Unique per test as well as per process: these tests run in parallel, and two of + // them sharing a port means one silently queries the other's site. + static NEXT: AtomicU32 = AtomicU32::new(0); + let port = 20000 + ((std::process::id() % 10000) as u16) + NEXT.fetch_add(1, Ordering::Relaxed) as u16; + let (s, o) = (src.to_owned(), out.to_owned()); + std::thread::spawn(move || { + let _ = org_ssg::serve::run(&s, &o, &BuildOptions::default(), "127.0.0.1", port); + }); + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline { + if get(port, "/", Duration::from_millis(500)).is_some() { + return port; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("server did not start on port {port}"); +} + +/// Serve a real site, and confirm an edit both rebuilds and answers a waiting poll — +/// which together are the whole point of the command. +#[test] +fn serving_a_site_reloads_the_browser_when_a_source_changes() { + let root = tmpdir("live"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nFirst version.\n").unwrap(); + // Output outside the source, so the test's own writes cannot be mistaken for edits. + let out = root.join("out"); + let port = start_server(&src, &out); + + let home = get(port, "/", Duration::from_secs(5)).expect("a response"); + assert!(home.contains("200 OK"), "{home}"); + assert!(home.contains("First version."), "the page is served: {home}"); + assert!(home.contains("__org-ssg/reload"), "with the reload script: {home}"); + assert!( + !std::fs::read_to_string(out.join("index.html")).unwrap().contains("__org-ssg"), + "but the file on disk stays clean" + ); + + // A poll for a generation we already have must block, not answer immediately. + let poller = std::thread::spawn(move || { + let started = Instant::now(); + let body = get(port, "/__org-ssg/reload?since=0", Duration::from_secs(30)); + (started.elapsed(), body) + }); + std::thread::sleep(Duration::from_millis(400)); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap(); + + let (waited, body) = poller.join().expect("poller"); + let body = body.expect("poll response"); + assert!( + waited >= Duration::from_millis(300), + "the poll should have waited for the edit, not returned at once ({waited:?})" + ); + assert!(body.trim_end().ends_with('1'), "it reports the new generation: {body}"); + + let updated = get(port, "/", Duration::from_secs(5)).expect("a response"); + assert!(updated.contains("Second version."), "and the rebuild is served: {updated}"); +} + +/// A traversal attempt against the running server, not just the resolver. +#[test] +fn the_running_server_refuses_to_escape_its_root() { + let root = tmpdir("livetraversal"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nBody.\n").unwrap(); + std::fs::write(root.join("secret.txt"), "private").unwrap(); + let out = root.join("out"); + let port = start_server(&src, &out); + + for attack in ["/../secret.txt", "/%2e%2e/secret.txt", "/../../etc/passwd"] { + let response = get(port, attack, Duration::from_secs(5)).expect("a response"); + assert!(response.contains("404"), "{attack} should 404: {response}"); + assert!(!response.contains("private"), "{attack} leaked the file: {response}"); + } +}