//! `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 orgo::serve::{inject_reload_script, resolve, since_parameter}; use orgo::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!("orgo-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"), "home").unwrap(); std::fs::write(out.join("blog/index.html"), "blog").unwrap(); std::fs::write(out.join("syntax.css"), "body{}").unwrap(); std::fs::write(out.join("a file.html"), "spaced").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"), "cpp").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("/__orgo/reload?since=7"), 7); assert_eq!(since_parameter("/__orgo/reload?x=1&since=42"), 42); assert_eq!(since_parameter("/__orgo/reload"), 0, "absent means start from zero"); assert_eq!(since_parameter("/__orgo/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"

hi

"; let served = String::from_utf8(inject_reload_script(page, 3)).unwrap(); assert!(served.contains("

hi

"), "content is preserved"); assert!(served.contains("})(3)"), "the generation is baked in: {served}"); let script_at = served.find("