//! One content root, and what a request path resolves to inside it. //! //! Containment is enforced twice, independently. `path::clean_path` cannot //! produce a string that escapes the root; `safe_file` then canonicalises what //! that string names and requires the result to still be under the root, which //! is what defeats a symlink pointing outside. There is a residual window //! between canonicalising and opening a file; for an author-controlled tree that //! is acceptable, and it is the reason the canonical path is what gets opened. use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::cache::{Stamp, StatCache}; use crate::config::{DIR_CONFIG, DirConfig}; use crate::error::Error; use crate::mime; use crate::parse; use crate::path::{clean_path, url_for}; use crate::render::{Page, Registry, title_from_stem}; /// How deep a chain of server-side redirects `resolve_flat` will follow. Every /// redirect currently points at a directly resolvable document, so one hop is /// always enough; the cap is there so a future rule cannot loop. const MAX_FLAT_HOPS: usize = 5; /// What a request path resolved to. #[derive(Debug)] pub enum Resolution { Found(Resource), /// The resource lives at this canonical root-relative URL. Redirect(String), NotFound, } #[derive(Debug)] pub enum Resource { /// A Markdown document, rendered into every format this server serves. Document { url: String, page: Arc }, /// A file served byte for byte, streamed rather than buffered. Raw { path: PathBuf, media_type: &'static str }, } pub struct Site { root: PathBuf, dir_configs: StatCache, pages: StatCache, /// Shared by every directory without a config of its own, so the common case /// allocates nothing. built_in: Arc, registry: Arc, /// Formats every page here is rendered into: the union of what the enabled /// listeners can serve, so a site with no HTTP listener never renders HTML. formats: Vec, } impl Site { /// Open a content root, canonicalising it so containment checks have a /// stable base and a missing root fails now rather than per request. pub fn new(root: &Path, registry: Arc, formats: Vec) -> Result { let root = root.canonicalize().map_err(|cause| Error::Io { path: root.to_path_buf(), cause })?; if !root.is_dir() { return Err(Error::config(format!("{} is not a directory", root.display()))); } Ok(Site { root, dir_configs: StatCache::new(), pages: StatCache::new(), built_in: Arc::new(DirConfig::default()), registry, formats, }) } pub fn root(&self) -> &Path { &self.root } /// Resolve a request path. /// /// | Request | Serves | /// | --- | --- | /// | `/` | `index.md` | /// | `/foo` | `foo.md`, else `foo/index.md` | /// | `/foo.md` | redirects to `/foo` | /// | `/img.png` | the file itself, by media type | pub fn resolve(&self, url_path: &str) -> Result { let Some(clean) = clean_path(url_path) else { return Ok(Resolution::NotFound) }; let Some(source) = self.file_for(&clean) else { return Ok(Resolution::NotFound) }; // `/foo.md` always redirects to its extensionless form, so one // root-relative link works identically from every protocol. if clean.ends_with(".md") { return Ok(match url_for(&self.root, &source) { Some(url) => Resolution::Redirect(url), None => Resolution::NotFound, }); } if source.extension().and_then(|e| e.to_str()) != Some("md") { let media_type = mime::media_type(&source); return Ok(Resolution::Found(Resource::Raw { path: source, media_type })); } let Some(url) = url_for(&self.root, &source) else { return Ok(Resolution::NotFound) }; let page = self.page(&source, &url)?; Ok(Resolution::Found(Resource::Document { url, page })) } /// Resolve the way [`Site::resolve`] does, but never hand back a redirect. /// /// Nex and Gopher have no redirect status, so there is nothing to bounce a /// client with: the canonical target is resolved here instead and its content /// served directly, on the first request. pub fn resolve_flat(&self, url_path: &str) -> Result { let mut target = url_path.to_string(); for _ in 0..MAX_FLAT_HOPS { match self.resolve(&target)? { Resolution::Redirect(location) => target = location, settled => return Ok(settled), } } Ok(Resolution::NotFound) } /// The source file a cleaned path names, if one exists and is contained. fn file_for(&self, clean: &str) -> Option { if clean.is_empty() { return self.safe_file(&self.root.join("index.md")); } // A name that already carries an extension resolves literally: `.md` is // never appended to a file that was asked for by name. let named = Path::new(clean).file_name()?.to_str()?; if named.contains('.') { return self.safe_file(&self.root.join(clean)); } self.safe_file(&self.root.join(format!("{clean}.md"))) .or_else(|| self.safe_file(&self.root.join(clean).join("index.md"))) } /// The canonical path of a file inside the root, or `None`. /// /// Canonicalising first and testing `is_file` on the *result* is what stops a /// symlink to a directory, or to anything outside the root, from passing. fn safe_file(&self, path: &Path) -> Option { let resolved = path.canonicalize().ok()?; (resolved.starts_with(&self.root) && resolved.is_file()).then_some(resolved) } /// The rendered page for a source file, built on first request and again /// whenever the file or its directory's config changes. fn page(&self, source: &Path, url: &str) -> Result, Error> { let dir = source.parent().unwrap_or(&self.root); let config_path = dir.join(DIR_CONFIG); // The config is a dependency of the render, not just of the settings, so // editing it re-renders this page rather than only changing what the next // fresh render would see. let stamp = Stamp::of_with_deps(source, &[&config_path])?; self.pages.get_or_insert_with(source, stamp, || { let name = source.file_name().and_then(|n| n.to_str()).unwrap_or_default(); let settings = self.dir_config(dir)?.settings_for(name); let doc = parse::document(source, &self.root)?; let stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or_default(); self.registry.page(&self.formats, &doc, url, &settings, &title_from_stem(stem)) }) } fn dir_config(&self, dir: &Path) -> Result, Error> { let path = dir.join(DIR_CONFIG); if !path.is_file() { return Ok(self.built_in.clone()); } self.dir_configs.get_or_insert_with(&path, Stamp::of(&path)?, || DirConfig::load(&path)) } } #[cfg(test)] mod tests { use std::fs; use super::*; use crate::config::PageSettings; use crate::ir::Doc; use crate::render::{RenderCtx, Rendered, Renderer}; /// Echoes the document's plain text, so a resolution test can assert on what /// was rendered without depending on a real format crate. struct Stub; impl Renderer for Stub { fn id(&self) -> &'static str { "stub" } fn media_type(&self) -> &'static str { "text/plain; charset=utf-8" } fn render(&self, doc: &Doc, ctx: &RenderCtx<'_>) -> Result { let mut body = format!("title={}\n", ctx.title); for block in &doc.blocks { body.push_str(&format!("{block:?}\n")); } Ok(Rendered::body(body.into_bytes())) } } fn registry() -> Arc { let mut registry = Registry::new(); registry.insert(Arc::new(Stub)).unwrap(); Arc::new(registry) } fn open(root: &Path) -> Site { Site::new(root, registry(), vec!["stub".to_string()]).unwrap() } /// Mirrors smolweb's `tests/conftest.py` fixture, so its assertions port /// across directly. fn fixture() -> (tempfile::TempDir, Site) { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); fs::write(root.join("index.md"), "# Home\n\nHello.\n").unwrap(); fs::write(root.join("about.md"), "# About\n\nBody.\n").unwrap(); fs::write(root.join("img.png"), b"\x89PNG\r\n\x1a\nfake").unwrap(); fs::create_dir(root.join("dir")).unwrap(); fs::write(root.join("dir/index.md"), "# Nested\n\nNested body.\n").unwrap(); fs::write(root.join(".secret.md"), "# Hidden\n").unwrap(); let site = open(root); (dir, site) } #[track_caller] fn document(site: &Site, path: &str) -> (String, Arc) { match site.resolve(path).unwrap() { Resolution::Found(Resource::Document { url, page }) => (url, page), other => panic!("expected a document at {path}, got {other:?}"), } } /// The stub's rendered body, as text. #[track_caller] fn rendered(site: &Site, path: &str) -> String { let (_, page) = document(site, path); String::from_utf8(page.body("stub").expect("the stub format is rendered").to_vec()).unwrap() } /// Force a modification time change: filesystem granularity is coarse /// enough that two writes in one test can share a timestamp. fn bump_mtime(path: &Path) { let later = std::time::SystemTime::now() + std::time::Duration::from_secs(5); fs::File::options() .write(true) .open(path) .unwrap() .set_times(fs::FileTimes::new().set_accessed(later).set_modified(later)) .unwrap(); } #[track_caller] fn assert_not_found(site: &Site, path: &str) { match site.resolve(path).unwrap() { Resolution::NotFound => {} other => panic!("expected nothing at {path}, got {other:?}"), } } // -- Ported from TestPathTraversal ------------------------------------ #[test] fn traversal_attempts_are_refused() { let (_dir, site) = fixture(); for path in [ "/../../etc/passwd", "/../../../../../../etc/passwd", "/foo/../../etc/passwd", // Percent-decoded to ".." before normalising, then clamped. "/%2e%2e/etc/passwd", ] { assert_not_found(&site, path); } } #[test] fn a_symlink_escaping_the_root_is_refused() { let dir = tempfile::tempdir().unwrap(); let outside = dir.path().join("outside"); fs::create_dir(&outside).unwrap(); fs::write(outside.join("secret.md"), "# Secret\n").unwrap(); let root = dir.path().join("root"); fs::create_dir(&root).unwrap(); std::os::unix::fs::symlink(outside.join("secret.md"), root.join("escape.md")).unwrap(); let site = open(&root); assert_not_found(&site, "/escape"); // The literal form is refused on the same grounds, not redirected. assert_not_found(&site, "/escape.md"); } #[test] fn a_symlink_to_a_directory_outside_the_root_is_refused() { let dir = tempfile::tempdir().unwrap(); let outside = dir.path().join("outside"); fs::create_dir(&outside).unwrap(); fs::write(outside.join("index.md"), "# Secret\n").unwrap(); let root = dir.path().join("root"); fs::create_dir(&root).unwrap(); std::os::unix::fs::symlink(&outside, root.join("link")).unwrap(); let site = open(&root); assert_not_found(&site, "/link"); } #[test] fn dotfile_paths_are_refused() { let (_dir, site) = fixture(); assert_not_found(&site, "/.secret"); assert_not_found(&site, "/.secret.md"); } #[test] fn the_per_directory_config_is_never_served() { let (dir, site) = fixture(); fs::write(dir.path().join(DIR_CONFIG), "[defaults]\ncache_control = 60\n").unwrap(); // It is not a dotfile by accident: without that rule it carries a dot in // its name and so would resolve literally, like img.png does. assert_not_found(&site, "/.itsybitsy.toml"); } // -- Ported from TestResolutionRules ---------------------------------- #[test] fn root_serves_index() { let (dir, site) = fixture(); let (url, _) = document(&site, "/"); assert_eq!(url, "/"); assert!(rendered(&site, "/").contains("Home")); let _ = dir; } #[test] fn an_extensionless_path_serves_the_md_file() { let (_dir, site) = fixture(); let (url, _) = document(&site, "/about"); assert_eq!(url, "/about"); assert!(rendered(&site, "/about").contains("About")); } #[test] fn a_directory_serves_its_index() { let (_dir, site) = fixture(); // Both forms resolve to the same page, and neither is redirected to the // other. That is the behaviour being ported, not an oversight. for path in ["/dir", "/dir/"] { let (url, _) = document(&site, path); assert_eq!(url, "/dir/"); assert!(rendered(&site, path).contains("Nested")); } } #[test] fn an_md_extension_redirects_to_the_canonical_url() { let (_dir, site) = fixture(); match site.resolve("/about.md").unwrap() { Resolution::Redirect(location) => assert_eq!(location, "/about"), other => panic!("expected a redirect, got {other:?}"), } match site.resolve("/dir/index.md").unwrap() { Resolution::Redirect(location) => assert_eq!(location, "/dir/"), other => panic!("expected a redirect, got {other:?}"), } } #[test] fn a_missing_md_file_is_not_found() { let (_dir, site) = fixture(); assert_not_found(&site, "/nonexistent.md"); } #[test] fn a_raw_file_is_served_with_its_media_type() { let (_dir, site) = fixture(); match site.resolve("/img.png").unwrap() { Resolution::Found(Resource::Raw { path, media_type }) => { assert_eq!(media_type, "image/png"); assert_eq!(path.file_name().unwrap(), "img.png"); } other => panic!("expected a raw file, got {other:?}"), } } #[test] fn a_missing_path_is_not_found() { let (_dir, site) = fixture(); assert_not_found(&site, "/nope"); } #[test] fn a_bare_unresolvable_segment_resolves_to_nothing() { // Regression carried over from smolweb: the Python reached this path // through `rpartition("/")`, where a bare top-level segment yields an // empty parent that must not be read as the root index. let (_dir, site) = fixture(); assert_not_found(&site, "/totally-unresolvable-segment"); } #[test] fn a_subpath_of_an_existing_document_is_not_found() { // `/about/whatever` is not a sub-resource of about.md just because // about.md exists. A format that invents sub-URLs claims them later. let (_dir, site) = fixture(); assert_not_found(&site, "/about/whatever"); } #[test] fn a_directory_without_an_index_is_not_found() { let (dir, site) = fixture(); fs::create_dir(dir.path().join("empty")).unwrap(); assert_not_found(&site, "/empty"); } // -- Ported from TestResolveFlat -------------------------------------- #[test] fn a_canonical_redirect_is_resolved_not_bounced() { let (_dir, site) = fixture(); match site.resolve_flat("/about.md").unwrap() { Resolution::Found(Resource::Document { url, .. }) => assert_eq!(url, "/about"), other => panic!("expected the document itself, got {other:?}"), } } #[test] fn resolve_flat_still_reports_a_missing_path() { let (_dir, site) = fixture(); match site.resolve_flat("/nope").unwrap() { Resolution::NotFound => {} other => panic!("expected nothing, got {other:?}"), } } #[test] fn resolve_flat_passes_a_raw_file_straight_through() { let (_dir, site) = fixture(); match site.resolve_flat("/img.png").unwrap() { Resolution::Found(Resource::Raw { media_type, .. }) => { assert_eq!(media_type, "image/png"); } other => panic!("expected a raw file, got {other:?}"), } } // -- Per-directory settings ------------------------------------------- #[test] fn settings_come_from_the_documents_own_directory() { let (dir, site) = fixture(); let root = dir.path(); fs::write( root.join(DIR_CONFIG), "[defaults]\ncache_control = 3600\n\n[page.\"about.md\"]\ntitle = \"About Us\"\n", ) .unwrap(); let (_, about) = document(&site, "/about"); assert_eq!(about.title, "About Us"); assert_eq!(about.settings.cache_control, Some(3600)); // index.md shares the directory defaults; with no title configured it // falls back to its own first heading. let (_, index) = document(&site, "/"); assert_eq!(index.title, "Home"); assert_eq!(index.settings.cache_control, Some(3600)); // The subdirectory does not inherit: it has no config of its own. let (_, nested) = document(&site, "/dir"); assert_eq!(nested.settings, PageSettings::default()); } #[test] fn an_edited_directory_config_takes_effect() { let (dir, site) = fixture(); let path = dir.path().join(DIR_CONFIG); fs::write(&path, "[defaults]\ncache_control = 60\n").unwrap(); assert_eq!(document(&site, "/about").1.settings.cache_control, Some(60)); fs::write(&path, "[defaults]\ncache_control = 120\n").unwrap(); bump_mtime(&path); assert_eq!(document(&site, "/about").1.settings.cache_control, Some(120)); } #[test] fn a_broken_directory_config_surfaces_as_an_error() { let (dir, site) = fixture(); fs::write(dir.path().join(DIR_CONFIG), "[defaults]\ncache_control = \"soon\"\n").unwrap(); // Not a silent fall back to defaults: that is how a typo ships. assert!(matches!(site.resolve("/about"), Err(Error::Toml { .. }))); } #[test] fn a_missing_root_is_rejected_at_construction() { let dir = tempfile::tempdir().unwrap(); let absent = dir.path().join("absent"); assert!(matches!(Site::new(&absent, registry(), vec![]), Err(Error::Io { .. }))); } #[test] fn a_file_as_a_root_is_rejected() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("not-a-dir"); fs::write(&file, "x").unwrap(); assert!(matches!(Site::new(&file, registry(), vec![]), Err(Error::Config { .. }))); } }