use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::error::ConfigError;
use crate::matcher::Pattern;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DeployConfig {
pub version: u32,
pub index: Vec<String>,
pub clean_urls: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub case_insensitive: bool,
pub trailing_slash: TrailingSlash,
pub error_documents: BTreeMap<u16, String>,
pub redirects: Vec<Redirect>,
pub rewrites: Vec<Rewrite>,
pub headers: Vec<HeaderRule>,
pub cache: CacheConfig,
pub mime_overrides: BTreeMap<String, String>,
pub proxy_allow: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub handlers: Vec<HandlerConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub consumers: Vec<ConsumerConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub crons: Vec<CronConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub streams: Vec<StreamConfig>,
}
impl Default for DeployConfig {
fn default() -> Self {
Self {
version: crate::SCHEMA_VERSION,
index: vec!["index.html".to_string()],
clean_urls: false,
case_insensitive: false,
trailing_slash: TrailingSlash::default(),
error_documents: BTreeMap::new(),
redirects: Vec::new(),
rewrites: Vec::new(),
headers: Vec::new(),
cache: CacheConfig::default(),
mime_overrides: BTreeMap::new(),
proxy_allow: Vec::new(),
handlers: Vec::new(),
consumers: Vec::new(),
crons: Vec::new(),
streams: Vec::new(),
}
}
}
impl DeployConfig {
pub fn from_ron(text: &str) -> Result<Self, ConfigError> {
let options = ron::Options::default()
.with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME);
let config: Self = options
.from_str(text)
.map_err(|err| ConfigError::parse(err.to_string()))?;
config.compile_check()?;
Ok(config)
}
pub fn proxy_host_allowed(&self, host: &str) -> bool {
if self.proxy_allow.is_empty() {
return true;
}
let host = host.trim_end_matches('.').to_ascii_lowercase();
self.proxy_allow.iter().any(|entry| {
let entry = entry.trim().to_ascii_lowercase();
match entry.strip_prefix('.') {
Some(suffix) => host == suffix || host.ends_with(&format!(".{suffix}")),
None => host == entry,
}
})
}
pub fn compile_check(&self) -> Result<(), ConfigError> {
for redirect in &self.redirects {
Pattern::compile(&redirect.from)?;
if let Some(when) = &redirect.when {
crate::predicate::Predicate::compile(when)?;
}
if crate::predicate::Template::is_template(&redirect.to) {
crate::predicate::Template::compile(&redirect.to)?;
}
}
for rewrite in &self.rewrites {
Pattern::compile(&rewrite.from)?;
if let Some(when) = &rewrite.when {
crate::predicate::Predicate::compile(when)?;
}
if crate::predicate::Template::is_template(&rewrite.to) {
crate::predicate::Template::compile(&rewrite.to)?;
}
}
for header in &self.headers {
Pattern::compile(&header.matches)?;
}
self.check_handlers()?;
Ok(())
}
fn check_handlers(&self) -> Result<(), ConfigError> {
let handler_patterns: Vec<Pattern> = self
.handlers
.iter()
.map(|h| Pattern::compile(&h.route))
.collect::<Result<_, _>>()?;
for handler in &self.handlers {
if handler.component.is_empty() {
return Err(ConfigError::parse(format!(
"handler {} has an empty component path",
handler.route
)));
}
for method in &handler.methods {
check_http_method(method)?;
}
for import in &handler.imports {
check_import(import)?;
}
for (key, value) in &handler.env {
if looks_like_secret(value) {
return Err(ConfigError::parse(format!(
"handler {} env var {key:?} looks like a secret; move it to \
[handlers].secrets as a reference to a host env var rather than \
inlining it in `env` (which is stored in the manifest)",
handler.route
)));
}
}
}
for consumer in &self.consumers {
if consumer.topic.is_empty() || consumer.component.is_empty() {
return Err(ConfigError::parse(
"consumer needs a non-empty topic and component".to_string(),
));
}
for import in &consumer.imports {
check_import(import)?;
}
}
for cron in &self.crons {
check_cron_schedule(&cron.schedule)?;
if !handler_patterns.iter().any(|p| p.is_match(&cron.route)) {
return Err(ConfigError::parse(format!(
"cron route {} is not served by any declared handler",
cron.route
)));
}
}
for stream in &self.streams {
Pattern::compile(&stream.route)?;
if stream.topics.is_empty() {
return Err(ConfigError::parse(format!(
"stream {} subscribes to no topics",
stream.route
)));
}
}
Ok(())
}
}
const KNOWN_IMPORTS: &[&str] = &[
"sql",
"invoke",
"wasi:http",
"wasi:io",
"wasi:keyvalue",
"wasi:blobstore",
"wasi:messaging",
"wasi:clocks",
"wasi:random",
"wasi:logging",
];
fn looks_like_secret(value: &str) -> bool {
let v = value.trim();
if v.contains("-----BEGIN") && v.contains("PRIVATE KEY") {
return true;
}
const PREFIXES: &[&str] = &[
"AKIA",
"ASIA",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"github_pat_",
"xoxb-",
"xoxp-",
"xoxa-",
"glpat-",
"AIza",
"AccountKey=",
];
if PREFIXES.iter().any(|p| v.contains(p)) {
return true;
}
let has_digit = v.bytes().any(|b| b.is_ascii_digit());
if v.len() >= 40 && has_digit && v.bytes().all(|b| b.is_ascii_hexdigit()) {
return true;
}
let charset_ok = v
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'-' | b'_'));
let mixed_case =
v.bytes().any(|b| b.is_ascii_uppercase()) && v.bytes().any(|b| b.is_ascii_lowercase());
v.len() >= 32 && charset_ok && has_digit && mixed_case && shannon_entropy_bits(v) >= 3.5
}
fn shannon_entropy_bits(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let mut counts = [0u32; 256];
for b in s.bytes() {
counts[b as usize] += 1;
}
let len = s.len() as f64;
counts
.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}
fn check_import(import: &str) -> Result<(), ConfigError> {
if KNOWN_IMPORTS.contains(&import) {
Ok(())
} else {
Err(ConfigError::parse(format!(
"unknown handler import {import:?}; allowed: {}",
KNOWN_IMPORTS.join(", ")
)))
}
}
fn check_http_method(method: &str) -> Result<(), ConfigError> {
const METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
if METHODS.contains(&method) {
Ok(())
} else {
Err(ConfigError::parse(format!(
"unknown HTTP method {method:?}"
)))
}
}
fn check_cron_schedule(schedule: &str) -> Result<(), ConfigError> {
crate::cron::CronSchedule::parse(schedule)
.map(|_| ())
.map_err(|err| ConfigError::parse(format!("cron schedule {schedule:?}: {err}")))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrailingSlash {
#[default]
Preserve,
Always,
Never,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Redirect {
pub from: String,
pub to: String,
#[serde(default = "default_redirect_status")]
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn default_redirect_status() -> u16 {
308
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rewrite {
pub from: String,
pub to: String,
#[serde(default = "default_rewrite_status")]
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn default_rewrite_status() -> u16 {
200
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HeaderRule {
pub matches: String,
#[serde(default)]
pub set: BTreeMap<String, String>,
#[serde(default)]
pub unset: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CacheConfig {
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HandlerConfig {
pub route: String,
#[serde(default)]
pub methods: Vec<String>,
pub component: String,
#[serde(default)]
pub imports: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<HandlerLimits>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub invoke_targets: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HandlerLimits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_mb: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fuel: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConsumerConfig {
pub topic: String,
pub component: String,
#[serde(default)]
pub imports: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CronConfig {
pub schedule: String,
pub route: String,
#[serde(default)]
pub overlap: Overlap,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Overlap {
#[default]
Skip,
Allow,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StreamConfig {
pub route: String,
pub topics: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub websocket: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish_topic: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SiteConfig {
pub version: u32,
pub domains: DomainConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub access: crate::access::AccessConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handlers: Option<HandlersSiteConfig>,
#[serde(default, skip_serializing_if = "CompressionConfig::is_default")]
pub compression: CompressionConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<crate::gateway::GatewayConfig>,
}
impl Default for SiteConfig {
fn default() -> Self {
Self {
version: crate::SCHEMA_VERSION,
domains: DomainConfig::default(),
security: SecurityConfig::default(),
access: crate::access::AccessConfig::default(),
handlers: None,
compression: CompressionConfig::default(),
gateway: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CompressionConfig {
pub enabled: bool,
pub min_size: u64,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
enabled: false,
min_size: 1024,
}
}
}
impl CompressionConfig {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlersSiteConfig {
pub enabled: bool,
pub allow_imports: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_memory_mb: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_timeout_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_fuel: Option<u64>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub secrets: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub background_aliases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_stream_connections: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_log_rate: Option<u32>,
}
impl SiteConfig {
pub fn from_json(bytes: &[u8]) -> Result<Self, ConfigError> {
serde_json::from_slice(bytes).map_err(|err| ConfigError::parse(err.to_string()))
}
pub fn to_json(&self) -> Result<Vec<u8>, ConfigError> {
serde_json::to_vec(self).map_err(|err| ConfigError::parse(err.to_string()))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DomainConfig {
pub primary: Option<String>,
pub aliases: Vec<String>,
pub wildcards: Vec<String>,
pub canonical_redirect: bool,
}
impl DomainConfig {
pub fn exact_hosts(&self) -> impl Iterator<Item = &str> {
self.primary
.as_deref()
.into_iter()
.chain(self.aliases.iter().map(String::as_str))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SecurityConfig {
pub https_redirect: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub hsts: Option<Hsts>,
#[serde(skip_serializing_if = "Option::is_none")]
pub csp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frame_options: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Hsts {
pub max_age: u64,
pub include_subdomains: bool,
pub preload: bool,
}
impl Default for Hsts {
fn default() -> Self {
Self {
max_age: 31_536_000,
include_subdomains: true,
preload: false,
}
}
}
impl Hsts {
pub fn header_value(&self) -> String {
let mut v = format!("max-age={}", self.max_age);
if self.include_subdomains {
v.push_str("; includeSubDomains");
}
if self.preload {
v.push_str("; preload");
}
v
}
}
pub fn transport_redirect(
security: &SecurityConfig,
domains: &DomainConfig,
scheme: &str,
host: &str,
path_and_query: &str,
) -> Option<String> {
let target_scheme = if security.https_redirect && scheme == "http" {
"https"
} else {
scheme
};
let target_host = match &domains.primary {
Some(primary)
if domains.canonical_redirect
&& primary != host
&& domains.aliases.iter().any(|a| a == host) =>
{
primary.as_str()
}
_ => host,
};
if target_scheme == scheme && target_host == host {
return None;
}
Some(format!("{target_scheme}://{target_host}{path_and_query}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_uses_defaults() {
let config = DeployConfig::from_ron("()").unwrap();
assert_eq!(config.index, vec!["index.html".to_string()]);
assert_eq!(config.trailing_slash, TrailingSlash::Preserve);
assert!(config.redirects.is_empty());
}
#[test]
fn transport_redirect_https_canonical_and_noop() {
let mut domains = DomainConfig {
primary: Some("example.com".into()),
aliases: vec!["www.example.com".into()],
..Default::default()
};
let mut sec = SecurityConfig::default();
assert_eq!(
transport_redirect(&sec, &domains, "http", "example.com", "/a?b=1"),
None
);
sec.https_redirect = true;
assert_eq!(
transport_redirect(&sec, &domains, "http", "example.com", "/a?b=1").as_deref(),
Some("https://example.com/a?b=1")
);
assert_eq!(
transport_redirect(&sec, &domains, "https", "example.com", "/a"),
None
);
domains.canonical_redirect = true;
assert_eq!(
transport_redirect(&sec, &domains, "http", "www.example.com", "/p").as_deref(),
Some("https://example.com/p")
);
assert_eq!(
transport_redirect(&sec, &domains, "https", "example.com", "/p"),
None
);
assert_eq!(
transport_redirect(&sec, &domains, "https", "blog.example.com", "/p"),
None
);
sec.https_redirect = false;
assert_eq!(
transport_redirect(&sec, &domains, "https", "www.example.com", "/p").as_deref(),
Some("https://example.com/p"),
"canonical redirect applies even without https_redirect"
);
}
#[test]
fn hsts_header_value() {
assert_eq!(
Hsts::default().header_value(),
"max-age=31536000; includeSubDomains"
);
assert_eq!(
Hsts {
max_age: 60,
include_subdomains: false,
preload: true
}
.header_value(),
"max-age=60; preload"
);
}
#[test]
fn secret_heuristic_flags_credentials_not_plain_config() {
for ok in [
"info",
"production",
"https://api.example.com/v1",
"3000",
"en-US,en;q=0.9",
"a-normal-kebab-case-flag",
] {
assert!(!looks_like_secret(ok), "false positive on {ok:?}");
}
let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIabc\n-----END RSA PRIVATE KEY-----";
for bad in [
pem,
"AKIAIOSFODNN7EXAMPLE",
"ghp_16C7e42F292c6912E7710c838347Ae178B4a", "AIzaSyA-1234567890abcdefghijklmnopqrstuv", "wJalrXUtnFEMI1bK7MDENGbPxRfiCYEXAMPLEKEY12", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0123", ] {
assert!(looks_like_secret(bad), "missed secret {bad:?}");
}
}
#[test]
fn check_handlers_rejects_secret_in_env() {
use std::collections::BTreeMap;
let config = DeployConfig {
handlers: vec![HandlerConfig {
route: "/h".into(),
methods: Vec::new(),
component: "h.wasm".into(),
imports: Vec::new(),
limits: None,
env: BTreeMap::from([("AWS_KEY".to_string(), "AKIAIOSFODNN7EXAMPLE".to_string())]),
invoke_targets: Vec::new(),
}],
..Default::default()
};
let err = config.compile_check().unwrap_err().to_string();
assert!(err.contains("looks like a secret"), "got: {err}");
}
#[test]
fn parses_a_full_document() {
let text = r#"(
clean_urls: true,
trailing_slash: Never,
error_documents: { 404: "/404.html" },
redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
rewrites: [ (from: "/app/**", to: "/index.html") ],
headers: [ (matches: "**.js", set: { "Cache-Control": "public, max-age=31536000, immutable" }) ],
cache: ( default: "public, max-age=0, must-revalidate" ),
mime_overrides: { ".webmanifest": "application/manifest+json" },
)"#;
let config = DeployConfig::from_ron(text).unwrap();
assert!(config.clean_urls);
assert_eq!(config.trailing_slash, TrailingSlash::Never);
assert_eq!(config.redirects[0].status, 301);
assert_eq!(config.rewrites[0].status, 200); assert_eq!(
config.error_documents.get(&404).map(String::as_str),
Some("/404.html")
);
}
#[test]
fn rejects_bad_pattern_at_parse() {
let text = r#"( redirects: [ (from: "/a/**/b/**", to: "/x") ] )"#;
assert!(DeployConfig::from_ron(text).is_err());
}
#[test]
fn rejects_unknown_field() {
assert!(DeployConfig::from_ron("( nope: true )").is_err());
}
#[test]
fn proxy_allow_list_matching() {
assert!(DeployConfig::default().proxy_host_allowed("anything.example"));
let cfg = DeployConfig {
proxy_allow: vec!["api.example.com".into(), ".internal.test".into()],
..DeployConfig::default()
};
assert!(cfg.proxy_host_allowed("api.example.com")); assert!(cfg.proxy_host_allowed("API.EXAMPLE.COM")); assert!(cfg.proxy_host_allowed("a.internal.test")); assert!(cfg.proxy_host_allowed("internal.test")); assert!(!cfg.proxy_host_allowed("evil.com"));
assert!(!cfg.proxy_host_allowed("notapi.example.com"));
}
#[test]
fn parses_handler_config() {
let text = r#"(
handlers: [
( route: "/api/orders/*", methods: ["GET", "POST"],
component: "handlers/orders.wasm",
imports: ["sql", "wasi:keyvalue", "wasi:messaging"],
limits: ( memory_mb: 64, timeout_ms: 10000 ),
env: { "LOG_LEVEL": "info" } ),
],
consumers: [
( topic: "orders/created", component: "handlers/agg.wasm",
imports: ["sql"] ),
],
crons: [ ( schedule: "0 */6 * * *", route: "/api/orders/reindex", overlap: Skip ) ],
streams: [ ( route: "/events/orders", topics: ["orders/created"] ) ],
)"#;
let config = DeployConfig::from_ron(text).unwrap();
assert_eq!(config.handlers.len(), 1);
assert_eq!(config.handlers[0].imports.len(), 3);
assert_eq!(
config.handlers[0].limits.as_ref().unwrap().memory_mb,
Some(64)
);
assert_eq!(config.consumers.len(), 1);
assert_eq!(config.crons[0].overlap, Overlap::Skip);
assert_eq!(config.streams[0].topics, vec!["orders/created".to_string()]);
}
#[test]
fn handler_validation_rejects_bad_config() {
assert!(DeployConfig::from_ron(
r#"( handlers: [ ( route: "/a", component: "a.wasm", imports: ["wasi:gpu"] ) ] )"#
)
.is_err());
assert!(DeployConfig::from_ron(
r#"( handlers: [ ( route: "/a", component: "a.wasm", methods: ["FETCH"] ) ] )"#
)
.is_err());
assert!(DeployConfig::from_ron(
r#"( handlers: [ ( route: "/a", component: "a.wasm" ) ],
crons: [ ( schedule: "* * * * *", route: "/nope" ) ] )"#
)
.is_err());
assert!(DeployConfig::from_ron(
r#"( handlers: [ ( route: "/tasks/*", component: "a.wasm" ) ],
crons: [ ( schedule: "0 0 * * *", route: "/tasks/x" ) ] )"#
)
.is_ok());
}
#[test]
fn cron_schedule_validation() {
for ok in [
"* * * * *",
"0 */6 * * *",
"30 2 1 1 0",
"0,15,30,45 9-17 * * 1-5",
] {
assert!(check_cron_schedule(ok).is_ok(), "{ok} should be valid");
}
for bad in [
"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "*/0 * * * *", "5-1 * * * *", ] {
assert!(check_cron_schedule(bad).is_err(), "{bad} should be invalid");
}
}
#[test]
fn handler_free_config_omits_handler_fields() {
let json = serde_json::to_string(&DeployConfig::default()).unwrap();
assert!(!json.contains("handlers"));
assert!(!json.contains("crons"));
}
#[test]
fn schema_version_defaults_to_one() {
assert_eq!(DeployConfig::from_ron("()").unwrap().version, 1);
assert_eq!(DeployConfig::from_ron("(version: 1)").unwrap().version, 1);
assert_eq!(SiteConfig::default().version, 1);
assert_eq!(SiteConfig::from_json(b"{}").unwrap().version, 1);
}
}