451 lines
16 KiB
Rust
451 lines
16 KiB
Rust
|
|
//! 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::StatCache;
|
||
|
|
use crate::config::{DIR_CONFIG, DirConfig, PageSettings};
|
||
|
|
use crate::error::Error;
|
||
|
|
use crate::mime;
|
||
|
|
use crate::path::{clean_path, url_for};
|
||
|
|
|
||
|
|
/// 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, to be rendered into the format the caller serves.
|
||
|
|
Document { url: String, source: PathBuf, settings: PageSettings },
|
||
|
|
/// A file served byte for byte.
|
||
|
|
Raw { path: PathBuf, media_type: &'static str },
|
||
|
|
}
|
||
|
|
|
||
|
|
pub struct Site {
|
||
|
|
root: PathBuf,
|
||
|
|
dir_configs: StatCache<DirConfig>,
|
||
|
|
/// Shared by every directory without a config of its own, so the common case
|
||
|
|
/// allocates nothing.
|
||
|
|
built_in: Arc<DirConfig>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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) -> Result<Self, Error> {
|
||
|
|
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(), built_in: Arc::new(DirConfig::default()) })
|
||
|
|
}
|
||
|
|
|
||
|
|
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<Resolution, Error> {
|
||
|
|
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 settings = self.settings_for(&source)?;
|
||
|
|
Ok(Resolution::Found(Resource::Document { url, source, settings }))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<Resolution, Error> {
|
||
|
|
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<PathBuf> {
|
||
|
|
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<PathBuf> {
|
||
|
|
let resolved = path.canonicalize().ok()?;
|
||
|
|
(resolved.starts_with(&self.root) && resolved.is_file()).then_some(resolved)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn settings_for(&self, source: &Path) -> Result<PageSettings, Error> {
|
||
|
|
let Some(name) = source.file_name().and_then(|n| n.to_str()) else {
|
||
|
|
return Ok(PageSettings::default());
|
||
|
|
};
|
||
|
|
let dir = source.parent().unwrap_or(&self.root);
|
||
|
|
Ok(self.dir_config(dir)?.settings_for(name))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn dir_config(&self, dir: &Path) -> Result<Arc<DirConfig>, 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, || DirConfig::load(&path))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use std::fs;
|
||
|
|
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
/// 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 = Site::new(root).unwrap();
|
||
|
|
(dir, site)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[track_caller]
|
||
|
|
fn document(site: &Site, path: &str) -> (String, PathBuf, PageSettings) {
|
||
|
|
match site.resolve(path).unwrap() {
|
||
|
|
Resolution::Found(Resource::Document { url, source, settings }) => {
|
||
|
|
(url, source, settings)
|
||
|
|
}
|
||
|
|
other => panic!("expected a document at {path}, got {other:?}"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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 = Site::new(&root).unwrap();
|
||
|
|
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 = Site::new(&root).unwrap();
|
||
|
|
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, source, _) = document(&site, "/");
|
||
|
|
assert_eq!(url, "/");
|
||
|
|
assert_eq!(source, dir.path().canonicalize().unwrap().join("index.md"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn an_extensionless_path_serves_the_md_file() {
|
||
|
|
let (_dir, site) = fixture();
|
||
|
|
let (url, source, _) = document(&site, "/about");
|
||
|
|
assert_eq!(url, "/about");
|
||
|
|
assert_eq!(source.file_name().unwrap(), "about.md");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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, source, _) = document(&site, path);
|
||
|
|
assert_eq!(url, "/dir/");
|
||
|
|
assert!(source.ends_with("dir/index.md"), "{source:?}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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.as_deref(), Some("About Us"));
|
||
|
|
assert_eq!(about.cache_control, Some(3600));
|
||
|
|
|
||
|
|
// index.md shares the directory defaults but has no title configured.
|
||
|
|
let (_, _, index) = document(&site, "/");
|
||
|
|
assert_eq!(index.title, None);
|
||
|
|
assert_eq!(index.cache_control, Some(3600));
|
||
|
|
|
||
|
|
// The subdirectory does not inherit: it has no config of its own.
|
||
|
|
let (_, _, nested) = document(&site, "/dir");
|
||
|
|
assert_eq!(nested, 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").2.cache_control, Some(60));
|
||
|
|
|
||
|
|
fs::write(&path, "[defaults]\ncache_control = 120\n").unwrap();
|
||
|
|
bump_mtime(&path);
|
||
|
|
|
||
|
|
assert_eq!(document(&site, "/about").2.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();
|
||
|
|
assert!(matches!(Site::new(&dir.path().join("absent")), 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), Err(Error::Config { .. })));
|
||
|
|
}
|
||
|
|
}
|