feat: add workspace skeleton and server configuration validation

This commit is contained in:
randogoth 2026-10-04 18:55:33 +03:00
commit 6a3a198975
20 changed files with 1891 additions and 0 deletions

18
core/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "itsybitsy-core"
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
description = "Path resolution, configuration and the output-format trait behind itsybitsy"
license = "Apache-2.0"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }
toml = "1.1"
[dev-dependencies]
tempfile = "3"
[lints.rust]
unsafe_code = "forbid"

583
core/src/config.rs Normal file
View file

@ -0,0 +1,583 @@
//! The server configuration file.
//!
//! `itsybitsy.toml`, passed with `--config`, declares the virtual hosts and the
//! listeners. Everything the schema cannot express is checked by
//! [`ServerConfig::validate`] at startup, so a mistake is a refusal to boot
//! rather than a surprising response later.
//!
//! How the Markdown in a directory renders is configured separately, by a
//! `.itsybitsy.toml` in that directory, which does not inherit from anywhere.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::Error;
/// Schema version of the server file, bumped only on an incompatible change.
pub const VERSION: u32 = 1;
/// Reserved file name of a per-directory config. It begins with a dot, so the
/// "reject any component starting with `.`" rule in path cleaning already keeps
/// it out of the URL space.
pub const DIR_CONFIG: &str = ".itsybitsy.toml";
const DEFAULT_MAX_CONNECTIONS: u32 = 256;
const DEFAULT_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
pub version: u32,
#[serde(default)]
pub site: BTreeMap<String, SiteSpec>,
#[serde(default)]
pub listener: BTreeMap<String, ListenerSpec>,
}
/// One virtual host: a content root and the names that reach it.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SiteSpec {
/// Content root. Every resolved path must canonicalise inside this.
pub root: PathBuf,
/// Names this site answers to, compared after [`normalize_host`]. A site
/// with none is reachable only by being named as a listener's `site` or
/// `default_site`.
#[serde(default)]
pub hosts: Vec<String>,
/// Gemini only; unused until that listener exists.
#[serde(default)]
pub tls: Option<TlsSpec>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TlsSpec {
pub cert: PathBuf,
pub key: PathBuf,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListenerSpec {
pub protocol: Protocol,
pub bind: String,
/// Formats this listener can serve, most preferred first. For HTTP a tie on
/// q breaks by this order and the last entry is the fallback; every other
/// protocol serves exactly one format.
pub formats: Vec<String>,
/// The one site served, for a protocol that carries no hostname.
#[serde(default)]
pub site: Option<String>,
/// Fallback site when the request names a host this server does not know.
#[serde(default)]
pub default_site: Option<String>,
/// Columns to wrap to, overriding the renderer's own default. Clients that
/// wrap for themselves (Gemini, Spartan) want this unset.
#[serde(default)]
pub width: Option<u16>,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
fn default_max_connections() -> u32 {
DEFAULT_MAX_CONNECTIONS
}
fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Http,
Spartan,
Nex,
Gemini,
Gopher,
}
impl Protocol {
pub fn as_str(self) -> &'static str {
match self {
Protocol::Http => "http",
Protocol::Spartan => "spartan",
Protocol::Nex => "nex",
Protocol::Gemini => "gemini",
Protocol::Gopher => "gopher",
}
}
/// Whether a request carries a hostname it can be routed by. Nex and Gopher
/// do not, so their listeners name one site outright.
pub fn is_host_addressed(self) -> bool {
matches!(self, Protocol::Http | Protocol::Spartan | Protocol::Gemini)
}
/// Whether more than one format can be negotiated. Only HTTP has an
/// `Accept` header to negotiate with.
pub fn negotiates(self) -> bool {
matches!(self, Protocol::Http)
}
}
/// What validation established, and what `--check` prints.
#[derive(Debug)]
pub struct Checked {
/// Normalised host → site name: the map every request is routed through.
pub hosts: BTreeMap<String, String>,
/// Site name → canonicalised content root.
pub roots: BTreeMap<String, PathBuf>,
/// Groups of site names sharing one canonical root. They will share one
/// `Site`, and so one render cache.
pub shared_roots: Vec<Vec<String>>,
/// Every format some enabled listener can serve, which is the set each page
/// is rendered into.
pub formats: BTreeSet<String>,
}
impl ServerConfig {
/// Read and parse the server file. Schema violations are reported here;
/// cross-field rules are [`ServerConfig::validate`]'s job.
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 })?;
toml::from_str(&text).map_err(|cause| Error::Toml { path: path.to_path_buf(), cause })
}
/// Check every rule the schema cannot express, resolving roots and the host
/// map on the way. `available_formats` is the renderer registry's id set, so
/// a format switched off by a cargo feature fails here by name instead of
/// becoming a server error on first request.
pub fn validate(
&self,
config_path: &Path,
available_formats: &[&str],
) -> Result<Checked, Error> {
if self.version != VERSION {
return Err(Error::config(format!(
"unsupported config version {}, expected {VERSION}",
self.version
)));
}
if self.site.is_empty() {
return Err(Error::config("no sites defined; at least one [site.<name>] is required"));
}
if self.listener.is_empty() {
return Err(Error::config(
"no listeners defined; at least one [listener.<name>] is required",
));
}
let roots = self.resolve_roots()?;
self.reject_config_inside_root(config_path, &roots)?;
let hosts = self.build_host_map()?;
let formats = self.validate_listeners(available_formats)?;
Ok(Checked {
hosts,
roots: roots.clone(),
shared_roots: group_shared_roots(&roots),
formats,
})
}
/// Canonicalise every content root, which also proves it exists and is a
/// directory before any request can reach it.
fn resolve_roots(&self) -> Result<BTreeMap<String, PathBuf>, Error> {
let mut roots = BTreeMap::new();
for (name, spec) in &self.site {
let root = spec
.root
.canonicalize()
.map_err(|cause| Error::Io { path: spec.root.clone(), cause })?;
if !root.is_dir() {
return Err(Error::config(format!(
"site '{name}': root {} is not a directory",
root.display()
)));
}
roots.insert(name.clone(), root);
}
Ok(roots)
}
/// The server file must not sit inside a content root, where it would be a
/// served file listing every other site's paths. A per-directory config is
/// a dotfile and so already unreachable; this one is not.
fn reject_config_inside_root(
&self,
config_path: &Path,
roots: &BTreeMap<String, PathBuf>,
) -> Result<(), Error> {
// A path that does not canonicalise cannot be inside a root either, and
// `load` has already read it, so there is nothing to report here.
let Ok(config) = config_path.canonicalize() else { return Ok(()) };
for (name, root) in roots {
if config.starts_with(root) {
return Err(Error::config(format!(
"config file {} is inside site '{name}' root {}, where it would be served",
config.display(),
root.display()
)));
}
}
Ok(())
}
fn build_host_map(&self) -> Result<BTreeMap<String, String>, Error> {
let mut hosts: BTreeMap<String, String> = BTreeMap::new();
for (name, spec) in &self.site {
for raw in &spec.hosts {
let host = normalize_host(raw).ok_or_else(|| {
Error::config(format!("site '{name}': '{raw}' is not a usable hostname"))
})?;
if let Some(first) = hosts.get(&host) {
return Err(Error::DuplicateHost {
host,
first: first.clone(),
second: name.clone(),
});
}
hosts.insert(host, name.clone());
}
}
Ok(hosts)
}
fn validate_listeners(&self, available: &[&str]) -> Result<BTreeSet<String>, Error> {
let mut formats = BTreeSet::new();
for (name, spec) in &self.listener {
if spec.formats.is_empty() {
return Err(Error::config(format!("listener '{name}': formats must not be empty")));
}
if spec.formats.len() > 1 && !spec.protocol.negotiates() {
return Err(Error::config(format!(
"listener '{name}': {} serves one format, but {} are listed; only http \
negotiates",
spec.protocol.as_str(),
spec.formats.len()
)));
}
for format in &spec.formats {
if !available.contains(&format.as_str()) {
return Err(Error::UnknownFormat {
listener: name.clone(),
format: format.clone(),
available: available.iter().map(|id| id.to_string()).collect(),
});
}
formats.insert(format.clone());
}
self.validate_listener_site(name, spec)?;
}
Ok(formats)
}
/// `site` and `default_site` are not interchangeable: a protocol carrying a
/// hostname routes by it and falls back, one that does not serves a single
/// site outright. Mixing them up would silently serve the wrong content.
fn validate_listener_site(&self, name: &str, spec: &ListenerSpec) -> Result<(), Error> {
for (key, value) in [("site", &spec.site), ("default_site", &spec.default_site)] {
if let Some(site) = value
&& !self.site.contains_key(site)
{
return Err(Error::config(format!(
"listener '{name}': {key} = '{site}' names no defined site"
)));
}
}
if spec.protocol.is_host_addressed() {
if spec.site.is_some() {
return Err(Error::config(format!(
"listener '{name}': {} routes by hostname, so use default_site, not site",
spec.protocol.as_str()
)));
}
return Ok(());
}
if spec.default_site.is_some() {
return Err(Error::config(format!(
"listener '{name}': {} carries no hostname, so use site, not default_site",
spec.protocol.as_str()
)));
}
// With one site defined there is nothing to get wrong; with two or more,
// leaving it implicit would let adding a vhost silently change which
// site this listener serves.
if spec.site.is_none() && self.site.len() > 1 {
return Err(Error::config(format!(
"listener '{name}': {} carries no hostname, so site is required when more than \
one site is defined",
spec.protocol.as_str()
)));
}
Ok(())
}
}
/// Site names that canonicalise to the same root, so they can share one `Site`.
fn group_shared_roots(roots: &BTreeMap<String, PathBuf>) -> Vec<Vec<String>> {
let mut by_root: BTreeMap<&PathBuf, Vec<String>> = BTreeMap::new();
for (name, root) in roots {
by_root.entry(root).or_default().push(name.clone());
}
by_root.into_values().filter(|names| names.len() > 1).collect()
}
/// Normalise a hostname into a routing key: lowercased, trailing dots and a
/// `:port` suffix removed.
///
/// Returns `None` when a character outside the allowlist survives, which is
/// what keeps unvalidated request bytes out of the host map and the log.
pub fn normalize_host(raw: &str) -> Option<String> {
let trimmed = raw.trim();
let without_port = if trimmed.starts_with('[') {
// Bracketed IPv6 literal: the port, if any, follows the bracket.
&trimmed[..=trimmed.find(']')?]
} else {
match trimmed.rsplit_once(':') {
// An unbracketed colon elsewhere means a bare IPv6 literal rather
// than a port, so leave it alone.
Some((host, port))
if !host.contains(':')
&& !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit()) =>
{
host
}
_ => trimmed,
}
};
let host = without_port.trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() {
return None;
}
let allowed = host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':' | b'[' | b']'));
allowed.then_some(host)
}
#[cfg(test)]
mod tests {
use super::*;
const FORMATS: &[&str] = &["gemtext", "html", "xhtmlmp", "nex"];
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, body).unwrap();
path
}
/// A config whose single site root is `root`, with the given extra tables.
fn config_text(root: &Path, extra: &str) -> String {
format!(
"version = 1\n\n[site.one]\nroot = {:?}\nhosts = [\"one.test\"]\n\n{extra}",
root.to_str().unwrap()
)
}
fn check(dir: &Path, extra: &str) -> Result<Checked, Error> {
let root = dir.join("content");
fs::create_dir_all(&root).unwrap();
// Outside the root, as the rule under test requires.
let path = write(dir, "itsybitsy.toml", &config_text(&root, extra));
ServerConfig::load(&path)?.validate(&path, FORMATS)
}
const HTTP: &str = "[listener.web]\nprotocol = \"http\"\nbind = \"0.0.0.0:8080\"\n\
formats = [\"xhtmlmp\", \"html\"]\n";
#[test]
fn accepts_a_minimal_config_and_reports_what_it_resolved() {
let dir = tempfile::tempdir().unwrap();
let checked = check(dir.path(), HTTP).unwrap();
assert_eq!(checked.hosts.get("one.test").map(String::as_str), Some("one"));
assert_eq!(
checked.formats.iter().map(String::as_str).collect::<Vec<_>>(),
["html", "xhtmlmp"]
);
assert!(checked.shared_roots.is_empty());
assert_eq!(checked.roots["one"], dir.path().join("content").canonicalize().unwrap());
}
#[test]
fn listener_defaults_are_applied() {
let text = "protocol = \"nex\"\nbind = \":1900\"\nformats = [\"nex\"]\n";
let spec: ListenerSpec = toml::from_str(text).unwrap();
assert_eq!(spec.max_connections, DEFAULT_MAX_CONNECTIONS);
assert_eq!(spec.timeout_secs, DEFAULT_TIMEOUT_SECS);
assert_eq!(spec.width, None);
}
#[test]
fn rejects_an_unknown_key() {
let dir = tempfile::tempdir().unwrap();
let err = check(dir.path(), &format!("{HTTP}bidn = \"typo\"\n")).unwrap_err();
assert!(matches!(err, Error::Toml { .. }), "{err}");
}
#[test]
fn rejects_a_format_this_build_does_not_provide() {
let dir = tempfile::tempdir().unwrap();
let listener = "[listener.web]\nprotocol = \"http\"\nbind = \":8080\"\n\
formats = [\"wml\"]\n";
let err = check(dir.path(), listener).unwrap_err();
let Error::UnknownFormat { format, available, .. } = &err else {
panic!("wrong variant: {err}")
};
assert_eq!(format, "wml");
assert!(available.contains(&"gemtext".to_string()));
}
#[test]
fn rejects_several_formats_on_a_protocol_that_cannot_negotiate() {
let dir = tempfile::tempdir().unwrap();
let listener = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\n\
formats = [\"nex\", \"gemtext\"]\n";
let err = check(dir.path(), listener).unwrap_err();
assert!(err.to_string().contains("only http negotiates"), "{err}");
}
#[test]
fn rejects_empty_formats() {
let dir = tempfile::tempdir().unwrap();
let listener = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\nformats = []\n";
let err = check(dir.path(), listener).unwrap_err();
assert!(err.to_string().contains("must not be empty"), "{err}");
}
#[test]
fn rejects_site_on_a_host_addressed_listener() {
let dir = tempfile::tempdir().unwrap();
let err = check(dir.path(), &format!("{HTTP}site = \"one\"\n")).unwrap_err();
assert!(err.to_string().contains("use default_site, not site"), "{err}");
}
#[test]
fn rejects_default_site_on_a_listener_carrying_no_hostname() {
let dir = tempfile::tempdir().unwrap();
let listener = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\n\
formats = [\"nex\"]\ndefault_site = \"one\"\n";
let err = check(dir.path(), listener).unwrap_err();
assert!(err.to_string().contains("use site, not default_site"), "{err}");
}
#[test]
fn nex_may_omit_site_with_one_site_but_not_with_two() {
let dir = tempfile::tempdir().unwrap();
let nex = "[listener.n]\nprotocol = \"nex\"\nbind = \":1900\"\nformats = [\"nex\"]\n";
assert!(check(dir.path(), nex).is_ok());
let second = dir.path().join("other");
fs::create_dir_all(&second).unwrap();
let two = format!(
"{nex}\n[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\n",
second.to_str().unwrap()
);
let err = check(dir.path(), &two).unwrap_err();
assert!(err.to_string().contains("site is required"), "{err}");
}
#[test]
fn rejects_a_listener_naming_an_undefined_site() {
let dir = tempfile::tempdir().unwrap();
let err = check(dir.path(), &format!("{HTTP}default_site = \"nope\"\n")).unwrap_err();
assert!(err.to_string().contains("names no defined site"), "{err}");
}
#[test]
fn rejects_one_host_claimed_by_two_sites() {
let dir = tempfile::tempdir().unwrap();
let second = dir.path().join("other");
fs::create_dir_all(&second).unwrap();
// Differing case and a trailing dot still collide once normalised.
let two = format!(
"{HTTP}\n[site.two]\nroot = {:?}\nhosts = [\"One.Test.\"]\n",
second.to_str().unwrap()
);
let err = check(dir.path(), &two).unwrap_err();
assert!(matches!(err, Error::DuplicateHost { .. }), "{err}");
}
#[test]
fn reports_sites_sharing_one_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("content");
fs::create_dir_all(&root).unwrap();
let two = format!(
"{HTTP}\n[site.two]\nroot = {:?}\nhosts = [\"two.test\"]\n",
root.to_str().unwrap()
);
let checked = check(dir.path(), &two).unwrap();
assert_eq!(checked.shared_roots, vec![vec!["one".to_string(), "two".to_string()]]);
}
#[test]
fn rejects_a_config_file_inside_a_content_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("content");
fs::create_dir_all(&root).unwrap();
let path = write(&root, "itsybitsy.toml", &config_text(&root, HTTP));
let err = ServerConfig::load(&path).unwrap().validate(&path, FORMATS).unwrap_err();
assert!(err.to_string().contains("where it would be served"), "{err}");
}
#[test]
fn rejects_a_missing_root() {
let dir = tempfile::tempdir().unwrap();
let text = format!(
"version = 1\n\n[site.one]\nroot = {:?}\n\n{HTTP}",
dir.path().join("absent").to_str().unwrap()
);
let path = write(dir.path(), "itsybitsy.toml", &text);
let err = ServerConfig::load(&path).unwrap().validate(&path, FORMATS).unwrap_err();
assert!(matches!(err, Error::Io { .. }), "{err}");
}
#[test]
fn rejects_an_unsupported_version() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("content");
fs::create_dir_all(&root).unwrap();
let text = config_text(&root, HTTP).replace("version = 1", "version = 2");
let path = write(dir.path(), "itsybitsy.toml", &text);
let err = ServerConfig::load(&path).unwrap().validate(&path, FORMATS).unwrap_err();
assert!(err.to_string().contains("unsupported config version 2"), "{err}");
}
#[test]
fn normalizes_hosts() {
assert_eq!(normalize_host("Example.COM"), Some("example.com".into()));
assert_eq!(normalize_host("example.com."), Some("example.com".into()));
assert_eq!(normalize_host("example.com:8080"), Some("example.com".into()));
assert_eq!(normalize_host(" example.com "), Some("example.com".into()));
assert_eq!(normalize_host("[::1]:1965"), Some("[::1]".into()));
assert_eq!(normalize_host("[::1]"), Some("[::1]".into()));
// A bare IPv6 literal: the last colon is not a port separator.
assert_eq!(normalize_host("::1"), Some("::1".into()));
}
#[test]
fn rejects_unusable_hosts() {
assert_eq!(normalize_host(""), None);
assert_eq!(normalize_host("."), None);
assert_eq!(normalize_host("exam ple.com"), None);
assert_eq!(normalize_host("example.com/path"), None);
// A control byte must never reach the host map or a log line.
assert_eq!(normalize_host("example.com\r\nX: y"), None);
assert_eq!(normalize_host("[::1"), None);
}
}

64
core/src/error.rs Normal file
View file

@ -0,0 +1,64 @@
//! The library error type.
//!
//! Variants carry the data a caller needs to act or an operator needs to fix
//! the cause, rather than a pre-formatted sentence.
use std::fmt;
use std::io;
use std::path::PathBuf;
#[derive(Debug)]
pub enum Error {
/// Reading or canonicalising a path named by the configuration failed.
Io { path: PathBuf, cause: io::Error },
/// A configuration file is not valid TOML, or does not match the schema.
Toml { path: PathBuf, cause: toml::de::Error },
/// A rule the schema cannot express was violated.
Config { message: String },
/// Two sites claim one hostname, so a request naming it could not be routed.
DuplicateHost { host: String, first: String, second: String },
/// A listener names a format no enabled feature provides. `available` is
/// the registry's full id set, which is what tells an operator whether the
/// name is a typo or a missing cargo feature.
UnknownFormat { listener: String, format: String, available: Vec<String> },
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// The cause is reachable through `source`, so it is not repeated here.
Error::Io { path, .. } => write!(f, "cannot read {}", path.display()),
Error::Toml { path, .. } => write!(f, "invalid TOML in {}", path.display()),
Error::Config { message } => write!(f, "{message}"),
Error::DuplicateHost { host, first, second } => {
write!(f, "host '{host}' is claimed by both site '{first}' and site '{second}'")
}
Error::UnknownFormat { listener, format, available } => write!(
f,
"listener '{listener}' wants format '{format}', which this build does not \
provide; available: {}",
if available.is_empty() { "none".to_string() } else { available.join(", ") }
),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io { cause, .. } => Some(cause),
Error::Toml { cause, .. } => Some(cause),
_ => None,
}
}
}
impl Error {
pub(crate) fn config(message: impl Into<String>) -> Self {
Error::Config { message: message.into() }
}
}

10
core/src/lib.rs Normal file
View file

@ -0,0 +1,10 @@
//! Everything itsybitsy does that is not a socket.
//!
//! Resolution, configuration and rendering live here so they can be tested
//! without binding a port; the binary crate holds the protocol listeners and
//! is the only place that names a format crate.
pub mod config;
pub mod error;
pub use error::Error;