feat: add workspace skeleton and server configuration validation
This commit is contained in:
commit
6a3a198975
20 changed files with 1891 additions and 0 deletions
18
core/Cargo.toml
Normal file
18
core/Cargo.toml
Normal 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
583
core/src/config.rs
Normal 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
64
core/src/error.rs
Normal 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
10
core/src/lib.rs
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue