feat: resolve request paths within contained site roots
This commit is contained in:
parent
6a3a198975
commit
3031b8acdd
9 changed files with 1257 additions and 8 deletions
31
README.md
31
README.md
|
|
@ -8,12 +8,13 @@ A Rust re-implementation of [smolweb](https://code.randogoth.com/randogoth/smolw
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Early. The configuration layer is implemented and tested; nothing is served yet.
|
Early. Configuration and path resolution are implemented and tested; nothing is served yet.
|
||||||
|
|
||||||
| Area | State |
|
| Area | State |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Server configuration, virtual hosts, `--check` | implemented |
|
| Server configuration, virtual hosts, `--check` | implemented |
|
||||||
| Path resolution and the render cache | planned |
|
| Path resolution, traversal containment, live reload | implemented |
|
||||||
|
| Markdown parsing | planned |
|
||||||
| gemtext and HTML output | planned |
|
| gemtext and HTML output | planned |
|
||||||
| HTTP, Spartan and Nex listeners | planned |
|
| HTTP, Spartan and Nex listeners | planned |
|
||||||
| 80-column text and Nex output | planned |
|
| 80-column text and Nex output | planned |
|
||||||
|
|
@ -40,6 +41,19 @@ default_site = "smol"
|
||||||
|
|
||||||
A `.itsybitsy.toml` in any content directory says how the Markdown in **that directory** renders. There is no inheritance: a subdirectory without its own file uses the built-in defaults rather than its parent's. Repetition in a deep tree is the price of never having to look elsewhere to know how a page renders.
|
A `.itsybitsy.toml` in any content directory says how the Markdown in **that directory** renders. There is no inheritance: a subdirectory without its own file uses the built-in defaults rather than its parent's. Repetition in a deep tree is the price of never having to look elsewhere to know how a page renders.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[defaults]
|
||||||
|
cache_control = 3600
|
||||||
|
|
||||||
|
[page."index.md"]
|
||||||
|
title = "Notes"
|
||||||
|
cache_control = 300
|
||||||
|
```
|
||||||
|
|
||||||
|
`[defaults]` applies to every Markdown file in the directory; a `[page."name.md"]` table overrides it for one file. `title` is per-page by nature, so setting it in `[defaults]` is an error; left unset, it is derived from the page's first level-1 heading. Each renderer's own keys arrive with that renderer. Editing the file re-renders that directory's pages on the next request, and a file that fails to parse makes them error rather than silently falling back to defaults.
|
||||||
|
|
||||||
|
Because the name begins with a dot, the same rule that keeps `.secret.md` out of the URL space keeps this file out of it too.
|
||||||
|
|
||||||
`--check` validates the configuration and reports the routing it resolved, without binding a port:
|
`--check` validates the configuration and reports the routing it resolved, without binding a port:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
|
@ -56,6 +70,19 @@ formats rendered per page: html, xhtmlmp
|
||||||
|
|
||||||
Everything the schema cannot express is checked here rather than on first request: a format no enabled feature provides, two sites claiming one hostname, a content root that does not exist, a server file sitting inside a content root where it would be served.
|
Everything the schema cannot express is checked here rather than on first request: a format no enabled feature provides, two sites claiming one hostname, a content root that does not exist, a server file sitting inside a content root where it would be served.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
Links should be root-relative and extensionless (`[about](/about)`, not `about.md`), so the same link resolves identically from every protocol.
|
||||||
|
|
||||||
|
| Request | Serves |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` | `index.md` |
|
||||||
|
| `/foo` | `foo.md`, else `foo/index.md` |
|
||||||
|
| `/foo.md` | redirects to `/foo` |
|
||||||
|
| `/img.png` | the file itself, by media type |
|
||||||
|
|
||||||
|
Nothing outside the content root is reachable. A request target is percent-decoded before it is normalised, so an encoded `..` becomes a real one and gets clamped at the root rather than quietly matching nothing; any path component beginning with a dot is refused outright; and whatever survives is canonicalised and required to still be inside the root, which is what defeats a symlink pointing out of it. There are no generated directory listings — only `index.md`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use itsybitsy_core::config::{Checked, ServerConfig};
|
use itsybitsy_core::config::{Checked, ServerConfig};
|
||||||
|
use itsybitsy_core::siteset::SiteSet;
|
||||||
|
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
|
|
||||||
|
|
@ -28,24 +29,28 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||||
/// Takes the format set rather than reading [`registry`] directly, so a test can
|
/// Takes the format set rather than reading [`registry`] directly, so a test can
|
||||||
/// supply the ids a later milestone's registry will provide.
|
/// supply the ids a later milestone's registry will provide.
|
||||||
pub fn check(config_path: &Path, formats: &[&str]) -> Result<()> {
|
pub fn check(config_path: &Path, formats: &[&str]) -> Result<()> {
|
||||||
let (config, checked) = load(config_path, formats)?;
|
let (config, checked, sites) = load(config_path, formats)?;
|
||||||
report(&config, &checked);
|
report(&config, &checked, &sites);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load(config_path: &Path, formats: &[&str]) -> Result<(ServerConfig, Checked)> {
|
fn load(config_path: &Path, formats: &[&str]) -> Result<(ServerConfig, Checked, SiteSet)> {
|
||||||
// No context on load: its errors already name the file.
|
// No context on load: its errors already name the file.
|
||||||
let config = ServerConfig::load(config_path)?;
|
let config = ServerConfig::load(config_path)?;
|
||||||
let checked = config
|
let checked = config
|
||||||
.validate(config_path, formats)
|
.validate(config_path, formats)
|
||||||
.with_context(|| format!("validating {}", config_path.display()))?;
|
.with_context(|| format!("validating {}", config_path.display()))?;
|
||||||
Ok((config, checked))
|
// Opening every root here means a broken site fails at startup rather than
|
||||||
|
// on the first request that happens to name it.
|
||||||
|
let sites = SiteSet::build(&config)
|
||||||
|
.with_context(|| format!("opening the sites in {}", config_path.display()))?;
|
||||||
|
Ok((config, checked, sites))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `--check` prints: the resolved routing, so an operator can see which
|
/// What `--check` prints: the resolved routing, so an operator can see which
|
||||||
/// name reaches which folder without sending a request.
|
/// name reaches which folder without sending a request.
|
||||||
fn report(config: &ServerConfig, checked: &Checked) {
|
fn report(config: &ServerConfig, checked: &Checked, sites: &SiteSet) {
|
||||||
println!("sites:");
|
println!("sites: {} configured, {} distinct roots", checked.roots.len(), sites.len());
|
||||||
for (name, root) in &checked.roots {
|
for (name, root) in &checked.roots {
|
||||||
println!(" {name}: {}", root.display());
|
println!(" {name}: {}", root.display());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
178
core/src/cache.rs
Normal file
178
core/src/cache.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
//! Deriving a value from a file once, and again when the file changes.
|
||||||
|
//!
|
||||||
|
//! Invalidation is by modification time and length, which is what makes editing
|
||||||
|
//! a file enough to see the change on the next request with no watcher and no
|
||||||
|
//! restart. Values are built outside the lock, so two first hits on one file can
|
||||||
|
//! both build it; the work is idempotent and the second insert wins, which is
|
||||||
|
//! the trade the Python makes too.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// The file state a cached value was derived from.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Stamp {
|
||||||
|
modified: SystemTime,
|
||||||
|
len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stamp {
|
||||||
|
/// Stat `path`. Fails if it cannot be read, which is also how a deleted file
|
||||||
|
/// invalidates its entry.
|
||||||
|
pub fn of(path: &Path) -> Result<Self, Error> {
|
||||||
|
let meta =
|
||||||
|
fs::metadata(path).map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
||||||
|
let modified =
|
||||||
|
meta.modified().map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
||||||
|
Ok(Stamp { modified, len: meta.len() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A map from file path to a value derived from that file.
|
||||||
|
pub struct StatCache<V> {
|
||||||
|
entries: RwLock<HashMap<PathBuf, (Stamp, Arc<V>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<V> Default for StatCache<V> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<V> StatCache<V> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
StatCache { entries: RwLock::new(HashMap::new()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cached value for `path`, or `build`'s result, stored for next time.
|
||||||
|
///
|
||||||
|
/// `build` is given the stamp the value will be recorded against, so a
|
||||||
|
/// caller that depends on a second file (a page on its directory's config)
|
||||||
|
/// can combine stamps without stating the file twice.
|
||||||
|
pub fn get_or_insert_with(
|
||||||
|
&self,
|
||||||
|
path: &Path,
|
||||||
|
build: impl FnOnce() -> Result<V, Error>,
|
||||||
|
) -> Result<Arc<V>, Error> {
|
||||||
|
let stamp = Stamp::of(path)?;
|
||||||
|
if let Some(value) = self.get(path, &stamp) {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
let value = Arc::new(build()?);
|
||||||
|
self.entries
|
||||||
|
.write()
|
||||||
|
.expect("cache lock")
|
||||||
|
.insert(path.to_path_buf(), (stamp, value.clone()));
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cached value, if one was derived from exactly this file state.
|
||||||
|
pub fn get(&self, path: &Path, stamp: &Stamp) -> Option<Arc<V>> {
|
||||||
|
let entries = self.entries.read().expect("cache lock");
|
||||||
|
let (cached, value) = entries.get(path)?;
|
||||||
|
(cached == stamp).then(|| value.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.read().expect("cache lock").len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.len() == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs::File;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Force a modification time change. Filesystem granularity is coarse
|
||||||
|
/// enough that two writes in one test can otherwise share a timestamp,
|
||||||
|
/// which is why the Python's live-reload test does the same thing.
|
||||||
|
fn bump_mtime(path: &Path) {
|
||||||
|
let stamp = Stamp::of(path).unwrap();
|
||||||
|
let later = stamp.modified + Duration::from_secs(5);
|
||||||
|
File::options()
|
||||||
|
.write(true)
|
||||||
|
.open(path)
|
||||||
|
.unwrap()
|
||||||
|
.set_times(fs::FileTimes::new().set_accessed(later).set_modified(later))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unchanged_file_reuses_the_cached_value() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("page.md");
|
||||||
|
fs::write(&path, "first").unwrap();
|
||||||
|
let cache: StatCache<String> = StatCache::new();
|
||||||
|
let builds = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
let build = || {
|
||||||
|
builds.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Ok(fs::read_to_string(&path).unwrap())
|
||||||
|
};
|
||||||
|
let first = cache.get_or_insert_with(&path, build).unwrap();
|
||||||
|
let second = cache.get_or_insert_with(&path, build).unwrap();
|
||||||
|
|
||||||
|
// Identity, not just equality: the Python asserts `first is second`.
|
||||||
|
assert!(Arc::ptr_eq(&first, &second));
|
||||||
|
assert_eq!(builds.load(Ordering::Relaxed), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edited_file_is_rebuilt() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("page.md");
|
||||||
|
fs::write(&path, "first").unwrap();
|
||||||
|
let cache: StatCache<String> = StatCache::new();
|
||||||
|
|
||||||
|
let build = || Ok(fs::read_to_string(&path).unwrap());
|
||||||
|
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "first");
|
||||||
|
|
||||||
|
fs::write(&path, "second").unwrap();
|
||||||
|
bump_mtime(&path);
|
||||||
|
|
||||||
|
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "second");
|
||||||
|
assert_eq!(cache.len(), 1, "the entry is replaced, not added to");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_same_length_edit_is_still_rebuilt() {
|
||||||
|
// Length alone would miss this; the modification time catches it.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("page.md");
|
||||||
|
fs::write(&path, "aaaaa").unwrap();
|
||||||
|
let cache: StatCache<String> = StatCache::new();
|
||||||
|
|
||||||
|
let build = || Ok(fs::read_to_string(&path).unwrap());
|
||||||
|
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "aaaaa");
|
||||||
|
|
||||||
|
fs::write(&path, "bbbbb").unwrap();
|
||||||
|
bump_mtime(&path);
|
||||||
|
|
||||||
|
assert_eq!(*cache.get_or_insert_with(&path, build).unwrap(), "bbbbb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_is_an_error_not_a_stale_value() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("page.md");
|
||||||
|
fs::write(&path, "here").unwrap();
|
||||||
|
let cache: StatCache<String> = StatCache::new();
|
||||||
|
let build = || Ok(fs::read_to_string(&path).unwrap_or_default());
|
||||||
|
cache.get_or_insert_with(&path, build).unwrap();
|
||||||
|
|
||||||
|
fs::remove_file(&path).unwrap();
|
||||||
|
assert!(matches!(cache.get_or_insert_with(&path, build), Err(Error::Io { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -369,6 +369,97 @@ pub fn normalize_host(raw: &str) -> Option<String> {
|
||||||
allowed.then_some(host)
|
allowed.then_some(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How the Markdown in one directory renders.
|
||||||
|
///
|
||||||
|
/// There is no inheritance. A subdirectory without its own `.itsybitsy.toml`
|
||||||
|
/// uses the built-in defaults rather than its parent's, so how a page renders is
|
||||||
|
/// knowable from its own directory alone. Repetition in a deep tree is the price.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DirConfig {
|
||||||
|
/// Applied to every Markdown file in the directory.
|
||||||
|
#[serde(default)]
|
||||||
|
pub defaults: PageKeys,
|
||||||
|
/// Overrides for one file, keyed by its bare name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub page: BTreeMap<String, PageKeys>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deserialisation shape: every key optional, so "unset" is distinguishable
|
||||||
|
/// from "set to what the default happens to be".
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PageKeys {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub cache_control: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings resolved for one page: built-in defaults, then `[defaults]`, then
|
||||||
|
/// the file's own `[page."...'"]` table.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct PageSettings {
|
||||||
|
/// An explicitly configured title. Absent means derive one, from the first
|
||||||
|
/// level-1 heading if there is one and the file stem otherwise.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// `Cache-Control: max-age` in seconds. Absent means send no such header.
|
||||||
|
pub cache_control: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirConfig {
|
||||||
|
/// Read and parse a per-directory config. A parse failure is returned rather
|
||||||
|
/// than swallowed: falling back to defaults after a typo is how a site ships
|
||||||
|
/// wrong output without anyone noticing.
|
||||||
|
pub fn load(path: &Path) -> Result<Self, Error> {
|
||||||
|
let text = fs::read_to_string(path)
|
||||||
|
.map_err(|cause| Error::Io { path: path.to_path_buf(), cause })?;
|
||||||
|
let config: DirConfig = toml::from_str(&text)
|
||||||
|
.map_err(|cause| Error::Toml { path: path.to_path_buf(), cause })?;
|
||||||
|
config.validate(path)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self, path: &Path) -> Result<(), Error> {
|
||||||
|
if self.defaults.title.is_some() {
|
||||||
|
return Err(Error::config(format!(
|
||||||
|
"{}: title belongs to one page, so it cannot be set in [defaults]",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for name in self.page.keys() {
|
||||||
|
// A key naming another directory is what "no inheritance" forbids,
|
||||||
|
// and this is where that is mechanically enforced.
|
||||||
|
if name.contains('/') {
|
||||||
|
return Err(Error::config(format!(
|
||||||
|
"{}: page key '{name}' must be a bare file name in this directory",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// A key that cannot name a Markdown file would silently do nothing.
|
||||||
|
if !name.ends_with(".md") {
|
||||||
|
return Err(Error::config(format!(
|
||||||
|
"{}: page key '{name}' names no Markdown file; it must end in .md",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings for one file in this directory, by its bare name.
|
||||||
|
pub fn settings_for(&self, file_name: &str) -> PageSettings {
|
||||||
|
let mut settings = PageSettings { title: None, cache_control: self.defaults.cache_control };
|
||||||
|
if let Some(page) = self.page.get(file_name) {
|
||||||
|
if page.title.is_some() {
|
||||||
|
settings.title = page.title.clone();
|
||||||
|
}
|
||||||
|
if page.cache_control.is_some() {
|
||||||
|
settings.cache_control = page.cache_control;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -581,3 +672,71 @@ mod tests {
|
||||||
assert_eq!(normalize_host("[::1"), None);
|
assert_eq!(normalize_host("[::1"), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dir_config_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn load(body: &str) -> Result<DirConfig, Error> {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join(DIR_CONFIG);
|
||||||
|
fs::write(&path, body).unwrap();
|
||||||
|
DirConfig::load(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absent_file_means_built_in_defaults() {
|
||||||
|
let config = DirConfig::default();
|
||||||
|
assert_eq!(config.settings_for("index.md"), PageSettings::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_page_table_overrides_the_directory_defaults() {
|
||||||
|
let config = load(
|
||||||
|
"[defaults]\ncache_control = 3600\n\n\
|
||||||
|
[page.\"index.md\"]\ntitle = \"Notes\"\ncache_control = 300\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let index = config.settings_for("index.md");
|
||||||
|
assert_eq!(index.title.as_deref(), Some("Notes"));
|
||||||
|
assert_eq!(index.cache_control, Some(300));
|
||||||
|
|
||||||
|
// A file with no table of its own still gets the directory defaults,
|
||||||
|
// and no title, which is the signal to derive one.
|
||||||
|
let other = config.settings_for("about.md");
|
||||||
|
assert_eq!(other.title, None);
|
||||||
|
assert_eq!(other.cache_control, Some(3600));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_unknown_key() {
|
||||||
|
let err = load("[defaults]\ncache_contrl = 60\n").unwrap_err();
|
||||||
|
assert!(matches!(err, Error::Toml { .. }), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_title_in_defaults() {
|
||||||
|
// A directory-wide title would silently give every page the same one.
|
||||||
|
let err = load("[defaults]\ntitle = \"Everything\"\n").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("cannot be set in [defaults]"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_page_key_naming_another_directory() {
|
||||||
|
let err = load("[page.\"sub/index.md\"]\ntitle = \"No\"\n").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("bare file name"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_page_key_that_cannot_name_a_markdown_file() {
|
||||||
|
let err = load("[page.index]\ntitle = \"No\"\n").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("must end in .md"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_parse_failure_surfaces_rather_than_falling_back() {
|
||||||
|
let err = load("[defaults]\ncache_control =\n").unwrap_err();
|
||||||
|
assert!(matches!(err, Error::Toml { .. }), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@
|
||||||
//! without binding a port; the binary crate holds the protocol listeners and
|
//! without binding a port; the binary crate holds the protocol listeners and
|
||||||
//! is the only place that names a format crate.
|
//! is the only place that names a format crate.
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod mime;
|
||||||
|
pub mod path;
|
||||||
|
pub mod site;
|
||||||
|
pub mod siteset;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
|
|
||||||
78
core/src/mime.rs
Normal file
78
core/src/mime.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
//! Media types for files served byte for byte.
|
||||||
|
//!
|
||||||
|
//! A fixed table rather than a system lookup: Python's `mimetypes` consults
|
||||||
|
//! `/etc/mime.types` where it exists, so smolweb's `Content-Type` for the same
|
||||||
|
//! file differs between hosts. Determinism is worth more here than coverage of
|
||||||
|
//! the long tail, and an unknown extension has a correct answer anyway.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Returned when the extension is absent or unrecognised.
|
||||||
|
pub const DEFAULT: &str = "application/octet-stream";
|
||||||
|
|
||||||
|
/// The media type to serve `path` as, by extension, compared case-insensitively.
|
||||||
|
pub fn media_type(path: &Path) -> &'static str {
|
||||||
|
let Some(extension) = path.extension().and_then(|e| e.to_str()) else { return DEFAULT };
|
||||||
|
let extension = extension.to_ascii_lowercase();
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.find(|(known, _)| *known == extension)
|
||||||
|
.map(|(_, media_type)| *media_type)
|
||||||
|
.unwrap_or(DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extensions a folder of Markdown actually carries. Text types name their
|
||||||
|
/// charset, since a client has no other way to know it.
|
||||||
|
const TABLE: &[(&str, &str)] = &[
|
||||||
|
// Images
|
||||||
|
("png", "image/png"),
|
||||||
|
("jpg", "image/jpeg"),
|
||||||
|
("jpeg", "image/jpeg"),
|
||||||
|
("gif", "image/gif"),
|
||||||
|
("webp", "image/webp"),
|
||||||
|
("svg", "image/svg+xml"),
|
||||||
|
("ico", "image/vnd.microsoft.icon"),
|
||||||
|
("avif", "image/avif"),
|
||||||
|
// Documents and data
|
||||||
|
("txt", "text/plain; charset=utf-8"),
|
||||||
|
("gmi", "text/gemini; charset=utf-8"),
|
||||||
|
("css", "text/css; charset=utf-8"),
|
||||||
|
("html", "text/html; charset=utf-8"),
|
||||||
|
("xml", "application/xml"),
|
||||||
|
("json", "application/json"),
|
||||||
|
("toml", "application/toml"),
|
||||||
|
("pdf", "application/pdf"),
|
||||||
|
("asc", "text/plain; charset=utf-8"),
|
||||||
|
// Audio and video
|
||||||
|
("mp3", "audio/mpeg"),
|
||||||
|
("ogg", "audio/ogg"),
|
||||||
|
("opus", "audio/ogg"),
|
||||||
|
("flac", "audio/flac"),
|
||||||
|
("wav", "audio/wav"),
|
||||||
|
("mp4", "video/mp4"),
|
||||||
|
("webm", "video/webm"),
|
||||||
|
// Archives and fonts
|
||||||
|
("gz", "application/gzip"),
|
||||||
|
("zip", "application/zip"),
|
||||||
|
("tar", "application/x-tar"),
|
||||||
|
("woff2", "font/woff2"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognises_common_extensions() {
|
||||||
|
assert_eq!(media_type(Path::new("img.png")), "image/png");
|
||||||
|
assert_eq!(media_type(Path::new("a/b/photo.JPEG")), "image/jpeg");
|
||||||
|
assert_eq!(media_type(Path::new("notes.txt")), "text/plain; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_for_the_unknown_and_the_extensionless() {
|
||||||
|
assert_eq!(media_type(Path::new("mystery.xyz")), DEFAULT);
|
||||||
|
assert_eq!(media_type(Path::new("LICENSE")), DEFAULT);
|
||||||
|
assert_eq!(media_type(Path::new("archive.tar.zst")), DEFAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
core/src/path.rs
Normal file
197
core/src/path.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
//! Turning a request target into a root-relative path.
|
||||||
|
//!
|
||||||
|
//! The ordering here is load-bearing and ported deliberately: percent-decode
|
||||||
|
//! *before* normalising, so an encoded `%2e%2e` becomes a real `..` that the
|
||||||
|
//! normaliser then clamps at the root rather than a literal segment that
|
||||||
|
//! quietly matches nothing. Normalisation alone cannot be tricked into a
|
||||||
|
//! traversal string; the canonicalisation check in `site` is the second,
|
||||||
|
//! independent layer that guards the filesystem side.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Normalise a request target into a root-relative path with no leading or
|
||||||
|
/// trailing slash. The root itself is the empty string.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the path must not be served: any component beginning
|
||||||
|
/// with a dot. That rule covers `.`/`..` left over from normalisation, dotfiles
|
||||||
|
/// generally, and with them the per-directory `.itsybitsy.toml`.
|
||||||
|
pub fn clean_path(url_path: &str) -> Option<String> {
|
||||||
|
let without_query = url_path.split(['?', '#']).next().unwrap_or("");
|
||||||
|
let decoded = percent_decode(without_query);
|
||||||
|
|
||||||
|
// `..` pops, and popping an empty stack is how the root clamps it: the
|
||||||
|
// Python relies on `posixpath.normpath` of a leading-slash path for this.
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
|
for part in decoded.split('/') {
|
||||||
|
match part {
|
||||||
|
"" | "." => {}
|
||||||
|
".." => {
|
||||||
|
parts.pop();
|
||||||
|
}
|
||||||
|
other => parts.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.iter().any(|part| part.starts_with('.')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(parts.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical root-relative URL a source file is served at.
|
||||||
|
///
|
||||||
|
/// An `index.md` is addressed by its directory, so it yields `/` or `/dir/`;
|
||||||
|
/// anything else drops its extension. Note that `/dir` and `/dir/` both resolve
|
||||||
|
/// to the same page and neither is redirected to the other, which is the
|
||||||
|
/// behaviour being ported.
|
||||||
|
pub fn url_for(root: &Path, source: &Path) -> Option<String> {
|
||||||
|
let rel = source.strip_prefix(root).ok()?;
|
||||||
|
if rel.file_name()? == "index.md" {
|
||||||
|
let parent = rel.parent()?.to_str()?;
|
||||||
|
return Some(if parent.is_empty() { "/".to_string() } else { format!("/{parent}/") });
|
||||||
|
}
|
||||||
|
Some(format!("/{}", rel.with_extension("").to_str()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode `%XX` escapes, leaving an invalid escape as the literal text it is.
|
||||||
|
///
|
||||||
|
/// Bytes that do not form valid UTF-8 become U+FFFD, which matches no filename,
|
||||||
|
/// so a malformed target resolves to nothing rather than erroring. That matches
|
||||||
|
/// Python's lossy `unquote` and is pinned by a test.
|
||||||
|
fn percent_decode(raw: &str) -> String {
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%'
|
||||||
|
&& i + 2 < bytes.len()
|
||||||
|
&& let (Some(hi), Some(lo)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2]))
|
||||||
|
{
|
||||||
|
out.push(hi << 4 | lo);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_digit(byte: u8) -> Option<u8> {
|
||||||
|
match byte {
|
||||||
|
b'0'..=b'9' => Some(byte - b'0'),
|
||||||
|
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn clean(path: &str) -> String {
|
||||||
|
clean_path(path).expect("should resolve")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_forms_are_the_empty_path() {
|
||||||
|
assert_eq!(clean("/"), "");
|
||||||
|
assert_eq!(clean(""), "");
|
||||||
|
assert_eq!(clean("//"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_query_and_fragment() {
|
||||||
|
assert_eq!(clean("/about?format=html"), "about");
|
||||||
|
assert_eq!(clean("/about#top"), "about");
|
||||||
|
assert_eq!(clean("/about?a=1#top"), "about");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapses_slashes_and_trailing_separators() {
|
||||||
|
assert_eq!(clean("/dir/"), "dir");
|
||||||
|
assert_eq!(clean("//dir//page//"), "dir/page");
|
||||||
|
assert_eq!(clean("dir/page"), "dir/page");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamps_traversal_at_the_root() {
|
||||||
|
// Ported from smolweb's TestPathTraversal: these must never reach above
|
||||||
|
// the root, and since nothing is mounted at the clamped path they
|
||||||
|
// resolve to a path that simply does not exist.
|
||||||
|
assert_eq!(clean("/../../etc/passwd"), "etc/passwd");
|
||||||
|
assert_eq!(clean("/../../../../../../etc/passwd"), "etc/passwd");
|
||||||
|
assert_eq!(clean("/foo/../../etc/passwd"), "etc/passwd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_before_normalising() {
|
||||||
|
// The point of the ordering: an encoded `..` has to become a real one
|
||||||
|
// before the clamp runs, or it would be treated as a literal segment.
|
||||||
|
assert_eq!(clean("/%2e%2e/etc/passwd"), "etc/passwd");
|
||||||
|
assert_eq!(clean("/%2E%2E/etc/passwd"), "etc/passwd");
|
||||||
|
// An encoded separator becomes a separator, as Python's unquote does.
|
||||||
|
assert_eq!(clean("/dir%2fpage"), "dir/page");
|
||||||
|
assert_eq!(clean("/hello%20world"), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_dotfiles() {
|
||||||
|
assert_eq!(clean_path("/.secret"), None);
|
||||||
|
assert_eq!(clean_path("/dir/.secret"), None);
|
||||||
|
// The per-directory config is a dotfile, so this rule hides it.
|
||||||
|
assert_eq!(clean_path("/.itsybitsy.toml"), None);
|
||||||
|
assert_eq!(clean_path("/notes/.itsybitsy.toml"), None);
|
||||||
|
// Surviving dot segments are caught defensively too.
|
||||||
|
assert_eq!(clean_path("/%2e%2e%2f.secret"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lone_dot_is_the_root_not_a_dotfile() {
|
||||||
|
assert_eq!(clean("/."), "");
|
||||||
|
assert_eq!(clean("/./"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invalid_escape_stays_literal() {
|
||||||
|
assert_eq!(clean("/%zz"), "%zz");
|
||||||
|
assert_eq!(clean("/%"), "%");
|
||||||
|
assert_eq!(clean("/%4"), "%4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_utf8_becomes_a_replacement_character() {
|
||||||
|
// U+FFFD matches no filename, so this resolves to nothing rather than
|
||||||
|
// erroring. Pinned so a later cleanup does not turn it into a 400.
|
||||||
|
assert_eq!(clean("/%ff"), "\u{fffd}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_backslash_is_an_ordinary_character() {
|
||||||
|
// posixpath treats it as part of a name, and so must this.
|
||||||
|
assert_eq!(clean("/dir\\page"), "dir\\page");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urls_for_index_files_address_their_directory() {
|
||||||
|
let root = PathBuf::from("/srv/content");
|
||||||
|
assert_eq!(url_for(&root, &root.join("index.md")).unwrap(), "/");
|
||||||
|
assert_eq!(url_for(&root, &root.join("dir/index.md")).unwrap(), "/dir/");
|
||||||
|
assert_eq!(url_for(&root, &root.join("a/b/index.md")).unwrap(), "/a/b/");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urls_for_other_files_drop_the_extension() {
|
||||||
|
let root = PathBuf::from("/srv/content");
|
||||||
|
assert_eq!(url_for(&root, &root.join("about.md")).unwrap(), "/about");
|
||||||
|
assert_eq!(url_for(&root, &root.join("dir/page.md")).unwrap(), "/dir/page");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_for_refuses_a_path_outside_the_root() {
|
||||||
|
assert_eq!(url_for(Path::new("/srv/content"), Path::new("/etc/passwd")), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
450
core/src/site.rs
Normal file
450
core/src/site.rs
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
//! 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 { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
150
core/src/siteset.rs
Normal file
150
core/src/siteset.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
//! Every site this server answers for, and how a request reaches one.
|
||||||
|
//!
|
||||||
|
//! Built once at startup from a validated configuration and immutable
|
||||||
|
//! thereafter, so a lookup is a hash probe with no locking. Sites whose roots
|
||||||
|
//! canonicalise to the same directory collapse into one [`Site`], which is what
|
||||||
|
//! lets two domains serving one folder share its caches.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::{ServerConfig, normalize_host};
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::site::Site;
|
||||||
|
|
||||||
|
pub struct SiteSet {
|
||||||
|
sites: Vec<Arc<Site>>,
|
||||||
|
/// Normalised host → index into `sites`.
|
||||||
|
by_host: HashMap<String, usize>,
|
||||||
|
/// Configured site name → index into `sites`. Two names can share one index.
|
||||||
|
by_name: HashMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SiteSet {
|
||||||
|
/// Build from a configuration that [`ServerConfig::validate`] has accepted.
|
||||||
|
///
|
||||||
|
/// Validation has already proved the roots canonicalise and that no host is
|
||||||
|
/// claimed twice, so the only work left is opening each site once.
|
||||||
|
pub fn build(config: &ServerConfig) -> Result<Self, Error> {
|
||||||
|
let mut set =
|
||||||
|
SiteSet { sites: Vec::new(), by_host: HashMap::new(), by_name: HashMap::new() };
|
||||||
|
|
||||||
|
for (name, spec) in &config.site {
|
||||||
|
let site = Site::new(&spec.root)?;
|
||||||
|
// One `Site` per distinct root, so sites sharing a folder share its
|
||||||
|
// caches rather than each building their own.
|
||||||
|
let index = match set.sites.iter().position(|open| open.root() == site.root()) {
|
||||||
|
Some(index) => index,
|
||||||
|
None => {
|
||||||
|
set.sites.push(Arc::new(site));
|
||||||
|
set.sites.len() - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
set.by_name.insert(name.clone(), index);
|
||||||
|
|
||||||
|
for raw in &spec.hosts {
|
||||||
|
let host = normalize_host(raw).ok_or_else(|| {
|
||||||
|
Error::config(format!("site '{name}': '{raw}' is not a usable hostname"))
|
||||||
|
})?;
|
||||||
|
set.by_host.insert(host, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The site a request naming `host` is served from. `host` arrives as it was
|
||||||
|
/// on the wire; normalising it is this function's job.
|
||||||
|
pub fn lookup(&self, host: &str) -> Option<&Arc<Site>> {
|
||||||
|
let host = normalize_host(host)?;
|
||||||
|
self.sites.get(*self.by_host.get(&host)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The site a listener named outright in its configuration.
|
||||||
|
pub fn by_name(&self, name: &str) -> Option<&Arc<Site>> {
|
||||||
|
self.sites.get(*self.by_name.get(name)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many distinct content roots are open, which is not the number of
|
||||||
|
/// configured sites when any of them share a root.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.sites.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.sites.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A config with two sites, whose roots are `first` and `second` under `dir`.
|
||||||
|
fn build(dir: &Path, first: &str, second: &str) -> SiteSet {
|
||||||
|
for name in [first, second] {
|
||||||
|
fs::create_dir_all(dir.join(name)).unwrap();
|
||||||
|
}
|
||||||
|
let text = format!(
|
||||||
|
"version = 1\n\n\
|
||||||
|
[site.one]\nroot = {:?}\nhosts = [\"one.test\", \"www.one.test\"]\n\n\
|
||||||
|
[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\n\n\
|
||||||
|
[listener.web]\nprotocol = \"http\"\nbind = \":8080\"\nformats = [\"html\"]\n",
|
||||||
|
dir.join(first).to_str().unwrap(),
|
||||||
|
dir.join(second).to_str().unwrap(),
|
||||||
|
);
|
||||||
|
let config: ServerConfig = toml::from_str(&text).unwrap();
|
||||||
|
SiteSet::build(&config).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routes_each_host_to_its_own_site() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let set = build(dir.path(), "one", "two");
|
||||||
|
assert_eq!(set.len(), 2);
|
||||||
|
|
||||||
|
let one = set.lookup("one.test").unwrap();
|
||||||
|
assert!(one.root().ends_with("one"));
|
||||||
|
assert!(Arc::ptr_eq(one, set.lookup("www.one.test").unwrap()));
|
||||||
|
assert!(set.lookup("two.test").unwrap().root().ends_with("two"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalises_the_host_before_looking_it_up() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let set = build(dir.path(), "one", "two");
|
||||||
|
for host in ["ONE.test", "one.test.", "one.test:8080", " one.test "] {
|
||||||
|
assert!(set.lookup(host).is_some(), "{host} should route");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_or_unusable_host_routes_nowhere() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let set = build(dir.path(), "one", "two");
|
||||||
|
assert!(set.lookup("three.test").is_none());
|
||||||
|
assert!(set.lookup("").is_none());
|
||||||
|
// A control byte must not reach the map, let alone a log line.
|
||||||
|
assert!(set.lookup("one.test\r\nX: y").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sites_sharing_a_root_share_one_site() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let set = build(dir.path(), "shared", "shared");
|
||||||
|
assert_eq!(set.len(), 1, "one root, one Site, one set of caches");
|
||||||
|
assert!(Arc::ptr_eq(set.lookup("one.test").unwrap(), set.lookup("two.test").unwrap()));
|
||||||
|
assert!(Arc::ptr_eq(set.by_name("one").unwrap(), set.by_name("two").unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn looks_a_site_up_by_its_configured_name() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let set = build(dir.path(), "one", "two");
|
||||||
|
assert!(set.by_name("two").unwrap().root().ends_with("two"));
|
||||||
|
assert!(set.by_name("nope").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue