use serde::Serialize;
const SIGNING_SECRET_MIN_LEN: usize = 32;
const SIGNING_SECRET_DEMO_VALUES: &[&str] = &[
"changeme",
"change_me",
"change-me",
"secret",
"supersecret",
"super-secret",
"super_secret",
"your-secret-here",
"your_secret_here",
"insert-secret-here",
"replace-this",
"replace_me",
"todo",
"fixme",
"example",
"placeholder",
"dev_only",
"dev-only",
"test_secret",
"test-secret",
"test",
"password",
];
const KNOWN_TOML_SECTIONS: &[&str] = &[
"server",
"database",
"log",
"telemetry",
"health",
"actuator",
"cors",
"session",
"jobs",
"auth",
"security",
"i18n",
"storage",
"mail",
"profile",
"compression",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckResult {
pub name: &'static str,
pub status: CheckStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<&'static str>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Summary {
pub passed: usize,
pub warned: usize,
pub failed: usize,
}
#[derive(Clone, Copy)]
pub struct DoctorOptions {
pub json: bool,
pub strict: bool,
}
#[allow(dead_code)]
pub trait Check {
fn run(&self) -> CheckResult;
}
pub fn check_signing_secret_impl(secret: Option<&str>, is_production: bool) -> CheckResult {
match secret {
None if is_production => CheckResult {
name: "signing_secret",
status: CheckStatus::Fail,
detail: Some("no signing secret configured in production".into()),
hint: Some(
"Set AUTUMN_SECURITY__SIGNING_SECRET (generate with `openssl rand -hex 32`)",
),
},
None => CheckResult {
name: "signing_secret",
status: CheckStatus::Warn,
detail: Some(
"using an ephemeral per-process signing secret (dev/test only; \
sessions and signed URLs will not survive restarts or be shared across replicas)"
.into(),
),
hint: Some("Set AUTUMN_SECURITY__SIGNING_SECRET before deploying to production"),
},
Some(s) if is_production => {
let lower = s.to_ascii_lowercase();
if SIGNING_SECRET_DEMO_VALUES.iter().any(|&d| lower == d) {
return CheckResult {
name: "signing_secret",
status: CheckStatus::Fail,
detail: Some("signing secret matches a known demo/template value".into()),
hint: Some("Generate a new secret: `openssl rand -hex 32`"),
};
}
let byte_len = s.len();
if byte_len < SIGNING_SECRET_MIN_LEN {
return CheckResult {
name: "signing_secret",
status: CheckStatus::Fail,
detail: Some(format!(
"signing secret too short: {byte_len} bytes \
(minimum {SIGNING_SECRET_MIN_LEN})"
)),
hint: Some("Generate a longer secret: `openssl rand -hex 32`"),
};
}
CheckResult {
name: "signing_secret",
status: CheckStatus::Pass,
detail: Some("signing secret is present and meets entropy requirements".into()),
hint: None,
}
}
Some(_) => CheckResult {
name: "signing_secret",
status: CheckStatus::Pass,
detail: Some("signing secret is configured".into()),
hint: None,
},
}
}
pub fn check_rate_limit_key_strategy_impl(
key_strategy: &str,
auth_extractor_mounted: bool,
) -> CheckResult {
if !key_strategy.is_empty()
&& key_strategy != "ip"
&& key_strategy != "api_token"
&& key_strategy != "authenticated_principal"
{
return CheckResult {
name: "rate_limit_key_strategy",
status: CheckStatus::Fail,
detail: Some(format!(
"rate_limit.key_strategy = {key_strategy:?} is not a valid strategy"
)),
hint: Some("Expected \"ip\", \"api_token\", or \"authenticated_principal\""),
};
}
if key_strategy == "authenticated_principal" && !auth_extractor_mounted {
return CheckResult {
name: "rate_limit_key_strategy",
status: CheckStatus::Warn,
detail: Some(
"rate_limit.key_strategy = \"authenticated_principal\" is configured \
but no auth extractor is mounted — unauthenticated requests will \
always fall back to IP keying, so the per-principal budget \
is never applied to authenticated callers"
.into(),
),
hint: Some(
"Mount an auth layer (e.g. `[auth]` in autumn.toml) or change \
key_strategy to \"ip\" or \"api_token\"",
),
};
}
CheckResult {
name: "rate_limit_key_strategy",
status: CheckStatus::Pass,
detail: Some(format!(
"rate_limit.key_strategy = {:?} is compatible with the current auth configuration",
if key_strategy.is_empty() {
"ip"
} else {
key_strategy
}
)),
hint: None,
}
}
#[derive(Default)]
pub struct ProxyConflictData {
pub new_ranges: Vec<String>,
pub new_trust_fwd: bool,
pub new_hops: Option<u32>,
pub old_ranges: Vec<String>,
pub old_trust_fwd: bool,
}
pub fn check_proxy_conflict_impl(data: &ProxyConflictData) -> CheckResult {
let new_set = data.new_trust_fwd || !data.new_ranges.is_empty();
let old_set = data.old_trust_fwd || !data.old_ranges.is_empty();
if new_set && old_set {
let new_set: std::collections::HashSet<&str> =
data.new_ranges.iter().map(String::as_str).collect();
let old_set: std::collections::HashSet<&str> =
data.old_ranges.iter().map(String::as_str).collect();
let conflicting = new_set != old_set
|| data.new_trust_fwd != data.old_trust_fwd
|| data.new_hops.is_some();
if conflicting {
return CheckResult {
name: "proxy_config_conflict",
status: CheckStatus::Warn,
detail: Some(
"[security.trusted_proxies] and [security.rate_limit] trusted proxy \
fields are both set with conflicting values."
.into(),
),
hint: Some(
"Remove the deprecated rate_limit.trusted_proxies / \
rate_limit.trust_forwarded_headers fields and keep only \
[security.trusted_proxies]",
),
};
}
}
CheckResult {
name: "proxy_config_conflict",
status: CheckStatus::Pass,
detail: None,
hint: None,
}
}
pub fn check_trusted_hosts_impl(hosts: &[String], is_production: bool) -> CheckResult {
let normalized: Vec<String> = hosts
.iter()
.map(|h| h.trim().to_owned())
.filter(|h| !h.is_empty())
.collect();
if normalized.is_empty() {
return if is_production {
CheckResult {
name: "trusted_hosts",
status: CheckStatus::Fail,
detail: Some("trusted hosts list is empty in production".into()),
hint: Some(
"Set [security.trusted_hosts] hosts = [\"example.com\"] or \
AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
),
}
} else {
CheckResult {
name: "trusted_hosts",
status: CheckStatus::Warn,
detail: Some("trusted hosts list is empty".into()),
hint: Some(
"Set [security.trusted_hosts] hosts to prevent Host-header rebinding attacks",
),
}
};
}
if normalized.iter().any(|h| h == "*") {
return CheckResult {
name: "trusted_hosts",
status: CheckStatus::Warn,
detail: Some("trusted hosts wildcard '*' disables host-header allow-listing".into()),
hint: Some("Prefer explicit hosts in production to fail closed"),
};
}
CheckResult {
name: "trusted_hosts",
status: CheckStatus::Pass,
detail: Some(format!(
"trusted hosts configured ({} entries)",
normalized.len()
)),
hint: None,
}
}
pub fn check_oauth2_provider_impl(
provider_name: &str,
client_id: &str,
client_secret: &str,
is_production: bool,
) -> CheckResult {
let detail_prefix = format!("[auth.oauth2.{provider_name}]");
if client_id.trim().is_empty() {
return if is_production {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Fail,
detail: Some(format!(
"{detail_prefix}: client_id is empty — OAuth2 login will fail in production"
)),
hint: Some(
"Set client_id via AUTUMN_AUTH__OAUTH2__<PROVIDER>__CLIENT_ID \
or in autumn.toml",
),
}
} else {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Warn,
detail: Some(format!(
"{detail_prefix}: client_id is empty (OK for dev if using env vars)"
)),
hint: Some("Set client_id before deploying to production"),
}
};
}
if client_secret.trim().is_empty() {
return if is_production {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Fail,
detail: Some(format!(
"{detail_prefix}: client_secret is empty — OAuth2 login will fail in production"
)),
hint: Some(
"Set client_secret via AUTUMN_AUTH__OAUTH2__<PROVIDER>__CLIENT_SECRET \
or autumn credentials edit",
),
}
} else {
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Warn,
detail: Some(format!(
"{detail_prefix}: client_secret is empty (OK for dev if using env vars)"
)),
hint: Some("Set client_secret before deploying to production"),
}
};
}
CheckResult {
name: "oauth2_provider",
status: CheckStatus::Pass,
detail: Some(format!("{detail_prefix}: provider is correctly configured")),
hint: None,
}
}
pub const fn glyph(status: &CheckStatus) -> &'static str {
match status {
CheckStatus::Pass => "✅",
CheckStatus::Warn => "⚠️ ",
CheckStatus::Fail => "❌",
}
}
pub fn compute_summary(results: &[CheckResult]) -> Summary {
let mut passed = 0;
let mut warned = 0;
let mut failed = 0;
for r in results {
match r.status {
CheckStatus::Pass => passed += 1,
CheckStatus::Warn => warned += 1,
CheckStatus::Fail => failed += 1,
}
}
Summary {
passed,
warned,
failed,
}
}
pub const fn exit_code(summary: &Summary, strict: bool) -> i32 {
if summary.failed > 0 || (strict && summary.warned > 0) {
1
} else {
0
}
}
pub fn format_check_line(result: &CheckResult) -> String {
use std::fmt::Write as _;
let g = glyph(&result.status);
let mut line = format!("{g} {}", result.name);
if let Some(ref detail) = result.detail {
let _ = write!(line, " — {detail}");
}
if let Some(hint) = result.hint
&& result.status != CheckStatus::Pass
{
let _ = write!(line, "\n hint: {hint}");
}
line
}
pub fn format_summary_line(summary: &Summary, code: i32) -> String {
let verdict = if code == 0 {
"all clear"
} else {
"problems found"
};
let w_label = if summary.warned == 1 {
"warning"
} else {
"warnings"
};
format!(
"{} passed, {} {}, {} failed — {verdict}",
summary.passed, summary.warned, w_label, summary.failed
)
}
pub fn to_json_output(results: &[CheckResult], summary: &Summary) -> String {
#[derive(Serialize)]
struct Output<'a> {
checks: &'a [CheckResult],
summary: &'a Summary,
}
serde_json::to_string_pretty(&Output {
checks: results,
summary,
})
.unwrap_or_else(|_| "{}".to_string())
}
pub fn check_toml_content(content: &str) -> CheckResult {
match toml::from_str::<toml::Table>(content) {
Err(e) => CheckResult {
name: "autumn_toml",
status: CheckStatus::Fail,
detail: Some(e.to_string()),
hint: Some("Fix the syntax error in autumn.toml"),
},
Ok(table) => {
let unknown: Vec<String> = table
.keys()
.filter(|k| !KNOWN_TOML_SECTIONS.contains(&k.as_str()))
.cloned()
.collect();
if unknown.is_empty() {
CheckResult {
name: "autumn_toml",
status: CheckStatus::Pass,
detail: Some("autumn.toml is valid".into()),
hint: None,
}
} else {
CheckResult {
name: "autumn_toml",
status: CheckStatus::Warn,
detail: Some(format!("unknown keys: {}", unknown.join(", "))),
hint: Some("Remove or rename unrecognised keys in autumn.toml"),
}
}
}
}
}
pub fn check_version_compat(cli_version: &str, web_version: &str) -> CheckResult {
let parse = |v: &str| -> Option<(u64, u64, u64)> {
let v = v.trim().trim_start_matches(['=', '^', '~', ' ']);
let parts: Vec<&str> = v.split('.').collect();
if parts.len() < 2 {
return None;
}
let major: u64 = parts[0].parse().ok()?;
let minor: u64 = parts[1].parse().ok()?;
let patch: u64 = if parts.len() >= 3 {
parts[2]
.split(|c: char| !c.is_ascii_digit())
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0)
} else {
0
};
Some((major, minor, patch))
};
let Some(cli) = parse(cli_version) else {
return CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse CLI version: {cli_version}")),
hint: Some("Reinstall autumn-cli"),
};
};
let Some(web) = parse(web_version) else {
return CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse autumn-web version: {web_version}")),
hint: Some("Check autumn-web version in Cargo.toml"),
};
};
if cli.0 != web.0 || cli.1 != web.1 {
CheckResult {
name: "version_compat",
status: CheckStatus::Fail,
detail: Some(format!(
"autumn-cli {cli_version} is incompatible with autumn-web {web_version}"
)),
hint: Some(
"Run `cargo install --path autumn-cli` to match your project's autumn-web version",
),
}
} else if cli.2 != web.2 {
CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some(format!(
"autumn-cli {cli_version} vs autumn-web {web_version} (patch skew)"
)),
hint: Some("Consider updating either the CLI or your project's autumn-web dependency"),
}
} else {
CheckResult {
name: "version_compat",
status: CheckStatus::Pass,
detail: Some(format!(
"autumn-cli {cli_version} matches autumn-web {web_version}"
)),
hint: None,
}
}
}
pub fn check_port_bindable_impl(port: u16, try_bind: impl Fn(u16) -> bool) -> CheckResult {
if try_bind(port) {
CheckResult {
name: "port_bindable",
status: CheckStatus::Pass,
detail: Some(format!("port {port} is available")),
hint: None,
}
} else {
CheckResult {
name: "port_bindable",
status: CheckStatus::Fail,
detail: Some(format!("port {port} is already in use")),
hint: Some("Kill the process using that port, or change server.port in autumn.toml"),
}
}
}
pub fn check_rust_toolchain_impl(current_output: &str, required: &str) -> CheckResult {
let parse_ver = |s: &str| -> Option<(u64, u64, u64)> {
let s = s.trim();
let s = s
.strip_prefix("rustc ")
.map_or(s, |rest| rest.split_whitespace().next().unwrap_or(rest));
let parts: Vec<&str> = s.split('.').collect();
if parts.len() < 3 {
return None;
}
let major: u64 = parts[0].parse().ok()?;
let minor: u64 = parts[1].parse().ok()?;
let patch: u64 = parts[2]
.split(|c: char| !c.is_ascii_digit())
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0);
Some((major, minor, patch))
};
let Some(cur) = parse_ver(current_output) else {
return CheckResult {
name: "rust_toolchain",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse rustc version: {current_output}")),
hint: Some("Run `rustup update` to ensure a known Rust version"),
};
};
let Some(req) = parse_ver(required) else {
return CheckResult {
name: "rust_toolchain",
status: CheckStatus::Warn,
detail: Some(format!("cannot parse MSRV: {required}")),
hint: None,
};
};
if cur >= req {
CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: Some(format!(
"rustc {}.{}.{} ≥ MSRV {}.{}.{}",
cur.0, cur.1, cur.2, req.0, req.1, req.2
)),
hint: None,
}
} else {
CheckResult {
name: "rust_toolchain",
status: CheckStatus::Fail,
detail: Some(format!(
"rustc {}.{}.{} < MSRV {}.{}.{}",
cur.0, cur.1, cur.2, req.0, req.1, req.2
)),
hint: Some("Run `rustup update stable` to upgrade your Rust toolchain"),
}
}
}
fn check_rust_toolchain(msrv: &str) -> CheckResult {
match std::process::Command::new("rustc")
.arg("--version")
.output()
{
Ok(out) if out.status.success() => {
let ver = String::from_utf8_lossy(&out.stdout).into_owned();
check_rust_toolchain_impl(ver.trim(), msrv)
}
_ => CheckResult {
name: "rust_toolchain",
status: CheckStatus::Fail,
detail: Some("`rustc --version` failed".into()),
hint: Some("Install Rust via https://rustup.rs/"),
},
}
}
fn check_port_bindable(port: u16) -> CheckResult {
check_port_bindable_impl(port, |p| {
std::net::TcpListener::bind(("127.0.0.1", p)).is_ok()
})
}
pub fn check_tailwind_binary_at(path: &std::path::Path) -> CheckResult {
check_tailwind_binary_at_with_executable_probe(path, tailwind_file_is_executable)
}
fn check_tailwind_binary_at_with_executable_probe(
path: &std::path::Path,
executable_probe: impl Fn(&std::path::Path, &std::fs::Metadata) -> bool,
) -> CheckResult {
let symlink_metadata = match path.symlink_metadata() {
Ok(metadata) => metadata,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} not found", path.display())),
hint: Some("Run `autumn setup` to download the Tailwind CSS binary"),
};
}
Err(e) => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("cannot inspect {}: {e}", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
};
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(_) if symlink_metadata.file_type().is_symlink() => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} is a broken symlink", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
Err(e) => {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("cannot inspect {}: {e}", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
};
if !metadata.is_file() {
let kind = if metadata.is_dir() {
"directory"
} else {
"non-file filesystem entry"
};
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} is a {kind}, not a file", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
if !executable_probe(path, &metadata) {
return CheckResult {
name: "tailwind_binary",
status: CheckStatus::Fail,
detail: Some(format!("{} exists but is not executable", path.display())),
hint: Some("Run `autumn setup --force` to re-download the Tailwind CSS binary"),
};
}
CheckResult {
name: "tailwind_binary",
status: CheckStatus::Pass,
detail: Some(format!("{} is present and executable", path.display())),
hint: None,
}
}
#[cfg(unix)]
fn tailwind_file_is_executable(path: &std::path::Path, _metadata: &std::fs::Metadata) -> bool {
nix::unistd::access(path, nix::unistd::AccessFlags::X_OK).is_ok()
}
#[cfg(windows)]
fn tailwind_file_is_executable(path: &std::path::Path, _metadata: &std::fs::Metadata) -> bool {
use std::io::{Read as _, Seek as _};
if !path
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
{
return false;
}
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut dos_header = [0_u8; 64];
if file.read_exact(&mut dos_header).is_err() || &dos_header[0..2] != b"MZ" {
return false;
}
let pe_offset = u32::from_le_bytes([
dos_header[0x3c],
dos_header[0x3d],
dos_header[0x3e],
dos_header[0x3f],
]);
if file
.seek(std::io::SeekFrom::Start(u64::from(pe_offset)))
.is_err()
{
return false;
}
let mut pe_signature = [0_u8; 4];
file.read_exact(&mut pe_signature).is_ok() && pe_signature == *b"PE\0\0"
}
#[cfg(not(any(unix, windows)))]
fn tailwind_file_is_executable(_path: &std::path::Path, _metadata: &std::fs::Metadata) -> bool {
true
}
fn check_tailwind_binary() -> CheckResult {
let path = if cfg!(windows) {
std::path::PathBuf::from("target/autumn/tailwindcss.exe")
} else {
std::path::PathBuf::from("target/autumn/tailwindcss")
};
check_tailwind_binary_at(&path)
}
fn check_stale_artifacts() -> CheckResult {
let cargo_lock = std::path::Path::new("Cargo.lock");
let dist = std::path::Path::new("dist");
let target = std::path::Path::new("target");
let lock_mtime = cargo_lock.metadata().and_then(|m| m.modified()).ok();
let dir_older_than_lock = |dir: &std::path::Path| -> bool {
let Some(lock_t) = lock_mtime else {
return false;
};
dir.metadata()
.and_then(|m| m.modified())
.is_ok_and(|dir_t| dir_t < lock_t)
};
let dist_stale = dist.exists() && dir_older_than_lock(dist);
let target_stale = target.exists() && dir_older_than_lock(target);
if dist_stale || target_stale {
let which: Vec<&str> = [
dist_stale.then_some("dist/"),
target_stale.then_some("target/"),
]
.into_iter()
.flatten()
.collect();
CheckResult {
name: "stale_artifacts",
status: CheckStatus::Warn,
detail: Some(format!(
"{} may be stale relative to Cargo.lock",
which.join(", ")
)),
hint: Some("Run `cargo build` or `autumn build` to refresh artifacts"),
}
} else {
CheckResult {
name: "stale_artifacts",
status: CheckStatus::Pass,
detail: Some("artifacts look fresh".into()),
hint: None,
}
}
}
fn read_msrv() -> Option<String> {
let content = std::fs::read_to_string("Cargo.toml").ok()?;
let table: toml::Table = toml::from_str(&content).ok()?;
if let Some(ver) = table
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("rust-version"))
.and_then(|v| v.as_str())
{
return Some(ver.to_owned());
}
table
.get("package")
.and_then(|p| p.get("rust-version"))
.and_then(|v| v.as_str())
.map(std::borrow::ToOwned::to_owned)
}
fn read_autumn_web_version() -> Option<String> {
let content = std::fs::read_to_string("Cargo.toml").ok()?;
let table: toml::Table = toml::from_str(&content).ok()?;
let find_in_deps = |deps: &toml::Value| -> Option<String> {
let entry = deps.get("autumn-web")?;
match entry {
toml::Value::String(v) => Some(v.clone()),
toml::Value::Table(t) => t
.get("version")?
.as_str()
.map(std::borrow::ToOwned::to_owned),
_ => None,
}
};
table
.get("dependencies")
.and_then(find_in_deps)
.or_else(|| {
table
.get("workspace")
.and_then(|w| w.get("dependencies"))
.and_then(find_in_deps)
})
}
fn tcp_reachable(host: &str, port: u16) -> bool {
use std::net::ToSocketAddrs;
use std::time::Duration;
let addrs: Vec<_> = match format!("{host}:{port}").to_socket_addrs() {
Ok(a) => a.collect(),
Err(_) => return false,
};
addrs
.iter()
.any(|addr| std::net::TcpStream::connect_timeout(addr, Duration::from_secs(1)).is_ok())
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DoctorReplicaFallback {
#[default]
FailReadiness,
Primary,
}
impl DoctorReplicaFallback {
fn from_config_value(value: Option<&str>) -> Self {
match value.unwrap_or("fail_readiness") {
"primary" | "fallback_to_primary" | "fallback-to-primary" => Self::Primary,
_ => Self::FailReadiness,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DoctorDatabaseTopology {
pub primary_url: Option<String>,
pub replica_url: Option<String>,
pub auto_migrate_in_production: bool,
pub replica_fallback: DoctorReplicaFallback,
}
fn check_database_topology_contract(
topology: &DoctorDatabaseTopology,
is_production: bool,
) -> CheckResult {
if topology.replica_url.is_some() && topology.primary_url.is_none() {
return CheckResult {
name: "database_topology",
status: CheckStatus::Fail,
detail: Some("replica role configured without a primary/write role".into()),
hint: Some("Set database.primary_url or remove database.replica_url"),
};
}
if is_production && topology.replica_url.is_some() && topology.auto_migrate_in_production {
return CheckResult {
name: "database_topology",
status: CheckStatus::Fail,
detail: Some(
"unsafe migration ownership: production primary/replica topology cannot run migrations from every web replica"
.into(),
),
hint: Some("Run `autumn migrate` as one primary-role job before starting web replicas"),
};
}
if topology.primary_url.is_some() && topology.replica_url.is_some() {
CheckResult {
name: "database_topology",
status: CheckStatus::Pass,
detail: Some("primary and replica database roles configured".into()),
hint: None,
}
} else if topology.primary_url.is_some() {
CheckResult {
name: "database_topology",
status: CheckStatus::Pass,
detail: Some("single primary database role configured".into()),
hint: None,
}
} else {
CheckResult {
name: "database_topology",
status: CheckStatus::Pass,
detail: Some("database not configured".into()),
hint: None,
}
}
}
fn check_replica_migration_versions(
primary_latest: Option<&str>,
replica_latest: Option<&str>,
fallback: DoctorReplicaFallback,
) -> CheckResult {
match (primary_latest, replica_latest) {
(Some(primary), Some(replica)) if primary == replica => CheckResult {
name: "replica_migrations",
status: CheckStatus::Pass,
detail: Some(format!("replica replayed latest migration {primary}")),
hint: None,
},
(Some(primary), Some(replica)) => {
let detail = format!(
"replica migration version {replica} is behind primary migration version {primary}"
);
match fallback {
DoctorReplicaFallback::FailReadiness => CheckResult {
name: "replica_migrations",
status: CheckStatus::Fail,
detail: Some(detail),
hint: Some(
"Wait for the replica to replay the latest migration before admitting traffic",
),
},
DoctorReplicaFallback::Primary => CheckResult {
name: "replica_migrations",
status: CheckStatus::Warn,
detail: Some(format!("{detail}; reads may fall back to primary")),
hint: Some(
"Restore replica replay or set replica_fallback = \"fail_readiness\" for stricter rollout gates",
),
},
}
}
_ => CheckResult {
name: "replica_migrations",
status: CheckStatus::Warn,
detail: Some("could not determine primary and replica migration versions".into()),
hint: Some("Ensure both roles expose __diesel_schema_migrations"),
},
}
}
fn check_db_role_connectivity(
role: &'static str,
database_url: &str,
reachable: impl Fn(&str, u16) -> bool,
) -> CheckResult {
match parse_db_host_port(database_url) {
None => CheckResult {
name: "db_connectivity",
status: CheckStatus::Warn,
detail: Some(format!(
"cannot parse {role} database URL to extract host:port"
)),
hint: Some("Ensure database URLs in autumn.toml use postgres:// or postgresql://"),
},
Some((host, port)) if reachable(&host, port) => CheckResult {
name: "db_connectivity",
status: CheckStatus::Pass,
detail: Some(format!("{role} database reachable at {host}:{port}")),
hint: None,
},
Some((host, port)) => CheckResult {
name: "db_connectivity",
status: CheckStatus::Fail,
detail: Some(format!(
"cannot connect to {role} database at {host}:{port}"
)),
hint: Some("Start Postgres and verify the configured database role URL"),
},
}
}
fn check_db_connectivity(database_url: &str) -> CheckResult {
check_db_role_connectivity("primary", database_url, tcp_reachable)
}
fn check_pending_migrations(database_url: &str) -> CheckResult {
match std::process::Command::new("diesel")
.args(["migration", "pending"])
.env("DATABASE_URL", database_url)
.output()
{
Err(_) => CheckResult {
name: "pending_migrations",
status: CheckStatus::Warn,
detail: Some("diesel CLI not found; cannot check pending migrations".into()),
hint: Some(
"Install diesel_cli: `cargo install diesel_cli --no-default-features --features postgres`",
),
},
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let pending = stdout.lines().filter(|l| !l.trim().is_empty()).count();
if pending == 0 {
CheckResult {
name: "pending_migrations",
status: CheckStatus::Pass,
detail: Some("no pending migrations".into()),
hint: None,
}
} else {
CheckResult {
name: "pending_migrations",
status: CheckStatus::Warn,
detail: Some(format!("{pending} pending migration(s)")),
hint: Some("Run `autumn migrate` to apply pending migrations"),
}
}
}
Ok(_) => CheckResult {
name: "pending_migrations",
status: CheckStatus::Warn,
detail: Some("diesel migration pending returned non-zero".into()),
hint: Some("Run `autumn migrate` to apply pending migrations"),
},
}
}
fn check_replica_migrations(
primary_url: &str,
replica_url: &str,
fallback: DoctorReplicaFallback,
) -> CheckResult {
let primary_latest = latest_applied_migration_version(primary_url);
let replica_latest = latest_applied_migration_version(replica_url);
check_replica_migration_versions(
primary_latest.as_deref(),
replica_latest.as_deref(),
fallback,
)
}
fn latest_applied_migration_version(database_url: &str) -> Option<String> {
let output = std::process::Command::new("diesel")
.args(["migration", "list"])
.env("DATABASE_URL", database_url)
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_latest_applied_migration_version(&String::from_utf8_lossy(&output.stdout))
}
fn parse_latest_applied_migration_version(output: &str) -> Option<String> {
output
.lines()
.filter_map(|line| {
let trimmed = line.trim();
let version = trimmed
.strip_prefix("[X]")
.or_else(|| trimmed.strip_prefix("[x]"))?
.split_whitespace()
.next()?;
Some(version.to_owned())
})
.max()
}
pub fn parse_db_host_port(url: &str) -> Option<(String, u16)> {
let without_scheme = url
.strip_prefix("postgres://")
.or_else(|| url.strip_prefix("postgresql://"))?;
let authority = without_scheme.split('/').next()?;
let host_port = authority
.rfind('@')
.map_or(authority, |at| &authority[at + 1..]);
if let Some(rest) = host_port.strip_prefix('[') {
let close = rest.find(']')?;
let host = &rest[..close];
let after_bracket = &rest[close + 1..];
let port = if let Some(port) = after_bracket.strip_prefix(':') {
port.parse().ok()?
} else if after_bracket.is_empty() {
5432
} else {
return None;
};
return Some((host.to_owned(), port));
}
match host_port.matches(':').count() {
1 => {
let (host, port) = host_port.split_once(':')?;
Some((host.to_owned(), port.parse().ok()?))
}
_ => Some((host_port.to_owned(), 5432)),
}
}
fn resolve_server_port() -> u16 {
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| {
t.get("server")
.and_then(|s| s.get("port"))
.and_then(toml::Value::as_integer)
.and_then(|p| u16::try_from(p).ok())
})
.unwrap_or(3000)
}
fn read_autumn_toml_table() -> Option<toml::Table> {
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|contents| toml::from_str::<toml::Table>(&contents).ok())
}
fn deep_merge(target: &mut toml::Table, source: &toml::Table) {
for (k, v) in source {
if let (Some(source_tbl), Some(target_tbl)) = (
v.as_table(),
target.get_mut(k).and_then(|t| t.as_table_mut()),
) {
deep_merge(target_tbl, source_tbl);
continue;
}
target.insert(k.clone(), v.clone());
}
}
fn get_merged_toml_table(profile: &str) -> toml::Table {
get_merged_toml_table_profiles(&[profile])
}
fn get_merged_toml_table_profiles(profiles: &[&str]) -> toml::Table {
let mut merged = toml::Table::new();
let base_toml = std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok());
if let Some(ref table) = base_toml {
deep_merge(&mut merged, table);
}
for &profile in profiles {
if let Some(prof) = base_toml
.as_ref()
.and_then(|t| t.get("profile"))
.and_then(toml::Value::as_table)
.and_then(|p| p.get(profile))
.and_then(toml::Value::as_table)
{
deep_merge(&mut merged, prof);
}
if let Some(table) = std::fs::read_to_string(format!("autumn-{profile}.toml"))
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
{
deep_merge(&mut merged, &table);
}
}
merged
}
pub struct DoctorOAuth2Provider {
pub name: String,
pub client_id: String,
pub client_secret: String,
}
fn resolve_oauth2_providers() -> Vec<DoctorOAuth2Provider> {
let raw_profile = std::env::var("AUTUMN_ENV")
.or_else(|_| std::env::var("AUTUMN_PROFILE"))
.unwrap_or_else(|_| "dev".to_owned());
let profile = match raw_profile.trim().to_lowercase().as_str() {
"production" => "prod".to_owned(),
"development" => "dev".to_owned(),
other => other.to_owned(),
};
let merged_toml = get_merged_toml_table(&profile);
resolve_oauth2_providers_from_sources(
|key| std::env::var(key).ok().filter(|v| !v.is_empty()),
Some(&merged_toml),
&profile,
)
}
fn resolve_oauth2_providers_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
profile: &str,
) -> Vec<DoctorOAuth2Provider>
where
F: Fn(&str) -> Option<String>,
{
let credentials =
autumn_web::credentials::load_credentials(profile, std::path::Path::new(".")).ok();
table
.and_then(|t| t.get("auth"))
.and_then(toml::Value::as_table)
.and_then(|auth| auth.get("oauth2"))
.and_then(toml::Value::as_table)
.map(|providers| {
providers
.iter()
.map(|(name, val)| {
let mut client_id = val
.as_table()
.and_then(|t| t.get("client_id"))
.and_then(toml::Value::as_str)
.unwrap_or("")
.to_owned();
let mut client_secret = val
.as_table()
.and_then(|t| t.get("client_secret"))
.and_then(toml::Value::as_str)
.unwrap_or("")
.to_owned();
let normalized_name = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.to_lowercase();
let upper = normalized_name.to_uppercase();
let env_id_key = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
if let Some(id) = env_var(&env_id_key) {
client_id = id;
}
let env_secret_key = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
if let Some(sec) = env_var(&env_secret_key) {
client_secret = sec;
}
if let Some(ref creds) = credentials {
let id_key = format!("oauth2_{normalized_name}_client_id");
if client_id.is_empty() {
if let Some(id) = creds.get::<String>(&id_key) {
client_id = id;
} else if let Some(id) =
creds.get::<String>(&format!("oauth2_{name}_client_id"))
{
client_id = id;
}
}
let secret_key = format!("oauth2_{normalized_name}_client_secret");
if client_secret.is_empty() {
if let Some(sec) = creds.get::<String>(&secret_key) {
client_secret = sec;
} else if let Some(sec) =
creds.get::<String>(&format!("oauth2_{name}_client_secret"))
{
client_secret = sec;
}
}
}
DoctorOAuth2Provider {
name: name.clone(),
client_id,
client_secret,
}
})
.collect()
})
.unwrap_or_default()
}
fn resolve_database_topology(table: Option<&toml::Table>) -> DoctorDatabaseTopology {
resolve_database_topology_from_sources(
|key| std::env::var(key).ok().filter(|value| !value.is_empty()),
table,
)
}
fn resolve_database_topology_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> DoctorDatabaseTopology
where
F: Fn(&str) -> Option<String>,
{
let database = table
.and_then(|t| t.get("database"))
.and_then(toml::Value::as_table);
let primary_url = first_env(
&env_var,
&[
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_DATABASE__URL",
"DATABASE_URL",
],
)
.or_else(|| first_toml_string(database, &["primary_url", "url"]));
let replica_url = first_env(&env_var, &["AUTUMN_DATABASE__REPLICA_URL"])
.or_else(|| first_toml_string(database, &["replica_url"]));
let auto_migrate_in_production =
first_env(&env_var, &["AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION"])
.as_deref()
.and_then(parse_config_bool)
.or_else(|| database?.get("auto_migrate_in_production")?.as_bool())
.unwrap_or(false);
let replica_fallback = DoctorReplicaFallback::from_config_value(
first_env(&env_var, &["AUTUMN_DATABASE__REPLICA_FALLBACK"])
.or_else(|| first_toml_string(database, &["replica_fallback"]))
.as_deref(),
);
DoctorDatabaseTopology {
primary_url,
replica_url,
auto_migrate_in_production,
replica_fallback,
}
}
fn first_env<F>(env_var: &F, keys: &[&str]) -> Option<String>
where
F: Fn(&str) -> Option<String>,
{
keys.iter().find_map(|key| {
env_var(key)
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
})
}
fn first_toml_string(database: Option<&toml::Table>, keys: &[&str]) -> Option<String> {
let database = database?;
keys.iter().find_map(|key| {
database
.get(*key)
.and_then(toml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
})
}
fn parse_config_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn resolve_proxy_conflict_data() -> ProxyConflictData {
let raw_profile = std::env::var("AUTUMN_ENV")
.or_else(|_| std::env::var("AUTUMN_PROFILE"))
.unwrap_or_default();
let raw_lower = raw_profile.trim().to_ascii_lowercase();
let canonical = match raw_lower.as_str() {
"production" | "prod" => "prod",
"development" | "dev" | "" => "dev",
other => other,
}
.to_owned();
let alias = match raw_lower.as_str() {
"production" => Some("production"),
"development" => Some("development"),
_ => None,
};
let profiles: Vec<&str> = std::iter::once(canonical.as_str()).chain(alias).collect();
let table = get_merged_toml_table_profiles(&profiles);
let parse_csv_env = |var: &str| -> Option<Vec<String>> {
std::env::var(var).ok().map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(std::borrow::ToOwned::to_owned)
.collect()
})
};
let parse_bool_env = |var: &str| -> Option<bool> {
std::env::var(var)
.ok()
.map(|v| matches!(v.trim(), "true" | "1"))
};
let security = table.get("security");
let tp_section = security.and_then(|s| s.get("trusted_proxies"));
let rl_section = security.and_then(|s| s.get("rate_limit"));
let toml_csv = |section: Option<&toml::Value>, key: &str| -> Vec<String> {
section
.and_then(|s| s.get(key))
.and_then(toml::Value::as_array)
.map(|a| {
a.iter()
.filter_map(toml::Value::as_str)
.map(std::borrow::ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
};
let toml_bool = |section: Option<&toml::Value>, key: &str| -> bool {
section
.and_then(|s| s.get(key))
.and_then(toml::Value::as_bool)
.unwrap_or(false)
};
ProxyConflictData {
new_ranges: parse_csv_env("AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES")
.unwrap_or_else(|| toml_csv(tp_section, "ranges")),
new_trust_fwd: parse_bool_env("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS")
.unwrap_or_else(|| toml_bool(tp_section, "trust_forwarded_headers")),
new_hops: std::env::var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.or_else(|| {
tp_section
.and_then(|s| s.get("trusted_hops"))
.and_then(toml::Value::as_integer)
.and_then(|v| u32::try_from(v).ok())
}),
old_ranges: parse_csv_env("AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES")
.unwrap_or_else(|| toml_csv(rl_section, "trusted_proxies")),
old_trust_fwd: parse_bool_env("AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS")
.unwrap_or_else(|| toml_bool(rl_section, "trust_forwarded_headers")),
}
}
fn resolve_rate_limit_key_strategy() -> String {
if let Ok(val) = std::env::var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY")
&& !val.is_empty()
{
return val;
}
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| {
t.get("security")
.and_then(|s| s.get("rate_limit"))
.and_then(|rl| rl.get("key_strategy"))
.and_then(toml::Value::as_str)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
})
.unwrap_or_default()
}
fn resolve_auth_extractor_mounted() -> bool {
if let Ok(val) = std::env::var("AUTUMN_AUTH__ENABLED") {
return !val.trim().eq_ignore_ascii_case("false");
}
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.is_some_and(|t| {
t.get("auth").is_some_and(|auth| {
auth.get("enabled")
.and_then(toml::Value::as_bool)
.unwrap_or(true) })
})
}
fn resolve_optional_signing_secret() -> Option<String> {
if let Ok(val) = std::env::var("AUTUMN_SECURITY__SIGNING_SECRET")
&& !val.is_empty()
{
return Some(val);
}
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| {
t.get("security")
.and_then(|s| s.get("signing_secret"))
.and_then(|ss| ss.get("secret"))
.and_then(toml::Value::as_str)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
})
}
fn resolve_trusted_hosts() -> Vec<String> {
if let Ok(val) = std::env::var("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS") {
return val
.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
.collect();
}
let table = read_autumn_toml_table().unwrap_or_default();
let profile = std::env::var("AUTUMN_ENV")
.ok()
.or_else(|| std::env::var("AUTUMN_PROFILE").ok())
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let parse_hosts = |root: &toml::Table| {
root.get("security")
.and_then(|s| s.get("trusted_hosts"))
.and_then(|th| th.get("hosts"))
.and_then(toml::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(toml::Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(std::borrow::ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
if !profile.is_empty()
&& let Some(profile_table) = table
.get("profile")
.and_then(|v| v.get(&profile))
.and_then(toml::Value::as_table)
{
let profile_hosts = parse_hosts(profile_table);
if !profile_hosts.is_empty() {
return profile_hosts;
}
}
parse_hosts(&table)
}
fn resolve_is_production() -> bool {
for var in ["AUTUMN_ENV", "AUTUMN_PROFILE"] {
if let Ok(val) = std::env::var(var) {
let v = val.trim().to_ascii_lowercase();
if v == "prod" || v == "production" {
return true;
}
if !v.is_empty() {
return false;
}
}
}
false
}
fn tailwind_enabled() -> bool {
std::fs::read_to_string("autumn.toml")
.ok()
.and_then(|c| toml::from_str::<toml::Table>(&c).ok())
.and_then(|t| t.get("tailwind").and_then(toml::Value::as_bool))
.unwrap_or_else(|| std::path::Path::new("build.rs").exists())
}
fn resolve_compression_enabled() -> bool {
if let Ok(val) = std::env::var("AUTUMN_COMPRESSION__ENABLED") {
match val.as_str() {
"true" | "1" => return true,
"false" | "0" => return false,
_ => {}
}
}
let table = read_autumn_toml_table().unwrap_or_default();
let profile = std::env::var("AUTUMN_ENV")
.ok()
.or_else(|| std::env::var("AUTUMN_PROFILE").ok())
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let parse_enabled = |root: &toml::Table| {
root.get("compression")
.and_then(|v| v.get("enabled"))
.and_then(toml::Value::as_bool)
};
if !profile.is_empty()
&& let Some(enabled) = table
.get("profile")
.and_then(|v| v.get(&profile))
.and_then(toml::Value::as_table)
.and_then(&parse_enabled)
{
return enabled;
}
parse_enabled(&table).unwrap_or(false)
}
#[allow(clippy::too_many_lines)]
pub fn run(opts: DoctorOptions) {
use std::thread;
type Task = Box<dyn FnOnce() -> CheckResult + Send>;
let cli_version = env!("CARGO_PKG_VERSION");
if !opts.json {
println!("\u{1F342} autumn doctor\n");
}
let msrv = read_msrv().unwrap_or_else(|| "1.88.0".to_owned());
let web_ver = read_autumn_web_version();
let toml_result = std::fs::read_to_string("autumn.toml");
let toml_table = toml_result
.as_deref()
.ok()
.and_then(|content| toml::from_str::<toml::Table>(content).ok())
.or_else(read_autumn_toml_table);
let db_topology = resolve_database_topology(toml_table.as_ref());
let port = resolve_server_port();
let tailwind = tailwind_enabled();
let signing_secret = resolve_optional_signing_secret();
let trusted_hosts = resolve_trusted_hosts();
let is_production = resolve_is_production();
let rate_limit_key_strategy = resolve_rate_limit_key_strategy();
let auth_extractor_mounted = resolve_auth_extractor_mounted();
let compression_enabled = resolve_compression_enabled();
let proxy_conflict_data = resolve_proxy_conflict_data();
let mut tasks: Vec<Task> = Vec::new();
tasks.push(Box::new(move || check_rust_toolchain(&msrv)));
if let Some(web) = web_ver {
tasks.push(Box::new(move || check_version_compat(cli_version, &web)));
}
match toml_result {
Ok(content) => tasks.push(Box::new(move || check_toml_content(&content))),
Err(_) => tasks.push(Box::new(|| CheckResult {
name: "autumn_toml",
status: CheckStatus::Warn,
detail: Some("autumn.toml not found in current directory".into()),
hint: Some("Run `autumn doctor` from your project root (where autumn.toml lives)"),
})),
}
let topology_for_check = db_topology.clone();
tasks.push(Box::new(move || {
check_database_topology_contract(&topology_for_check, is_production)
}));
if let Some(url) = db_topology.primary_url.clone() {
let primary_for_pending = url.clone();
tasks.push(Box::new(move || check_db_connectivity(&url)));
tasks.push(Box::new(move || {
check_pending_migrations(&primary_for_pending)
}));
}
if let Some(url) = db_topology.replica_url.clone() {
tasks.push(Box::new(move || {
check_db_role_connectivity("replica", &url, tcp_reachable)
}));
}
if let (Some(primary_url), Some(replica_url)) = (
db_topology.primary_url.clone(),
db_topology.replica_url.clone(),
) {
let fallback = db_topology.replica_fallback;
tasks.push(Box::new(move || {
check_replica_migrations(&primary_url, &replica_url, fallback)
}));
}
tasks.push(Box::new(move || check_port_bindable(port)));
if tailwind {
tasks.push(Box::new(check_tailwind_binary));
}
tasks.push(Box::new(move || {
check_signing_secret_impl(signing_secret.as_deref(), is_production)
}));
tasks.push(Box::new(move || {
check_trusted_hosts_impl(&trusted_hosts, is_production)
}));
tasks.push(Box::new(move || {
check_rate_limit_key_strategy_impl(&rate_limit_key_strategy, auth_extractor_mounted)
}));
tasks.push(Box::new(move || {
check_proxy_conflict_impl(&proxy_conflict_data)
}));
let oauth2_providers = resolve_oauth2_providers();
for p in oauth2_providers {
tasks.push(Box::new(move || {
check_oauth2_provider_impl(&p.name, &p.client_id, &p.client_secret, is_production)
}));
}
tasks.push(Box::new(check_stale_artifacts));
tasks.push(Box::new(check_maintenance_mode));
tasks.push(Box::new(move || {
check_compression_impl(compression_enabled, is_production)
}));
tasks.push(Box::new(check_system_test_browser));
tasks.push(Box::new(|| {
let has_auth_starter = std::path::Path::new("src/routes/auth.rs").exists();
let unregistered = resolve_gdpr_unregistered_tables();
check_gdpr_export_registration_impl(
has_auth_starter,
&unregistered.iter().map(String::as_str).collect::<Vec<_>>(),
)
}));
let handles: Vec<thread::JoinHandle<CheckResult>> =
tasks.into_iter().map(thread::spawn).collect();
let results: Vec<CheckResult> = handles
.into_iter()
.map(|h| {
h.join().unwrap_or_else(|_| CheckResult {
name: "internal_error",
status: CheckStatus::Fail,
detail: Some("a check panicked unexpectedly".into()),
hint: None,
})
})
.collect();
let summary = compute_summary(&results);
let code = exit_code(&summary, opts.strict);
if opts.json {
println!("{}", to_json_output(&results, &summary));
} else {
for r in &results {
println!("{}", format_check_line(r));
}
println!();
println!("{}", format_summary_line(&summary, code));
}
std::process::exit(code);
}
pub fn check_compression_impl(compression_enabled: bool, is_production: bool) -> CheckResult {
if is_production && !compression_enabled {
return CheckResult {
name: "compression",
status: CheckStatus::Warn,
detail: Some(
"response compression is disabled in production; \
text payloads (HTML/JSON/CSS) are served uncompressed"
.into(),
),
hint: Some(
"Set [compression] enabled = true in autumn.toml (or AUTUMN_COMPRESSION__ENABLED=true) \
if you are not using a CDN or reverse-proxy that compresses for you. \
Read the BREACH/CRIME tradeoff in docs/guide/compression.md before enabling.",
),
};
}
CheckResult {
name: "compression",
status: CheckStatus::Pass,
detail: Some(if compression_enabled {
"response compression is enabled".into()
} else {
"response compression is disabled (off by default; use CDN or enable explicitly)".into()
}),
hint: None,
}
}
pub fn check_maintenance_mode() -> CheckResult {
use autumn_web::maintenance::{MAINTENANCE_FLAG_FILE, MaintenanceState};
let path = std::path::Path::new(MAINTENANCE_FLAG_FILE);
match MaintenanceState::load_from_file(path) {
Ok(Some(config)) => {
let detail = config.message.as_ref().map_or_else(
|| "maintenance mode is ON".to_owned(),
|msg| format!("maintenance mode is ON — \"{msg}\""),
);
CheckResult {
name: "maintenance_mode",
status: CheckStatus::Warn,
detail: Some(detail),
hint: Some("Run `autumn maintenance off` to re-enable normal traffic"),
}
}
Ok(None) | Err(_) => CheckResult {
name: "maintenance_mode",
status: CheckStatus::Pass,
detail: Some("maintenance mode is off".into()),
hint: None,
},
}
}
pub fn browser_candidate_paths() -> Vec<std::path::PathBuf> {
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
if let Ok(p) = std::env::var("AUTUMN_CHROMIUM") {
candidates.push(std::path::PathBuf::from(p));
}
if let Ok(base) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
let base = std::path::PathBuf::from(base);
if let Ok(entries) = std::fs::read_dir(&base) {
let mut pw_paths: Vec<_> = entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("chromium-"))
.map(|e| {
if cfg!(target_os = "macos") {
e.path()
.join("chrome-mac")
.join("Chromium.app")
.join("Contents")
.join("MacOS")
.join("Chromium")
} else if cfg!(target_os = "windows") {
e.path().join("chrome-win").join("chrome.exe")
} else {
e.path().join("chrome-linux").join("chrome")
}
})
.collect();
pw_paths.sort();
pw_paths.reverse();
candidates.extend(pw_paths);
}
}
candidates.extend(
[
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
.map(std::path::PathBuf::from),
);
candidates
}
fn probe_browser_version(path: &std::path::Path) -> Option<String> {
std::process::Command::new(path)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
}
fn cargo_toml_features_has_key(cargo_toml: &str, key: &str) -> bool {
let quoted_key = format!("\"{key}\"");
let mut in_features = false;
for line in cargo_toml.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if trimmed == "[features]"
|| (trimmed.starts_with("[features]")
&& trimmed["[features]".len()..].trim_start().starts_with('#'))
{
in_features = true;
continue;
}
if in_features {
if trimmed.starts_with('[') {
break;
}
let bare = trimmed
.strip_prefix(key)
.is_some_and(|r| r.trim_start().starts_with('='));
let quoted = trimmed
.strip_prefix(quoted_key.as_str())
.is_some_and(|r| r.trim_start().starts_with('='));
if bare || quoted {
return true;
}
}
}
false
}
pub fn check_system_test_browser() -> CheckResult {
let candidates = browser_candidate_paths();
for path in &candidates {
if path.is_file()
&& let Some(version) = probe_browser_version(path)
{
return CheckResult {
name: "system_test_browser",
status: CheckStatus::Pass,
detail: Some(format!(
"Chromium for system tests: {version} ({})",
path.display()
)),
hint: None,
};
}
}
let project_uses_system_tests = std::env::current_dir()
.ok()
.and_then(|d| std::fs::read_to_string(d.join("Cargo.toml")).ok())
.is_some_and(|s| cargo_toml_features_has_key(&s, "system-tests"));
if project_uses_system_tests {
CheckResult {
name: "system_test_browser",
status: CheckStatus::Warn,
detail: Some("no Chromium binary found — system tests will be skipped".into()),
hint: Some(
"Install: apt-get install chromium-browser \
or set AUTUMN_CHROMIUM=/path/to/chrome",
),
}
} else {
CheckResult {
name: "system_test_browser",
status: CheckStatus::Pass,
detail: Some("no Chromium binary found (project does not use system-tests)".into()),
hint: None,
}
}
}
fn resolve_gdpr_unregistered_tables() -> Vec<String> {
let schema_path = std::path::Path::new("src/schema.rs");
let Ok(schema) = std::fs::read_to_string(schema_path) else {
return Vec::new();
};
let mut declared_tables: Vec<String> = Vec::new();
for line in schema.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed
.strip_prefix("diesel::table!")
.map(str::trim)
.filter(|r| r.starts_with('{'))
{
let _ = rest;
}
if let Some(open_paren) = trimmed.find('(')
&& (trimmed.ends_with('{') || trimmed.ends_with("{ "))
{
let name = trimmed[..open_paren].trim();
if !name.is_empty() && !name.starts_with("//") && !name.starts_with("diesel") {
declared_tables.push(name.to_owned());
}
}
}
if declared_tables.is_empty() {
return Vec::new();
}
let registered_tables = collect_gdpr_registered_tables_from_source();
declared_tables
.into_iter()
.filter(|t| !registered_tables.contains(t))
.collect()
}
fn collect_gdpr_registered_tables_from_source() -> std::collections::HashSet<String> {
let mut found = std::collections::HashSet::new();
for path in glob_rs_files(std::path::Path::new("src")) {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
scan_source_for_gdpr_registrations(&content, &mut found);
}
found
}
fn scan_source_for_gdpr_registrations(
content: &str,
found: &mut std::collections::HashSet<String>,
) {
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
if let Some(idx) = line.find("ModelRegistration::") {
let rest = &line[idx..];
if extract_quoted_table_name(rest, found) {
continue;
}
for j in 1..=3 {
if let Some(next) = lines.get(i + j)
&& extract_quoted_table_name(next.trim(), found)
{
break;
}
}
}
}
}
fn extract_quoted_table_name(s: &str, found: &mut std::collections::HashSet<String>) -> bool {
if let Some(start) = s.find('"')
&& let Some(end) = s[start + 1..].find('"')
{
let name = &s[start + 1..start + 1 + end];
if !name.is_empty() && !name.contains(' ') {
found.insert(name.to_owned());
return true;
}
}
false
}
fn glob_rs_files(dir: impl AsRef<std::path::Path>) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
out.append(&mut glob_rs_files(&path));
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
out
}
pub fn check_gdpr_export_registration_impl(
has_auth_starter: bool,
unregistered_tables: &[&str],
) -> CheckResult {
if !has_auth_starter {
return CheckResult {
name: "gdpr_export_registration",
status: CheckStatus::Pass,
detail: Some("auth starter not present; GDPR registration not required".into()),
hint: None,
};
}
if unregistered_tables.is_empty() {
return CheckResult {
name: "gdpr_export_registration",
status: CheckStatus::Pass,
detail: Some("all repositories are registered for GDPR export/erasure".into()),
hint: None,
};
}
let tables = unregistered_tables.join(", ");
CheckResult {
name: "gdpr_export_registration",
status: CheckStatus::Warn,
detail: Some(format!(
"{} repository model(s) not registered for GDPR export/erasure: {tables}",
unregistered_tables.len()
)),
hint: Some(
"Register each model via GdprRegistry in your application state. \
See docs/guide/gdpr-compliance.md for the export/erasure API.",
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glyph_pass() {
assert_eq!(glyph(&CheckStatus::Pass), "✅");
}
#[test]
fn glyph_warn() {
assert_eq!(glyph(&CheckStatus::Warn), "⚠️ ");
}
#[test]
fn glyph_fail() {
assert_eq!(glyph(&CheckStatus::Fail), "❌");
}
#[test]
fn trusted_hosts_fail_in_production_when_empty() {
let result = check_trusted_hosts_impl(&[], true);
assert_eq!(result.name, "trusted_hosts");
assert!(matches!(result.status, CheckStatus::Fail));
}
#[test]
fn trusted_hosts_warn_on_wildcard() {
let result = check_trusted_hosts_impl(&["*".to_owned()], true);
assert_eq!(result.name, "trusted_hosts");
assert!(matches!(result.status, CheckStatus::Warn));
}
#[test]
fn trusted_hosts_warn_on_wildcard_when_mixed_with_other_entries() {
let result = check_trusted_hosts_impl(&["example.com".to_owned(), "*".to_owned()], true);
assert_eq!(result.name, "trusted_hosts");
assert!(matches!(result.status, CheckStatus::Warn));
}
#[test]
fn compute_summary_all_pass() {
let results = vec![
CheckResult {
name: "a",
status: CheckStatus::Pass,
detail: None,
hint: None,
},
CheckResult {
name: "b",
status: CheckStatus::Pass,
detail: None,
hint: None,
},
];
let s = compute_summary(&results);
assert_eq!(s.passed, 2);
assert_eq!(s.warned, 0);
assert_eq!(s.failed, 0);
}
#[test]
fn compute_summary_mixed() {
let results = vec![
CheckResult {
name: "a",
status: CheckStatus::Pass,
detail: None,
hint: None,
},
CheckResult {
name: "b",
status: CheckStatus::Warn,
detail: None,
hint: None,
},
CheckResult {
name: "c",
status: CheckStatus::Fail,
detail: None,
hint: None,
},
];
let s = compute_summary(&results);
assert_eq!(s.passed, 1);
assert_eq!(s.warned, 1);
assert_eq!(s.failed, 1);
}
#[test]
fn compute_summary_empty() {
let s = compute_summary(&[]);
assert_eq!(s.passed, 0);
assert_eq!(s.warned, 0);
assert_eq!(s.failed, 0);
}
#[test]
fn exit_code_no_failures() {
let s = Summary {
passed: 3,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&s, false), 0);
}
#[test]
fn exit_code_with_failure() {
let s = Summary {
passed: 2,
warned: 0,
failed: 1,
};
assert_eq!(exit_code(&s, false), 1);
}
#[test]
fn exit_code_warn_non_strict() {
let s = Summary {
passed: 2,
warned: 1,
failed: 0,
};
assert_eq!(exit_code(&s, false), 0);
}
#[test]
fn exit_code_warn_strict() {
let s = Summary {
passed: 2,
warned: 1,
failed: 0,
};
assert_eq!(exit_code(&s, true), 1);
}
#[test]
fn exit_code_zero_when_all_pass_strict() {
let s = Summary {
passed: 5,
warned: 0,
failed: 0,
};
assert_eq!(exit_code(&s, true), 0);
}
#[test]
fn format_check_line_pass_contains_glyph_and_name() {
let r = CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: Some("1.88.0".into()),
hint: None,
};
let line = format_check_line(&r);
assert!(line.contains("✅"));
assert!(line.contains("rust_toolchain"));
}
#[test]
fn format_check_line_fail_includes_hint() {
let r = CheckResult {
name: "port_bindable",
status: CheckStatus::Fail,
detail: Some("port 3000 in use".into()),
hint: Some("Kill the process using port 3000 or change server.port in autumn.toml"),
};
let line = format_check_line(&r);
assert!(line.contains("❌"));
assert!(line.contains("port_bindable"));
assert!(line.contains("Kill the process"));
}
#[test]
fn format_check_line_pass_omits_hint() {
let r = CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: None,
hint: Some("some hint that should not appear on pass"),
};
let line = format_check_line(&r);
assert!(!line.contains("some hint"));
}
#[test]
fn format_check_line_warn_includes_hint() {
let r = CheckResult {
name: "version_compat",
status: CheckStatus::Warn,
detail: Some("patch skew".into()),
hint: Some("Update your dependency"),
};
let line = format_check_line(&r);
assert!(line.contains("⚠️"));
assert!(line.contains("Update your dependency"));
}
#[test]
fn format_summary_all_pass() {
let s = Summary {
passed: 7,
warned: 0,
failed: 0,
};
let line = format_summary_line(&s, 0);
assert!(line.contains("7 passed"));
assert!(line.contains("0 warnings"));
assert!(line.contains("0 failed"));
assert!(line.contains("all clear"));
}
#[test]
fn format_summary_with_failure() {
let s = Summary {
passed: 5,
warned: 1,
failed: 1,
};
let line = format_summary_line(&s, 1);
assert!(line.contains("5 passed"));
assert!(line.contains("1 warning"));
assert!(line.contains("1 failed"));
assert!(line.contains("problems found"));
}
#[test]
fn format_summary_singular_warning_label() {
let s = Summary {
passed: 3,
warned: 1,
failed: 0,
};
let line = format_summary_line(&s, 0);
assert!(line.contains("1 warning,"));
}
#[test]
fn format_summary_plural_warning_label() {
let s = Summary {
passed: 1,
warned: 2,
failed: 0,
};
let line = format_summary_line(&s, 0);
assert!(line.contains("2 warnings,"));
}
#[test]
fn check_toml_content_valid() {
let content = r#"
[server]
port = 3000
[database]
url = "postgres://localhost/mydb"
"#;
let r = check_toml_content(content);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_toml_content_syntax_error() {
let content = "[[[[invalid toml";
let r = check_toml_content(content);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_toml_content_unknown_key_warns() {
let content = r#"
[server]
port = 3000
[totally_unknown_section]
foo = "bar"
"#;
let r = check_toml_content(content);
assert_eq!(r.status, CheckStatus::Warn);
assert!(
r.detail
.as_deref()
.unwrap_or("")
.contains("totally_unknown_section"),
"detail should mention the unknown key"
);
}
#[test]
fn check_toml_content_empty_is_pass() {
let r = check_toml_content("");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_toml_content_all_known_sections_pass() {
let content: String = KNOWN_TOML_SECTIONS
.iter()
.flat_map(|&s| ["[", s, "]\n"])
.collect();
let r = check_toml_content(&content);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_version_compat_matching() {
let r = check_version_compat("0.3.0", "0.3.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_version_compat_minor_skew_fails() {
let r = check_version_compat("0.3.0", "0.4.0");
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_version_compat_patch_skew_warns() {
let r = check_version_compat("0.3.0", "0.3.1");
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn check_version_compat_caret_requirement_stripped() {
let r = check_version_compat("0.3.0", "^0.3");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_version_compat_tilde_requirement_stripped() {
let r = check_version_compat("0.3.0", "~0.3.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_version_compat_exact_requirement_stripped() {
let r = check_version_compat("0.3.0", "=0.3.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_port_bindable_impl_success() {
let r = check_port_bindable_impl(3000, |_port| true);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.as_deref().unwrap_or("").contains("available"));
}
#[test]
fn check_port_bindable_impl_failure() {
let r = check_port_bindable_impl(3000, |_port| false);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
assert!(r.detail.as_deref().unwrap_or("").contains("in use"));
}
#[test]
fn check_port_bindable_impl_reports_correct_port() {
let r = check_port_bindable_impl(8080, |_| true);
assert!(r.detail.as_deref().unwrap_or("").contains("8080"));
}
#[test]
fn check_rust_toolchain_impl_pass() {
let r = check_rust_toolchain_impl("rustc 1.88.0 (abc123 2026-04-01)", "1.88.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_rust_toolchain_impl_fail() {
let r = check_rust_toolchain_impl("rustc 1.80.0 (abc123 2025-01-01)", "1.88.0");
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_rust_toolchain_impl_newer_passes() {
let r = check_rust_toolchain_impl("rustc 1.90.0 (abc 2026-01-01)", "1.88.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_rust_toolchain_impl_exact_msrv_passes() {
let r = check_rust_toolchain_impl("1.88.0", "1.88.0");
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_rust_toolchain_impl_unparseable_warns() {
let r = check_rust_toolchain_impl("not-a-version", "1.88.0");
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn parse_db_host_port_full_url() {
let (host, port) = parse_db_host_port("postgres://user:pass@localhost:5432/mydb").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_no_credentials() {
let (host, port) = parse_db_host_port("postgres://localhost/mydb").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_default_port() {
let (host, port) = parse_db_host_port("postgres://user:pass@db.example.com/mydb").unwrap();
assert_eq!(host, "db.example.com");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_custom_port() {
let (host, port) = parse_db_host_port("postgres://localhost:6543/test").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 6543);
}
#[test]
fn parse_db_host_port_unbracketed_ipv6_defaults_port() {
let (host, port) = parse_db_host_port("postgres://::1/db").unwrap();
assert_eq!(host, "::1");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_bracketed_ipv6_custom_port() {
let (host, port) = parse_db_host_port("postgres://[::1]:6543/db").unwrap();
assert_eq!(host, "::1");
assert_eq!(port, 6543);
}
#[test]
fn parse_db_host_port_postgresql_scheme() {
let (host, port) = parse_db_host_port("postgresql://localhost:5432/db").unwrap();
assert_eq!(host, "localhost");
assert_eq!(port, 5432);
}
#[test]
fn parse_db_host_port_invalid_scheme() {
assert!(parse_db_host_port("mysql://localhost/db").is_none());
}
#[test]
fn database_topology_doctor_fails_replica_without_primary() {
let topology = DoctorDatabaseTopology {
primary_url: None,
replica_url: Some("postgres://user:secret@replica:5432/app".to_owned()),
auto_migrate_in_production: false,
replica_fallback: DoctorReplicaFallback::FailReadiness,
};
let result = check_database_topology_contract(&topology, true);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("replica"));
assert!(result.hint.unwrap_or_default().contains("primary"));
}
#[test]
fn database_topology_doctor_fails_prod_replica_with_startup_migrations() {
let topology = DoctorDatabaseTopology {
primary_url: Some("postgres://user:secret@primary:5432/app".to_owned()),
replica_url: Some("postgres://user:secret@replica:5432/app".to_owned()),
auto_migrate_in_production: true,
replica_fallback: DoctorReplicaFallback::FailReadiness,
};
let result = check_database_topology_contract(&topology, true);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("migration"));
assert!(!detail.contains("secret"));
}
#[test]
fn database_topology_doctor_fails_stale_replica_when_readiness_must_fail() {
let result = check_replica_migration_versions(
Some("20260510000000"),
Some("20260509000000"),
DoctorReplicaFallback::FailReadiness,
);
assert_eq!(result.status, CheckStatus::Fail);
assert!(result.detail.unwrap_or_default().contains("replica"));
assert!(result.hint.unwrap_or_default().contains("replay"));
}
#[test]
fn database_topology_doctor_warns_stale_replica_when_falling_back_to_primary() {
let result = check_replica_migration_versions(
Some("20260510000000"),
Some("20260509000000"),
DoctorReplicaFallback::Primary,
);
assert_eq!(result.status, CheckStatus::Warn);
assert!(result.detail.unwrap_or_default().contains("primary"));
}
#[test]
fn database_topology_parses_latest_applied_migration_version() {
let output = r"
[X] 20260509000000_create_widgets
[ ] 20260510000000_add_widget_index
[X] 20260508000000_create_users
";
assert_eq!(
parse_latest_applied_migration_version(output).as_deref(),
Some("20260509000000_create_widgets")
);
}
#[test]
fn database_topology_connectivity_names_role_without_credentials() {
let result = check_db_role_connectivity(
"primary",
"postgres://user:secret@db:5432/app",
|host, port| {
assert_eq!(host, "db");
assert_eq!(port, 5432);
false
},
);
assert_eq!(result.status, CheckStatus::Fail);
let detail = result.detail.unwrap_or_default();
assert!(detail.contains("primary"));
assert!(detail.contains("db:5432"));
assert!(!detail.contains("user"));
assert!(!detail.contains("secret"));
}
fn temp_tailwind_path(temp: &tempfile::TempDir) -> std::path::PathBuf {
let tailwind_name = if cfg!(windows) {
"tailwindcss.exe"
} else {
"tailwindcss"
};
temp.path()
.join("target")
.join("autumn")
.join(tailwind_name)
}
fn create_executable_tailwind_fixture(path: &std::path::Path) {
let parent = path.parent().expect("tailwind path has parent");
std::fs::create_dir_all(parent).expect("create tailwind parent");
std::fs::copy(std::env::current_exe().expect("current test exe"), path)
.expect("copy executable fixture");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mut permissions = std::fs::metadata(path)
.expect("tailwind fixture metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("make tailwind fixture executable");
}
}
#[test]
fn check_tailwind_expected_path_directory_fails() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
std::fs::create_dir_all(&tailwind_path).expect("create directory at tailwind path");
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Fail);
assert!(
r.detail.as_deref().unwrap_or("").contains("directory"),
"detail should identify directory path, got {r:?}"
);
}
#[test]
fn check_tailwind_not_found() {
let temp = tempfile::tempdir().expect("temp dir");
let r = check_tailwind_binary_at(&temp_tailwind_path(&temp));
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("not found"));
assert!(r.hint.unwrap_or("").contains("autumn setup"));
}
#[test]
fn check_tailwind_regular_file_without_execute_permission_fails() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
std::fs::create_dir_all(tailwind_path.parent().expect("tailwind path has parent"))
.expect("create tailwind parent");
std::fs::write(&tailwind_path, "not an executable").expect("write non-executable file");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mut permissions = std::fs::metadata(&tailwind_path)
.expect("tailwind file metadata")
.permissions();
permissions.set_mode(0o644);
std::fs::set_permissions(&tailwind_path, permissions)
.expect("clear executable permission");
}
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("not executable"));
assert!(r.hint.unwrap_or("").contains("--force"));
}
#[test]
fn check_tailwind_existing_file_fails_when_current_user_cannot_execute() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
create_executable_tailwind_fixture(&tailwind_path);
let r = check_tailwind_binary_at_with_executable_probe(&tailwind_path, |_, _| false);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("not executable"));
}
#[test]
fn check_tailwind_executable_file_passes() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
create_executable_tailwind_fixture(&tailwind_path);
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.detail.as_deref().unwrap_or("").contains("executable"));
}
#[cfg(unix)]
#[test]
fn check_tailwind_broken_symlink_fails() {
let temp = tempfile::tempdir().expect("temp dir");
let tailwind_path = temp_tailwind_path(&temp);
std::fs::create_dir_all(tailwind_path.parent().expect("tailwind path has parent"))
.expect("create tailwind parent");
std::os::unix::fs::symlink(temp.path().join("missing-tailwind"), &tailwind_path)
.expect("create broken symlink");
let r = check_tailwind_binary_at(&tailwind_path);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("broken symlink"));
}
#[test]
fn check_signing_secret_impl_name_is_signing_secret() {
let r = check_signing_secret_impl(None, false);
assert_eq!(r.name, "signing_secret");
}
#[test]
fn check_signing_secret_impl_dev_no_secret_warns() {
let r = check_signing_secret_impl(None, false);
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.detail.as_deref().unwrap_or("").contains("ephemeral"));
}
#[test]
fn check_signing_secret_impl_dev_with_valid_secret_passes() {
let r = check_signing_secret_impl(Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"), false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_signing_secret_impl_prod_missing_fails() {
let r = check_signing_secret_impl(None, true);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_signing_secret_impl_prod_too_short_fails() {
let r = check_signing_secret_impl(Some("short"), true);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.detail.as_deref().unwrap_or("").contains("short"));
}
#[test]
fn check_signing_secret_impl_prod_demo_value_fails() {
let r = check_signing_secret_impl(Some("changeme"), true);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.hint.is_some());
}
#[test]
fn check_signing_secret_impl_prod_valid_secret_passes() {
let secret = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";
let r = check_signing_secret_impl(Some(secret), true);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn check_signing_secret_impl_prod_hint_mentions_openssl() {
let r = check_signing_secret_impl(None, true);
let hint = r.hint.unwrap_or("");
assert!(hint.contains("openssl") || hint.contains("rand"));
}
#[test]
fn check_signing_secret_impl_dev_warn_has_hint() {
let r = check_signing_secret_impl(None, false);
assert!(r.hint.is_some());
}
#[test]
fn json_output_contains_checks_and_summary() {
let results = vec![
CheckResult {
name: "rust_toolchain",
status: CheckStatus::Pass,
detail: Some("1.88.0".into()),
hint: None,
},
CheckResult {
name: "port_bindable",
status: CheckStatus::Fail,
detail: None,
hint: Some("hint text"),
},
];
let summary = compute_summary(&results);
let json = to_json_output(&results, &summary);
assert!(json.contains("rust_toolchain"));
assert!(json.contains("port_bindable"));
assert!(json.contains("\"passed\": 1"));
assert!(json.contains("\"failed\": 1"));
}
#[test]
fn json_output_valid_json() {
let results = vec![CheckResult {
name: "test",
status: CheckStatus::Pass,
detail: None,
hint: None,
}];
let summary = compute_summary(&results);
let json = to_json_output(&results, &summary);
assert!(serde_json::from_str::<serde_json::Value>(&json).is_ok());
}
#[test]
fn rate_limit_key_strategy_ip_always_passes() {
let r = check_rate_limit_key_strategy_impl("ip", false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_api_token_always_passes() {
let r = check_rate_limit_key_strategy_impl("api_token", false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_authenticated_principal_without_auth_warns_in_strict() {
let r = check_rate_limit_key_strategy_impl("authenticated_principal", false);
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.hint.is_some());
assert!(
r.detail.as_deref().unwrap_or("").contains("auth extractor"),
"detail should mention auth extractor"
);
}
#[test]
fn rate_limit_key_strategy_authenticated_principal_with_auth_passes() {
let r = check_rate_limit_key_strategy_impl("authenticated_principal", true);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_name_is_stable() {
let r = check_rate_limit_key_strategy_impl("authenticated_principal", false);
assert_eq!(r.name, "rate_limit_key_strategy");
}
#[test]
fn rate_limit_key_strategy_empty_strategy_passes() {
let r = check_rate_limit_key_strategy_impl("", false);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn rate_limit_key_strategy_invalid_value_fails() {
let r = check_rate_limit_key_strategy_impl("authenticated_principals", false);
assert_eq!(r.status, CheckStatus::Fail);
assert!(
r.detail
.as_deref()
.unwrap_or("")
.contains("not a valid strategy")
);
}
#[test]
fn check_maintenance_mode_passes_when_off() {
let result = check_maintenance_mode();
assert!(
result.status == CheckStatus::Pass || result.status == CheckStatus::Warn,
"unexpected status: {:?}",
result.status
);
}
#[test]
fn check_oauth2_empty_client_id_in_production_fails() {
let result = check_oauth2_provider_impl("github", "", "real-secret-value", true);
assert_eq!(
result.status,
CheckStatus::Fail,
"empty client_id in production must fail: {result:?}",
);
assert!(
result.detail.as_deref().unwrap_or("").contains("client_id"),
"detail must mention client_id: {:?}",
result.detail
);
}
#[test]
fn check_oauth2_empty_client_secret_in_production_fails() {
let result = check_oauth2_provider_impl("github", "cid", "", true);
assert_eq!(
result.status,
CheckStatus::Fail,
"empty client_secret in production must fail: {result:?}",
);
assert!(
result
.detail
.as_deref()
.unwrap_or("")
.contains("client_secret"),
"detail must mention client_secret: {:?}",
result.detail
);
}
#[test]
fn check_oauth2_empty_client_secret_in_dev_warns() {
let result = check_oauth2_provider_impl("github", "cid", "", false);
assert_eq!(
result.status,
CheckStatus::Warn,
"empty client_secret outside production must warn: {result:?}",
);
}
#[test]
fn check_oauth2_non_empty_client_secret_passes() {
let result = check_oauth2_provider_impl("github", "cid", "real-secret-value", true);
assert_eq!(
result.status,
CheckStatus::Pass,
"non-empty client_secret must pass: {result:?}",
);
}
#[test]
fn check_oauth2_provider_name_appears_in_check_name() {
let result = check_oauth2_provider_impl("google", "cid", "", true);
assert!(
result.name.contains("oauth2") || result.name.contains("google"),
"check name must identify the provider: {}",
result.name
);
}
#[test]
fn resolve_oauth2_providers_prefers_env_var_over_empty_toml_secret() {
let toml: toml::Table = toml::from_str(
r#"
[auth.oauth2.github]
client_id = "cid"
client_secret = ""
authorize_url = "https://github.com/login/oauth/authorize"
token_url = "https://github.com/login/oauth/access_token"
redirect_uri = "http://localhost/callback"
"#,
)
.unwrap();
let providers = resolve_oauth2_providers_from_sources(
|key| {
if key == "AUTUMN_AUTH__OAUTH2__GITHUB__CLIENT_SECRET" {
Some("ghp_test_secret".to_owned())
} else {
None
}
},
Some(&toml),
"dev",
);
let p = providers.into_iter().find(|p| p.name == "github").unwrap();
assert_eq!(
p.client_secret, "ghp_test_secret",
"env var must override empty TOML client_secret"
);
}
#[test]
fn compression_warns_in_production_when_disabled() {
let result = check_compression_impl(false, true);
assert_eq!(result.name, "compression");
assert!(
matches!(result.status, CheckStatus::Warn),
"expected Warn, got {:?}",
result.status
);
}
#[test]
fn compression_passes_in_production_when_enabled() {
let result = check_compression_impl(true, true);
assert_eq!(result.name, "compression");
assert!(
matches!(result.status, CheckStatus::Pass),
"expected Pass, got {:?}",
result.status
);
}
#[test]
fn compression_passes_in_dev_when_disabled() {
let result = check_compression_impl(false, false);
assert_eq!(result.name, "compression");
assert!(
matches!(result.status, CheckStatus::Pass),
"expected Pass in dev profile, got {:?}",
result.status
);
}
#[test]
fn parse_config_bool_handles_false_values() {
assert_eq!(parse_config_bool("false"), Some(false));
assert_eq!(parse_config_bool("0"), Some(false));
assert_eq!(parse_config_bool("no"), Some(false));
assert_eq!(parse_config_bool("off"), Some(false));
assert_eq!(parse_config_bool("true"), Some(true));
assert_eq!(parse_config_bool("1"), Some(true));
assert_eq!(parse_config_bool("yes"), Some(true));
assert_eq!(parse_config_bool("on"), Some(true));
assert_eq!(parse_config_bool("garbage"), None);
}
#[test]
fn proxy_conflict_passes_when_only_new_fields_set() {
let data = ProxyConflictData {
new_ranges: vec!["10.0.0.0/8".into()],
new_trust_fwd: true,
new_hops: None,
old_ranges: vec![],
old_trust_fwd: false,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn proxy_conflict_passes_when_only_old_fields_set() {
let data = ProxyConflictData {
new_ranges: vec![],
new_trust_fwd: false,
new_hops: None,
old_ranges: vec!["10.0.0.0/8".into()],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn proxy_conflict_warns_when_both_set_with_different_ranges() {
let data = ProxyConflictData {
new_ranges: vec!["10.0.0.0/8".into()],
new_trust_fwd: true,
new_hops: None,
old_ranges: vec!["192.168.0.0/16".into()],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Warn);
assert!(r.hint.is_some());
}
#[test]
fn proxy_conflict_warns_when_trusted_hops_set_alongside_old_fields() {
let data = ProxyConflictData {
new_ranges: vec![],
new_trust_fwd: true,
new_hops: Some(1),
old_ranges: vec![],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Warn);
}
#[test]
fn proxy_conflict_passes_when_matching_values_no_hops() {
let data = ProxyConflictData {
new_ranges: vec!["10.0.0.0/8".into()],
new_trust_fwd: true,
new_hops: None,
old_ranges: vec!["10.0.0.0/8".into()],
old_trust_fwd: true,
};
let r = check_proxy_conflict_impl(&data);
assert_eq!(r.status, CheckStatus::Pass);
}
#[test]
fn browser_check_not_found_is_warn_not_fail() {
let result = check_system_test_browser();
assert!(
result.status == CheckStatus::Pass || result.status == CheckStatus::Warn,
"browser check must be Pass or Warn, got {:?}",
result.status
);
}
#[test]
fn browser_candidate_paths_includes_common_locations() {
let paths = browser_candidate_paths();
let as_strs: Vec<_> = paths
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
as_strs
.iter()
.any(|s| s.contains("chromium") || s.contains("chrome")),
"candidate list must include common Chrome paths; got {as_strs:?}"
);
}
#[test]
fn browser_check_hint_mentions_apt_get() {
let result = check_system_test_browser();
if result.status == CheckStatus::Warn {
let hint = result.hint.unwrap_or("");
assert!(
hint.contains("apt-get") || hint.contains("AUTUMN_CHROMIUM"),
"hint must mention install command; got: {hint}"
);
}
}
#[test]
fn features_has_key_detects_bare_key() {
let toml = "[features]\nsystem-tests = [\"autumn-web/system-tests\"]\n";
assert!(cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_detects_quoted_key() {
let toml = "[features]\n\"system-tests\" = [\"autumn-web/system-tests\"]\n";
assert!(cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_ignores_dev_dependency_mention() {
let toml = "[dev-dependencies]\nautumn-web = { features = [\"system-tests\"] }\n";
assert!(!cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_no_features_section() {
let toml = "[package]\nname = \"x\"\n";
assert!(!cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn features_has_key_commented_header() {
let toml = "[features] # project features\nsystem-tests = []\n";
assert!(cargo_toml_features_has_key(toml, "system-tests"));
}
#[test]
fn gdpr_check_passes_when_no_auth_starter() {
let r = check_gdpr_export_registration_impl(false, &[]);
assert_eq!(
r.status,
CheckStatus::Pass,
"no auth starter → should always pass: {r:?}"
);
}
#[test]
fn gdpr_check_passes_when_no_auth_starter_even_with_unregistered_tables() {
let r = check_gdpr_export_registration_impl(false, &["posts", "comments"]);
assert_eq!(
r.status,
CheckStatus::Pass,
"no auth starter → must not warn about unregistered tables: {r:?}"
);
}
#[test]
fn gdpr_check_passes_when_all_tables_registered() {
let r = check_gdpr_export_registration_impl(true, &[]);
assert_eq!(
r.status,
CheckStatus::Pass,
"auth starter present + all tables registered → should pass: {r:?}"
);
}
#[test]
fn gdpr_check_warns_when_unregistered_tables_present() {
let r = check_gdpr_export_registration_impl(true, &["posts", "comments"]);
assert_eq!(
r.status,
CheckStatus::Warn,
"unregistered tables with auth starter must warn: {r:?}"
);
}
#[test]
fn gdpr_check_name_is_stable() {
let r = check_gdpr_export_registration_impl(false, &[]);
assert_eq!(r.name, "gdpr_export_registration");
}
#[test]
fn gdpr_check_detail_lists_unregistered_table_names() {
let r = check_gdpr_export_registration_impl(true, &["posts", "comments"]);
let detail = r.detail.as_deref().unwrap_or("");
assert!(
detail.contains("posts"),
"detail must mention unregistered table 'posts': {detail}"
);
assert!(
detail.contains("comments"),
"detail must mention unregistered table 'comments': {detail}"
);
}
#[test]
fn gdpr_check_hint_references_registry_api() {
let r = check_gdpr_export_registration_impl(true, &["orders"]);
let hint = r.hint.unwrap_or("");
assert!(
hint.contains("GdprRegistry") || hint.contains("gdpr"),
"hint must reference the GdprRegistry API: {hint}"
);
}
#[test]
fn gdpr_check_detail_mentions_count_of_unregistered_tables() {
let r = check_gdpr_export_registration_impl(true, &["t1", "t2", "t3"]);
let detail = r.detail.as_deref().unwrap_or("");
assert!(
detail.contains('3') || detail.contains("3 "),
"detail must mention the count of unregistered tables: {detail}"
);
}
#[test]
fn scanner_detects_single_line_hard_delete() {
let src = r#"registry.register(ModelRegistration::hard_delete("posts"));"#;
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("posts"),
"should detect single-line hard_delete: {found:?}"
);
}
#[test]
fn scanner_detects_multiline_retain_call() {
let src = "registry.register(ModelRegistration::retain(\n \"invoices\",\n \"7-year financial hold\",\n));";
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("invoices"),
"should detect multi-line retain registration: {found:?}"
);
}
#[test]
fn scanner_detects_multiline_anonymize_call() {
let src = "registry.register(ModelRegistration::anonymize(\n \"comments\",\n));";
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("comments"),
"should detect multi-line anonymize registration: {found:?}"
);
}
#[test]
fn scanner_ignores_string_before_token() {
let src = r#"let _x = "not_a_table"; ModelRegistration::hard_delete("real_table");"#;
let mut found = std::collections::HashSet::new();
scan_source_for_gdpr_registrations(src, &mut found);
assert!(
found.contains("real_table"),
"should contain real_table: {found:?}"
);
assert!(
!found.contains("not_a_table"),
"should not contain string before token: {found:?}"
);
}
}