use super::*;
fn load_with(extra: &str) -> ValidatedConfig {
load_result(extra).expect("validate")
}
fn load_result(extra: &str) -> anyhow::Result<ValidatedConfig> {
let raw = format!(
r#"
[knot]
hostname = "knot.test"
owner = "did:plc:owner"
[repo]
scan_path = "repos"
[secrets]
sealed_key_file = "sealed-keys"
[output]
root = "output"
{extra}
"#
);
ValidatedConfig::validated(toml::from_str(&raw)?)
}
#[test]
fn the_example_config_loads_and_its_limits_are_the_defaults() {
let example = ValidatedConfig::from_toml(include_str!("../../knot-static.toml.example"))
.expect("the example validates");
assert_eq!(
format!("{:?}", example.serve.limits),
format!("{:?}", ServeLimits::default())
);
}
#[test]
fn a_misplaced_or_misspelled_key_is_refused_by_name() {
let error = load_result("[[activate.hooks]]\nname = \"render\"\ncommand = \"/bin/true\"")
.err()
.expect("hooks is a top-level table");
assert!(
format!("{error:#}").contains("unknown field `hooks`"),
"{error:#}"
);
for (extra, key) in [
("[index]\nlink = \"https://example.test\"", "link"),
("[serve.limits]\npack_rates = \"2m\"", "pack_rates"),
("[site_hook]\ncommand = \"/bin/true\"", "site_hook"),
(
"[[hooks]]\nname = \"render\"\ncommand = \"/bin/true\"\nargv = []",
"argv",
),
("[knots]\nhostname = \"knot.test\"", "knots"),
] {
let error = load_result(extra).err().expect(extra);
assert!(
format!("{error:#}").contains(&format!("unknown field `{key}`")),
"{error:#}"
);
}
let hooks = load_with(
"[[hooks]]\nname = \"svfe-pages\"\ncommand = \"/bin/true\"\n[[hooks]]\nname = \"notify_1\"\ncommand = \"/bin/true\"",
);
assert_eq!(hooks.activate.hooks.len(), 2);
for (bad, says) in [
(
"[[hooks]]\nname = \"\"\ncommand = \"/bin/true\"",
"plain name",
),
(
"[[hooks]]\nname = \"../escape\"\ncommand = \"/bin/true\"",
"plain name",
),
(
"[[hooks]]\nname = \"a/b\"\ncommand = \"/bin/true\"",
"plain name",
),
(
"[[hooks]]\nname = \"dup\"\ncommand = \"/bin/true\"\n[[hooks]]\nname = \"dup\"\ncommand = \"/bin/true\"",
"more than once",
),
] {
let error = load_result(bad).err().expect(bad);
assert!(format!("{error:#}").contains(says), "{bad}: {error:#}");
}
}
/// what nix/module.nix writes before any `settings` of its own
#[test]
fn the_nixos_modules_default_settings_load() {
ValidatedConfig::from_toml(
r#"
[activate]
listen = "127.0.0.1:8080"
nginx_binary = "/nix/store/nginx/bin/nginx"
real_ip_from = ["127.0.0.1"]
reload_command = "p=$(cat /var/lib/knot-static/out/runtime/nginx.pid 2>/dev/null); [ -z \"$p\" ] || kill -HUP $p"
[crawl]
urls = ["https://index.tangled.network"]
[firehose]
socket = "/run/knot-static-firehose.sock"
[knot]
hostname = "git.example.com"
owner = "did:plc:owner"
[output]
root = "/var/lib/knot-static/out"
[repo]
default_branch = "main"
object_format = "sha1"
scan_path = "/var/lib/knot-static/src"
[secrets]
sealed_key_file = "/var/lib/knot-static/keys.sealed"
[writes]
cgi_binary = "/run/current-system/sw/bin/knot-static"
enabled = true
fastcgi_socket = "unix:/run/knot-static/fcgiwrap.sock"
"#,
)
.expect("the module's settings validate");
}
#[test]
fn trusted_clients_are_addresses_or_prefixes_with_a_rate() {
let served = load_with(
"[serve]\ntrusted = [\"203.0.113.5\", \"2a01:4f8::/32\"]\ntrusted_rate = \"20m\"",
);
let trusted: Vec<String> = served
.serve
.trusted
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(trusted, ["203.0.113.5", "2a01:4f8::/32"]);
assert_eq!(served.serve.trusted_rate(), "20m");
assert_eq!(load_with("").serve.trusted_rate(), "0");
for bad in [
"[serve]\ntrusted = [\"203.0.113.5/33\"]",
"[serve]\ntrusted = [\"2a01:4f8::/129\"]",
"[serve]\ntrusted = [\"203.0.113.0/notanumber\"]",
"[serve]\ntrusted = [\"not-an-address\"]",
"[serve]\ntrusted_rate = \"fast\"",
"[serve]\ntrusted_rate = \"m\"",
] {
assert!(load_result(bad).is_err(), "{bad} must be refused");
}
}
#[test]
fn what_a_client_is_served_at_is_part_of_the_generation_identity() {
let digest = |extra: &str| load_with(extra).config_digest();
let base = digest("");
for changed in [
"[serve]\ntrusted = [\"203.0.113.5\"]",
"[serve]\ntrusted_rate = \"20m\"",
"[serve.limits]\npack_rate = \"8m\"",
"[serve.limits]\npack_rate_after = \"off\"",
"[serve.limits]\npacks_in_flight = 8",
"[serve.limits]\nreads = \"5r/s\"",
"[serve.limits]\nreads_burst = 0",
"[serve.limits]\nread_connections = \"off\"",
"[serve.limits]\nblob_connections = 4",
"[serve.limits]\nblob_rate = \"1m\"",
"[serve.limits]\nblob_rate_after = \"1m\"",
"[serve.limits]\ninflate_rate_all = \"9r/s\"",
] {
assert_ne!(
digest(changed),
base,
"{changed} must rotate the generation"
);
}
}
#[test]
fn a_rate_counts_the_requests_of_a_span() {
let rate = |text: &str| RequestRate::parse(text, "test").unwrap();
assert_eq!(rate("50r/s").over(30), 1500);
assert_eq!(rate("4r/m").over(30), 2);
assert_eq!(rate("1r/m").over(30), 0);
}
#[test]
fn a_limit_is_a_number_a_size_or_off_and_says_so_when_it_is_not() {
fn text<T: ToString>(value: Option<T>) -> Option<String> {
value.map(|value| value.to_string())
}
let defaults = load_with("").serve.limits;
assert_eq!(text(defaults.pack_rate).as_deref(), Some("2m"));
assert_eq!(text(defaults.packs_in_flight).as_deref(), Some("16"));
assert_eq!(text(defaults.reads).as_deref(), Some("20r/s"));
assert_eq!(text(defaults.inflate_rate_all).as_deref(), Some("50r/s"));
let set = load_with(
"[serve.limits]\npack_rate = \"512k\"\npacks_in_flight = \"off\"\nreads = \"4r/m\"\nblob_rate = 0\nreads_burst = 10\ninflate_rate_all = \"3r/m\"",
)
.serve
.limits;
assert_eq!(text(set.pack_rate).as_deref(), Some("512k"));
assert_eq!(set.packs_in_flight, None);
assert_eq!(text(set.reads).as_deref(), Some("4r/m"));
assert_eq!(set.blob_rate, None, "a zero size is no limit");
assert_eq!(set.reads_burst, 10);
assert_eq!(text(set.inflate_rate_all).as_deref(), Some("3r/m"));
assert!(
load_with("[serve.limits]\ninflate_rate_all = \"off\"")
.serve
.limits
.inflate_rate_all
.is_none()
);
for (bad, says) in [
("[serve.limits]\npacks_in_flight = -1", "is not a count"),
(
"[serve.limits]\npacks_in_flight = 65536",
"more connections than nginx counts",
),
(
"[serve.limits]\npacks_in_flight = \"lots\"",
"is not a count",
),
("[serve.limits]\npack_rate = \"2x\"", "is not a size"),
("[serve.limits]\npack_rate = -5", "is not a size"),
("[serve.limits]\nblob_rate_after = true", "is not a size"),
("[serve.limits]\nreads = \"20\"", "is not a rate"),
("[serve.limits]\nreads = \"20r/h\"", "is not a rate"),
("[serve.limits]\ninflate_rate_all = 5", "is not a rate"),
] {
let error = load_result(bad)
.err()
.unwrap_or_else(|| panic!("{bad} must be refused"))
.to_string();
assert!(error.contains(says), "{bad} said {error:?}");
}
}
#[test]
fn private_addresses_are_blocked_unless_allowed() {
assert!(load_with("").http_limits().block_private_addresses);
let allowed = load_with("[atproto]\nallow_private_addresses = true");
assert!(!allowed.http_limits().block_private_addresses);
}
#[test]
fn crawl_urls_are_validated() {
let config = load_with("[crawl]\nurls = [\"https://hydrant.test\"]");
assert_eq!(config.crawlers[0].as_str(), "https://hydrant.test/");
let raw = "[knot]\nhostname = \"k.test\"\nowner = \"did:plc:o\"\n[repo]\nscan_path = \"r\"\n[secrets]\nsealed_key_file = \"s\"\n[output]\nroot = \"o\"\n[crawl]\nurls = [\"not a url\"]\n";
assert!(ValidatedConfig::validated(toml::from_str(raw).unwrap()).is_err());
}
#[test]
fn the_digest_follows_what_shapes_nginx_even_with_writes_off() {
let plain = load_with("").config_digest();
let socket = load_with("[writes]\nfastcgi_socket = \"unix:/run/a.sock\"").config_digest();
let other = load_with("[writes]\nfastcgi_socket = \"unix:/run/b.sock\"").config_digest();
let proxied = load_with("[activate]\nreal_ip_from = [\"127.0.0.1\"]").config_digest();
let firehose = load_with("[firehose]\nsocket = \"/run/f.sock\"").config_digest();
let digests = [plain, socket, other, proxied, firehose];
for (n, digest) in digests.iter().enumerate() {
assert!(!digests[..n].contains(digest), "{n}");
}
}
#[test]
fn the_firehose_socket_is_an_absolute_path_nginx_can_name() {
let config = load_with("[firehose]\nsocket = \"/run/knot-static/firehose.sock\"");
assert_eq!(
config.firehose_socket.as_deref(),
Some(Path::new("/run/knot-static/firehose.sock"))
);
assert_eq!(load_with("").firehose_socket, None);
for bad in ["run/f.sock", "/run/a:b.sock", "/run/$f.sock"] {
let error = load_result(&format!("[firehose]\nsocket = \"{bad}\""))
.err()
.unwrap_or_else(|| panic!("{bad} must be refused"))
.to_string();
assert!(error.contains("firehose.socket"), "{bad} said {error:?}");
}
}
#[test]
fn real_ip_from_takes_addresses_and_cidrs_only() {
let config = load_with("[activate]\nreal_ip_from = [\"127.0.0.1\", \"10.0.0.0/8\", \"::1\"]");
assert_eq!(config.activate.real_ip_from.len(), 3);
let raw = "[knot]\nhostname = \"k.test\"\nowner = \"did:plc:o\"\n[repo]\nscan_path = \"r\"\n[secrets]\nsealed_key_file = \"s\"\n[output]\nroot = \"o\"\n[activate]\nreal_ip_from = [\"all; deny all\"]\n";
assert!(ValidatedConfig::validated(toml::from_str(raw).unwrap()).is_err());
}
#[test]
fn index_text_defaults_and_overrides_change_the_digest() {
let defaults = load_with("");
assert_eq!(defaults.index, IndexConfig::default());
let custom = load_with(
"[index]\ntitle = \"{owner}'s knot\"\nog_description = \"a\\nb\\nc\"\nog_alt = \"card\"",
);
assert_eq!(custom.index.title, "{owner}'s knot");
assert_eq!(custom.index.og_description, "a\nb\nc");
assert_eq!(custom.index.og_alt.as_deref(), Some("card"));
assert_eq!(custom.index.subtitle, IndexConfig::default().subtitle);
assert_ne!(defaults.config_digest(), custom.config_digest());
assert_ne!(
load_with("[index]\nog_alt = \"\"").config_digest(),
defaults.config_digest()
);
let error = load_result("[index]\nog_description = \"a\\nb\\nc\\nd\"")
.err()
.expect("a fourth line is refused");
assert!(
format!("{error:#}").contains("more than 3 lines"),
"{error:#}"
);
let links = load_with("[index]\nlinks = \"https://{hostname}\"");
assert_eq!(links.index.links, "https://{hostname}");
assert_ne!(links.config_digest(), defaults.config_digest());
let error = load_result("[index]\nlinks = \"javascript:alert(1)\"")
.err()
.expect("only http(s) links");
assert!(format!("{error:#}").contains("index.links"), "{error:#}");
}
#[test]
fn index_fonts_parse_validate_and_change_the_digest() {
let fonts = "[index.fonts]\nsans = \"Outfit\"\nmono = \"Maple Mono\"\n\
[[index.fonts.faces]]\nfamily = \"Outfit\"\nurl = \"/fonts/outfit-400.woff2\"\n\
[[index.fonts.faces]]\nfamily = \"Outfit\"\nweight = 700\nurl = \"/fonts/outfit-700.woff2\"\n\
[[index.fonts.faces]]\nfamily = \"Maple Mono\"\nitalic = true\nurl = \"https://cdn.test/maple-400i.woff2\"\n";
let config = load_with(fonts);
let parsed = config.index.fonts.as_ref().expect("fonts");
assert_eq!(parsed.sans.as_deref(), Some("Outfit"));
assert_eq!(parsed.faces.len(), 3);
assert_eq!(
(parsed.faces[0].weight, parsed.faces[0].italic),
(400, false)
);
assert_eq!(
(parsed.faces[2].weight, parsed.faces[2].italic),
(400, true)
);
assert_eq!(load_with("").index.fonts, None);
assert_ne!(config.config_digest(), load_with("").config_digest());
let heavier = fonts.replace("weight = 700", "weight = 800");
assert_ne!(config.config_digest(), load_with(&heavier).config_digest());
for (bad, why) in [
(
fonts.replace("/fonts/outfit-700.woff2", "//cdn.test/a.woff2"),
"url",
),
(
fonts.replace("/fonts/outfit-700.woff2", "/fonts/a\\\").woff2"),
"url",
),
(
fonts.replace("/fonts/outfit-700.woff2", "javascript:x"),
"url",
),
(
fonts.replace("mono = \"Maple Mono\"", "mono = \"Iosevka\""),
"no face for",
),
(
fonts.replace("sans = \"Outfit\"\n", ""),
"neither the sans nor the mono",
),
(fonts.replace("weight = 700", "weight = 0"), "outside"),
(
fonts.replace("sans = \"Outfit\"", "sans = \"Out;fit\""),
"plain name",
),
(
"[index.fonts]\nfaces = []".to_string(),
"neither sans nor mono",
),
] {
let error = load_result(&bad).err().expect(why);
assert!(format!("{error:#}").contains(why), "{why}: {error:#}");
}
}