use std::process::Command;
use autumn_web::route_listing::{
CsrfDump, HeadersDump, OMITTED_ROUTES_MARKER, SECURITY_CONFIG_MARKER, SecurityDump,
};
use serde::{Deserialize, Serialize};
use crate::routes;
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
pub struct AuditOptions<'a> {
pub package: Option<&'a str>,
pub bin: Option<&'a str>,
pub manifest: Option<&'a str>,
pub json: bool,
pub strict: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuditRoute {
pub method: String,
pub path: String,
pub handler: String,
#[serde(default)]
pub source: String,
#[serde(default)]
pub classification: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub policy: bool,
#[serde(default)]
pub module: Option<String>,
}
impl AuditRoute {
#[must_use]
pub fn is_unclassified(&self) -> bool {
!matches!(
self.classification.as_str(),
"framework" | "gated" | "public"
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Provenance {
Provable,
Declared,
#[serde(rename = "runtime-only")]
RuntimeOnly,
}
#[derive(Debug, Serialize)]
pub struct Manifest {
pub schema_version: u32,
pub dimensions: Dimensions,
pub excluded: Vec<ExcludedDimension>,
}
#[derive(Debug, Serialize)]
pub struct Dimensions {
pub routes: RoutesDimension,
pub csrf: CsrfDimension,
pub security_headers: HeadersDimension,
}
#[derive(Debug, Serialize)]
pub struct RoutesDimension {
pub provenance: Provenance,
pub source: &'static str,
pub entries: Vec<ManifestRoute>,
}
#[derive(Debug, Serialize)]
pub struct ManifestRoute {
pub path: String,
pub method: String,
pub name: String,
pub classification: String,
pub roles: Vec<String>,
pub scopes: Vec<String>,
pub policy: bool,
pub source: String,
pub provenance: &'static str,
}
#[derive(Debug, Serialize)]
pub struct CsrfDimension {
pub provenance: Provenance,
pub source: &'static str,
pub exempt_paths: Vec<String>,
pub entries: Vec<CsrfEntry>,
}
#[derive(Debug, Serialize)]
pub struct CsrfEntry {
pub path: String,
pub method: String,
pub csrf_enforced: bool,
pub exempt: bool,
}
#[derive(Debug, Serialize)]
pub struct HeadersDimension {
pub provenance: Provenance,
pub source: &'static str,
pub entries: Vec<HeaderEntry>,
}
#[derive(Debug, Serialize)]
pub struct HeaderEntry {
pub header: String,
pub value: String,
pub emitted: bool,
}
#[derive(Debug, Serialize)]
pub struct ExcludedDimension {
pub dimension: &'static str,
pub eventual_provenance: Provenance,
pub reason: &'static str,
}
fn normalize_method(method: &str) -> &str {
if method == "WS" { "GET" } else { method }
}
fn method_is_safe(method: &str, safe_methods: &[String]) -> bool {
let method = normalize_method(method);
safe_methods.iter().any(|m| m == method)
}
fn path_is_exempt(path: &str, exempt_paths: &[String]) -> bool {
exempt_paths.iter().any(|prefix| {
if path == prefix {
true
} else if let Some(stripped) = path.strip_prefix(prefix.as_str()) {
prefix.ends_with('/') || stripped.starts_with('/')
} else {
false
}
})
}
fn build_routes_dimension(routes: &[AuditRoute]) -> RoutesDimension {
let mut entries: Vec<ManifestRoute> = routes
.iter()
.map(|r| ManifestRoute {
path: r.path.clone(),
method: r.method.clone(),
name: r.handler.clone(),
classification: r.classification.clone(),
roles: r.roles.clone(),
scopes: r.scopes.clone(),
policy: r.policy,
source: r.source.clone(),
provenance: "provable",
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
RoutesDimension {
provenance: Provenance::Provable,
source: "macro:#[secured]/#[authorize]/#[public]",
entries,
}
}
fn build_csrf_dimension(routes: &[AuditRoute], csrf: &CsrfDump) -> CsrfDimension {
let mut exempt_paths = csrf.exempt_paths.clone();
exempt_paths.sort();
exempt_paths.dedup();
let mut entries: Vec<CsrfEntry> = routes
.iter()
.filter(|r| !method_is_safe(&r.method, &csrf.safe_methods))
.map(|r| {
let exempt = path_is_exempt(&r.path, &exempt_paths);
CsrfEntry {
path: r.path.clone(),
method: r.method.clone(),
csrf_enforced: csrf.enabled && !exempt,
exempt,
}
})
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
CsrfDimension {
provenance: Provenance::Declared,
source: "config:security.csrf",
exempt_paths,
entries,
}
}
fn build_headers_dimension(h: &HeadersDump) -> HeadersDimension {
let hsts_value = if h.strict_transport_security {
let mut v = format!("max-age={}", h.hsts_max_age_secs);
if h.hsts_include_subdomains {
v.push_str("; includeSubDomains");
}
v
} else {
String::new()
};
let bool_header = |on: bool, val: &str| if on { val.to_owned() } else { String::new() };
let mut entries = vec![
header_entry("content_security_policy", h.content_security_policy.clone()),
header_entry("permissions_policy", h.permissions_policy.clone()),
header_entry("referrer_policy", h.referrer_policy.clone()),
header_entry("strict_transport_security", hsts_value),
header_entry(
"x_content_type_options",
bool_header(h.x_content_type_options, "nosniff"),
),
header_entry("x_frame_options", h.x_frame_options.clone()),
header_entry(
"xss_protection",
bool_header(h.xss_protection, "1; mode=block"),
),
];
entries.sort_by(|a, b| a.header.cmp(&b.header));
HeadersDimension {
provenance: Provenance::Declared,
source: "config:security.headers",
entries,
}
}
fn header_entry(header: &str, value: String) -> HeaderEntry {
use autumn_web::reexports::http::HeaderValue;
let emitted = !value.is_empty() && HeaderValue::from_str(&value).is_ok();
HeaderEntry {
header: header.to_owned(),
emitted,
value,
}
}
fn excluded_dimensions() -> Vec<ExcludedDimension> {
vec![
ExcludedDimension {
dimension: "authorization_policies",
eventual_provenance: Provenance::Provable,
reason: "phase-1 follow-up: full #[authorize] action→policy-type binding needs new \
macro metadata; boolean policy presence already ships in \
routes.entries[].policy",
},
ExcludedDimension {
dimension: "sessions",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "secrets",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "encryption",
eventual_provenance: Provenance::Provable,
reason: "later phase (#[encrypted] fields provable; key config declared)",
},
ExcludedDimension {
dimension: "rate_limit",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "submit_token",
eventual_provenance: Provenance::Declared,
reason: "later phase",
},
ExcludedDimension {
dimension: "outbound_http",
eventual_provenance: Provenance::Declared,
reason: "later phase; named-client base_urls allowlist is config, nothing proves every \
outbound call routes through a named client",
},
ExcludedDimension {
dimension: "policy_registration",
eventual_provenance: Provenance::RuntimeOnly,
reason: "boot-time fact; excluded from a build-time manifest by design",
},
ExcludedDimension {
dimension: "serve_path_routers",
eventual_provenance: Provenance::Provable,
reason: "opt-in serve-path HTTP surfaces (MCP, inbound-mail, storage, SEO) are \
injected only on the serve path, after the dump early-exit, so they are not \
enumerated by `autumn routes` at build time; their mutating POSTs (MCP, \
inbound-mail) run outside the framework CSRF layer and self-protect (MCP: \
origin + csrf_header + trusted-host; inbound-mail: provider signature \
verification). Enumerating them is deferred to a dump-plumbing follow-up",
},
]
}
const fn empty_csrf_dimension() -> CsrfDimension {
CsrfDimension {
provenance: Provenance::Declared,
source: "config:security.csrf",
exempt_paths: Vec::new(),
entries: Vec::new(),
}
}
const fn empty_headers_dimension() -> HeadersDimension {
HeadersDimension {
provenance: Provenance::Declared,
source: "config:security.headers",
entries: Vec::new(),
}
}
#[must_use]
pub fn build_manifest(routes: &[AuditRoute], security: Option<&SecurityDump>) -> Manifest {
let routes_dim = build_routes_dimension(routes);
let (csrf, security_headers) = security.map_or_else(
|| (empty_csrf_dimension(), empty_headers_dimension()),
|s| {
(
build_csrf_dimension(routes, &s.csrf),
build_headers_dimension(&s.headers),
)
},
);
Manifest {
schema_version: MANIFEST_SCHEMA_VERSION,
dimensions: Dimensions {
routes: routes_dim,
csrf,
security_headers,
},
excluded: excluded_dimensions(),
}
}
#[must_use]
pub fn parse_security_config(stderr: &str) -> Option<SecurityDump> {
stderr
.lines()
.filter_map(|line| line.strip_prefix(SECURITY_CONFIG_MARKER))
.filter_map(|json| serde_json::from_str::<SecurityDump>(json.trim()).ok())
.next_back()
}
#[must_use]
pub fn manifest_json(manifest: &Manifest) -> String {
serde_json::to_string_pretty(manifest).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
}
#[must_use]
pub fn unclassified_routes(routes: &[AuditRoute]) -> Vec<&AuditRoute> {
routes.iter().filter(|r| r.is_unclassified()).collect()
}
#[must_use]
pub fn audit_exit_code(routes: &[AuditRoute]) -> i32 {
i32::from(routes.iter().any(AuditRoute::is_unclassified))
}
#[must_use]
pub fn parse_omitted_count(stderr: &str) -> usize {
let marker = OMITTED_ROUTES_MARKER.trim();
stderr
.lines()
.filter_map(|line| line.trim().strip_prefix(marker))
.filter_map(|rest| rest.trim().parse::<usize>().ok())
.next_back()
.unwrap_or(0)
}
#[must_use]
pub fn format_omitted_diagnostic(count: usize) -> String {
format!(
"\u{2717} {count} raw router(s) added via `AppBuilder::merge()`/`nest()` \
are not enumerable and were omitted from the route listing.\n\
Route auth coverage can't be proven while these exist. Mount routes via \
`routes![]` (or a plugin's `declare_plugin_routes`) so they are visible \
and classifiable.\n"
)
}
#[must_use]
pub fn format_diagnostic(unresolved: &[&AuditRoute]) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"\u{2717} {} route(s) have no proven auth posture:",
unresolved.len()
);
for r in unresolved {
let module = r
.module
.as_deref()
.map(|m| format!(" [{m}]"))
.unwrap_or_default();
let _ = writeln!(
out,
" {method:<6} {path} (handler `{handler}`{module})",
method = r.method,
path = r.path,
handler = r.handler,
);
}
out.push_str(
"\nAdd a guard (`#[secured]` / `#[authorize]`) or mark the route \
deliberately open with `#[public]`.\n",
);
out
}
#[must_use]
pub fn format_summary(routes: &[AuditRoute]) -> String {
let mut framework = 0usize;
let mut gated = 0usize;
let mut public = 0usize;
let mut unclassified = 0usize;
for r in routes {
match r.classification.as_str() {
"framework" => framework += 1,
"gated" => gated += 1,
"public" => public += 1,
_ => unclassified += 1,
}
}
format!(
"{total} route(s): {gated} gated, {public} public, {framework} framework, \
{unclassified} unclassified",
total = routes.len(),
)
}
pub fn run(opts: &AuditOptions<'_>) {
eprintln!("\u{1F342} autumn routes audit\n");
routes::compile_binary(opts.package, opts.bin);
let binary = routes::find_binary(opts.package, opts.bin);
let output = Command::new(&binary)
.env("AUTUMN_DUMP_ROUTES", "1")
.env("AUTUMN_DUMP_SECURITY", "1")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to run {}: {e}", binary.display());
std::process::exit(1);
});
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if !line.starts_with(SECURITY_CONFIG_MARKER) {
eprintln!("{line}");
}
}
let omitted = parse_omitted_count(&stderr);
let security = parse_security_config(&stderr);
if !output.status.success() {
eprintln!(
"\u{2717} Binary exited with status {} while dumping routes",
output.status
);
std::process::exit(output.status.code().unwrap_or(1));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let routes: Vec<AuditRoute> = serde_json::from_str(&stdout).unwrap_or_else(|e| {
eprintln!("\u{2717} Failed to parse route listing JSON: {e}");
eprintln!("Raw output: {stdout}");
std::process::exit(1);
});
let manifest = build_manifest(&routes, security.as_ref());
let json = manifest_json(&manifest);
if let Some(path) = opts.manifest {
if let Err(e) = std::fs::write(path, format!("{json}\n")) {
eprintln!("\u{2717} Failed to write manifest to {path}: {e}");
std::process::exit(1);
}
eprintln!("\u{2713} Wrote security manifest \u{2192} {path}");
}
if opts.json {
println!("{json}");
} else {
println!("{}", format_summary(&routes));
}
let unresolved = unclassified_routes(&routes);
if unresolved.is_empty() {
if !opts.json && omitted == 0 {
eprintln!("\u{2713} All routes are classified.");
}
} else {
eprint!("\n{}", format_diagnostic(&unresolved));
if opts.strict {
eprintln!("(strict mode)");
}
}
if omitted > 0 {
eprint!("\n{}", format_omitted_diagnostic(omitted));
}
let failed = omitted > 0 || audit_exit_code(&routes) != 0;
std::process::exit(i32::from(failed));
}
#[cfg(test)]
mod tests {
use super::*;
fn route(method: &str, path: &str, handler: &str, classification: &str) -> AuditRoute {
AuditRoute {
method: method.to_owned(),
path: path.to_owned(),
handler: handler.to_owned(),
source: "user".to_owned(),
classification: classification.to_owned(),
roles: vec![],
scopes: vec![],
policy: false,
module: None,
}
}
#[test]
fn is_unclassified_true_for_unclassified_and_unknown() {
assert!(route("POST", "/w", "create", "unclassified").is_unclassified());
assert!(route("POST", "/w", "create", "").is_unclassified());
assert!(route("POST", "/w", "create", "bogus").is_unclassified());
}
#[test]
fn is_unclassified_false_for_known_postures() {
assert!(!route("GET", "/w", "list", "gated").is_unclassified());
assert!(!route("GET", "/w", "list", "public").is_unclassified());
assert!(!route("GET", "/health", "health", "framework").is_unclassified());
}
#[test]
fn exit_code_is_nonzero_iff_any_route_unclassified() {
let clean = vec![
route("GET", "/a", "a", "public"),
route("GET", "/b", "b", "gated"),
route("GET", "/health", "health", "framework"),
];
assert_eq!(audit_exit_code(&clean), 0);
let mut dirty = clean;
dirty.push(route("POST", "/widgets", "create_widget", "unclassified"));
assert_eq!(audit_exit_code(&dirty), 1);
}
#[test]
fn unclassified_routes_returns_only_the_offenders() {
let routes = vec![
route("GET", "/a", "a", "public"),
route("POST", "/widgets", "create_widget", "unclassified"),
route("DELETE", "/things/{id}", "delete_thing", ""),
];
let unresolved = unclassified_routes(&routes);
assert_eq!(unresolved.len(), 2);
assert!(unresolved.iter().any(|r| r.handler == "create_widget"));
assert!(unresolved.iter().any(|r| r.handler == "delete_thing"));
}
#[test]
fn diagnostic_names_the_offending_route() {
let mut r = route("POST", "/widgets", "create_widget", "unclassified");
r.module = Some("myapp::widgets".to_owned());
let unresolved = vec![&r];
let msg = format_diagnostic(&unresolved);
assert!(msg.contains("POST"), "{msg}");
assert!(msg.contains("/widgets"), "{msg}");
assert!(msg.contains("create_widget"), "{msg}");
assert!(msg.contains("myapp::widgets"), "{msg}");
assert!(msg.contains("#[public]"), "{msg}");
}
#[test]
fn manifest_is_schema_shaped_and_provable() {
let routes = vec![route("GET", "/a", "a", "gated")];
let manifest = build_manifest(&routes, None);
assert_eq!(manifest.schema_version, MANIFEST_SCHEMA_VERSION);
assert_eq!(manifest.dimensions.routes.entries.len(), 1);
assert_eq!(manifest.dimensions.routes.entries[0].provenance, "provable");
let json: serde_json::Value = serde_json::from_str(&manifest_json(&manifest)).unwrap();
assert_eq!(json["schema_version"], 2);
let entry = &json["dimensions"]["routes"]["entries"][0];
for key in [
"path",
"method",
"name",
"classification",
"roles",
"scopes",
"policy",
"source",
"provenance",
] {
assert!(entry.get(key).is_some(), "manifest route missing `{key}`");
}
}
#[test]
fn manifest_routes_are_stable_ordered_by_path_then_method() {
let routes = vec![
route("POST", "/posts", "create", "gated"),
route("GET", "/posts", "list", "public"),
route("GET", "/about", "about", "public"),
];
let manifest = build_manifest(&routes, None);
let order: Vec<(&str, &str)> = manifest
.dimensions
.routes
.entries
.iter()
.map(|r| (r.path.as_str(), r.method.as_str()))
.collect();
assert_eq!(
order,
vec![("/about", "GET"), ("/posts", "GET"), ("/posts", "POST")]
);
}
#[test]
fn manifest_carries_roles_scopes_policy() {
let mut r = route("POST", "/admin", "admin_action", "gated");
r.roles = vec!["admin".to_owned()];
r.scopes = vec!["posts:write".to_owned()];
r.policy = true;
let manifest = build_manifest(&[r], None);
let entry = &manifest.dimensions.routes.entries[0];
assert_eq!(entry.roles, vec!["admin"]);
assert_eq!(entry.scopes, vec!["posts:write"]);
assert!(entry.policy);
}
fn security_dump(enabled: bool, exempt_paths: &[&str]) -> SecurityDump {
SecurityDump {
csrf: CsrfDump {
enabled,
safe_methods: vec![
"GET".to_owned(),
"HEAD".to_owned(),
"OPTIONS".to_owned(),
"TRACE".to_owned(),
],
exempt_paths: exempt_paths.iter().map(|s| (*s).to_owned()).collect(),
},
headers: HeadersDump {
x_frame_options: "DENY".to_owned(),
x_content_type_options: true,
xss_protection: true,
content_security_policy: "default-src 'self'".to_owned(),
referrer_policy: "strict-origin-when-cross-origin".to_owned(),
permissions_policy: String::new(),
strict_transport_security: false,
hsts_max_age_secs: 31_536_000,
hsts_include_subdomains: true,
csp_nonce: false,
},
}
}
fn manifest_value(m: &Manifest) -> serde_json::Value {
serde_json::from_str(&manifest_json(m)).unwrap()
}
#[test]
fn manifest_v2_envelope_shape_and_provenance_labels() {
let routes = vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", "gated"),
];
let sec = security_dump(true, &[]);
let json = manifest_value(&build_manifest(&routes, Some(&sec)));
assert_eq!(json["schema_version"], 2);
assert_eq!(json["dimensions"]["routes"]["provenance"], "provable");
assert_eq!(
json["dimensions"]["routes"]["source"],
"macro:#[secured]/#[authorize]/#[public]"
);
assert_eq!(json["dimensions"]["csrf"]["provenance"], "declared");
assert_eq!(json["dimensions"]["csrf"]["source"], "config:security.csrf");
assert_eq!(
json["dimensions"]["security_headers"]["provenance"],
"declared"
);
assert_eq!(
json["dimensions"]["security_headers"]["source"],
"config:security.headers"
);
let excluded = json["excluded"].as_array().expect("excluded array");
assert_eq!(excluded.len(), 9);
assert_eq!(excluded[0]["dimension"], "authorization_policies");
assert_eq!(excluded[0]["eventual_provenance"], "provable");
let runtime_only = excluded
.iter()
.find(|e| e["dimension"] == "policy_registration")
.expect("policy_registration excluded");
assert_eq!(runtime_only["eventual_provenance"], "runtime-only");
let serve_path = excluded
.iter()
.find(|e| e["dimension"] == "serve_path_routers")
.expect("serve_path_routers excluded");
assert_eq!(serve_path["eventual_provenance"], "provable");
}
#[test]
fn csrf_dimension_only_covers_mutating_routes() {
let routes = vec![
route("GET", "/widgets", "list_widgets", "public"),
route("POST", "/widgets", "create_widget", "gated"),
route("DELETE", "/widgets/{id}", "delete_widget", "gated"),
];
let sec = security_dump(true, &[]);
let manifest = build_manifest(&routes, Some(&sec));
let paths: Vec<(&str, &str)> = manifest
.dimensions
.csrf
.entries
.iter()
.map(|e| (e.path.as_str(), e.method.as_str()))
.collect();
assert_eq!(
paths,
vec![("/widgets", "POST"), ("/widgets/{id}", "DELETE")],
"only mutating routes, ordered by (path, method)"
);
assert!(
manifest
.dimensions
.csrf
.entries
.iter()
.all(|e| e.csrf_enforced)
);
}
#[test]
fn csrf_dimension_covers_mutating_framework_routes() {
let routes = vec![
route("GET", "/actuator/loggers", "loggers_get", "framework"),
route(
"PUT",
"/actuator/loggers/{name}",
"loggers_put",
"framework",
),
];
let dim = build_csrf_dimension(&routes, &security_dump(true, &[]).csrf);
let entry = dim
.entries
.iter()
.find(|e| e.method == "PUT" && e.path == "/actuator/loggers/{name}")
.expect("mutating framework route must appear in csrf.entries");
assert!(
entry.csrf_enforced && !entry.exempt,
"PUT loggers must be CSRF-enforced: {entry:?}"
);
assert!(
!dim.entries.iter().any(|e| e.method == "GET"),
"safe-method routes must not appear: {:?}",
dim.entries
);
}
#[test]
fn disabling_csrf_flips_only_csrf_entries() {
let routes = vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", "gated"),
];
let on = manifest_value(&build_manifest(&routes, Some(&security_dump(true, &[]))));
let off = manifest_value(&build_manifest(&routes, Some(&security_dump(false, &[]))));
assert_eq!(on["dimensions"]["routes"], off["dimensions"]["routes"]);
assert_eq!(
on["dimensions"]["security_headers"],
off["dimensions"]["security_headers"]
);
assert_eq!(
on["dimensions"]["csrf"]["entries"][0]["csrf_enforced"],
true
);
assert_eq!(
off["dimensions"]["csrf"]["entries"][0]["csrf_enforced"],
false
);
}
#[test]
fn adding_exempt_path_flips_only_affected_csrf_entries() {
let routes = vec![
route("POST", "/api/widgets", "api_create", "gated"),
route("POST", "/widgets", "create_widget", "gated"),
];
let base = manifest_value(&build_manifest(&routes, Some(&security_dump(true, &[]))));
let exempt = manifest_value(&build_manifest(
&routes,
Some(&security_dump(true, &["/api/"])),
));
assert_eq!(base["dimensions"]["routes"], exempt["dimensions"]["routes"]);
assert_eq!(
base["dimensions"]["security_headers"],
exempt["dimensions"]["security_headers"]
);
let e = &exempt["dimensions"]["csrf"]["entries"];
assert_eq!(e[0]["path"], "/api/widgets");
assert_eq!(e[0]["exempt"], true);
assert_eq!(e[0]["csrf_enforced"], false);
assert_eq!(e[1]["path"], "/widgets");
assert_eq!(e[1]["exempt"], false);
assert_eq!(e[1]["csrf_enforced"], true);
assert_eq!(exempt["dimensions"]["csrf"]["exempt_paths"][0], "/api/");
}
#[test]
fn weakening_a_header_changes_only_that_entry() {
let routes = vec![route("POST", "/widgets", "create_widget", "gated")];
let base_sec = security_dump(true, &[]);
let base = manifest_value(&build_manifest(&routes, Some(&base_sec)));
let mut weak = security_dump(true, &[]);
weak.headers.x_frame_options = "SAMEORIGIN".to_owned();
let weak = manifest_value(&build_manifest(&routes, Some(&weak)));
assert_eq!(base["dimensions"]["routes"], weak["dimensions"]["routes"]);
assert_eq!(base["dimensions"]["csrf"], weak["dimensions"]["csrf"]);
let find = |v: &serde_json::Value, key: &str| -> serde_json::Value {
v["dimensions"]["security_headers"]["entries"]
.as_array()
.unwrap()
.iter()
.find(|e| e["header"] == key)
.unwrap()
.clone()
};
assert_eq!(find(&base, "x_frame_options")["value"], "DENY");
assert_eq!(find(&weak, "x_frame_options")["value"], "SAMEORIGIN");
for key in ["content_security_policy", "referrer_policy"] {
assert_eq!(
find(&base, key),
find(&weak, key),
"{key} must be untouched"
);
}
let mut no_csp = security_dump(true, &[]);
no_csp.headers.content_security_policy = String::new();
let no_csp = manifest_value(&build_manifest(&routes, Some(&no_csp)));
assert_eq!(find(&base, "content_security_policy")["emitted"], true);
assert_eq!(find(&no_csp, "content_security_policy")["emitted"], false);
assert_eq!(find(&no_csp, "content_security_policy")["value"], "");
}
#[test]
fn ws_routes_are_csrf_safe_when_get_is_a_safe_method() {
let routes = vec![
route("WS", "/live", "live_socket", "public"),
route("POST", "/widgets", "create_widget", "gated"),
];
let manifest = build_manifest(&routes, Some(&security_dump(true, &[])));
let entries: Vec<(&str, &str)> = manifest
.dimensions
.csrf
.entries
.iter()
.map(|e| (e.path.as_str(), e.method.as_str()))
.collect();
assert_eq!(
entries,
vec![("/widgets", "POST")],
"WS routes mount as GET; with GET safe they must not be listed as mutating"
);
assert!(
!manifest
.dimensions
.csrf
.entries
.iter()
.any(|e| e.method == "WS"),
"no WS entry may appear in the csrf dimension when GET is safe"
);
}
#[test]
fn ws_routes_are_csrf_enforced_when_get_is_not_a_safe_method() {
let routes = vec![
route("WS", "/live", "live_socket", "public"),
route("WS", "/api/live", "api_socket", "public"),
route("POST", "/widgets", "create_widget", "gated"),
];
let mut sec = security_dump(true, &["/api/"]);
sec.csrf.safe_methods = vec!["HEAD".to_owned(), "OPTIONS".to_owned()];
let manifest = build_manifest(&routes, Some(&sec));
let entries: Vec<(&str, &str, bool, bool)> = manifest
.dimensions
.csrf
.entries
.iter()
.map(|e| {
(
e.path.as_str(),
e.method.as_str(),
e.csrf_enforced,
e.exempt,
)
})
.collect();
assert_eq!(
entries,
vec![
("/api/live", "WS", false, true),
("/live", "WS", true, false),
("/widgets", "POST", true, false),
],
"WS routes appear as GET-mutating entries with the exempt-path predicate applied, \
while keeping their listed `WS` method for display"
);
}
#[test]
fn invalid_header_value_is_not_emitted() {
let routes = vec![route("POST", "/widgets", "create_widget", "gated")];
let mut sec = security_dump(true, &[]);
sec.headers.referrer_policy = "no-referrer\r\ninjected: 1".to_owned();
let json = manifest_value(&build_manifest(&routes, Some(&sec)));
let entry = json["dimensions"]["security_headers"]["entries"]
.as_array()
.unwrap()
.iter()
.find(|e| e["header"] == "referrer_policy")
.unwrap()
.clone();
assert_eq!(
entry["emitted"], false,
"an invalid (unparseable) header value must not be reported as emitted"
);
}
#[test]
fn csp_nonce_is_not_reported_as_an_emitted_header() {
let routes = vec![route("GET", "/page", "page", "public")];
let mut sec = security_dump(true, &[]);
sec.headers.csp_nonce = true;
sec.headers.content_security_policy =
"style-src 'self' 'nonce-AUTUMN_CSP_NONCE'; script-src 'self' 'nonce-AUTUMN_CSP_NONCE'"
.to_owned();
let json = manifest_value(&build_manifest(&routes, Some(&sec)));
let entries = json["dimensions"]["security_headers"]["entries"]
.as_array()
.unwrap();
assert!(
!entries.iter().any(|e| e["header"] == "csp_nonce"),
"csp_nonce is not a response header and must not appear in security_headers"
);
let csp = entries
.iter()
.find(|e| e["header"] == "content_security_policy")
.expect("content_security_policy entry present");
assert_eq!(csp["emitted"], true);
assert!(
csp["value"]
.as_str()
.unwrap()
.contains("'nonce-AUTUMN_CSP_NONCE'"),
"the nonce-aware CSP template must remain visible in the CSP value: {csp}"
);
}
#[test]
fn webhook_exempt_path_marks_route_unenforced() {
let routes = vec![route("POST", "/webhooks/stripe", "stripe_hook", "public")];
let sec = security_dump(true, &["/webhooks/stripe"]);
let manifest = build_manifest(&routes, Some(&sec));
let entry = &manifest.dimensions.csrf.entries[0];
assert_eq!(entry.path, "/webhooks/stripe");
assert!(entry.exempt, "webhook path must be exempt");
assert!(
!entry.csrf_enforced,
"runtime skips CSRF on webhook endpoints, so csrf_enforced must be false"
);
assert!(
manifest
.dimensions
.csrf
.exempt_paths
.iter()
.any(|p| p == "/webhooks/stripe"),
"dimension exempt_paths must record the webhook path"
);
}
#[test]
fn unsubscribe_exempt_path_marks_post_unenforced() {
let routes = vec![route(
"POST",
"/_autumn/unsubscribe",
"unsubscribe",
"framework",
)];
let sec = security_dump(true, &["/_autumn/unsubscribe"]);
let manifest = build_manifest(&routes, Some(&sec));
let entry = &manifest.dimensions.csrf.entries[0];
assert_eq!(entry.path, "/_autumn/unsubscribe");
assert_eq!(entry.method, "POST");
assert!(entry.exempt, "mounted unsubscribe path must be exempt");
assert!(
!entry.csrf_enforced,
"runtime exempts the unsubscribe endpoint from CSRF, so csrf_enforced must be false"
);
assert!(
manifest
.dimensions
.csrf
.exempt_paths
.iter()
.any(|p| p == "/_autumn/unsubscribe"),
"dimension exempt_paths must record the unsubscribe path"
);
}
#[test]
fn manifest_rebuild_is_byte_identical() {
let routes = vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", "gated"),
route("DELETE", "/widgets/{id}", "delete_widget", "gated"),
];
let sec = security_dump(true, &["/api/", "/webhooks/"]);
let a = manifest_json(&build_manifest(&routes, Some(&sec)));
let b = manifest_json(&build_manifest(&routes, Some(&sec)));
assert_eq!(a, b, "manifest must be reproducible byte-for-byte");
}
#[test]
fn parse_security_config_reads_the_marker() {
let sec = security_dump(true, &["/api/"]);
let line = serde_json::to_string(&sec).unwrap();
let stderr = format!(
"\u{1F342} autumn routes audit\n{SECURITY_CONFIG_MARKER}{line}\nsome trailing warning\n"
);
let parsed = parse_security_config(&stderr).expect("marker parsed");
assert!(parsed.csrf.enabled);
assert_eq!(parsed.csrf.exempt_paths, vec!["/api/"]);
assert!(parse_security_config("no marker here\n").is_none());
}
#[test]
fn audit_gate_red_then_green() {
let dump = |cls: &str| {
vec![
route("GET", "/health", "health", "framework"),
route("POST", "/widgets", "create_widget", cls),
]
};
let red = dump("unclassified");
assert_eq!(audit_exit_code(&red), 1);
let unresolved = unclassified_routes(&red);
assert_eq!(unresolved.len(), 1);
assert!(format_diagnostic(&unresolved).contains("create_widget"));
assert_eq!(audit_exit_code(&dump("gated")), 0);
assert_eq!(audit_exit_code(&dump("public")), 0);
}
#[test]
fn parse_omitted_count_reads_the_marker() {
let stderr = format!(
"\u{1F342} autumn routes\n\
[autumn routes] warning: 2 raw router(s) added via .merge()/.nest() ...\n\
{OMITTED_ROUTES_MARKER}2\n"
);
assert_eq!(parse_omitted_count(&stderr), 2);
}
#[test]
fn parse_omitted_count_zero_when_no_marker() {
assert_eq!(parse_omitted_count(""), 0);
assert_eq!(parse_omitted_count("some unrelated warning\n"), 0);
}
#[test]
fn omitted_routes_fail_the_gate_even_when_visible_routes_are_clean() {
let visible = vec![
route("GET", "/health", "health", "framework"),
route("GET", "/posts", "list", "public"),
];
assert_eq!(audit_exit_code(&visible), 0);
let stderr = format!("{OMITTED_ROUTES_MARKER}1\n");
let omitted = parse_omitted_count(&stderr);
assert_eq!(omitted, 1);
let failed = omitted > 0 || audit_exit_code(&visible) != 0;
assert!(failed, "omitted routes must hard-fail the audit gate");
let diag = format_omitted_diagnostic(omitted);
assert!(diag.contains("merge()"), "{diag}");
assert!(diag.contains("routes!["), "{diag}");
}
#[test]
fn declared_nest_emits_no_marker_and_passes_the_gate() {
let visible = vec![
route("GET", "/health", "health", "framework"),
route("GET", "/admin", "admin::index", "gated"),
route("POST", "/admin/users", "admin::create", "gated"),
];
assert_eq!(audit_exit_code(&visible), 0);
let stderr = "\u{1F342} autumn routes\n";
let omitted = parse_omitted_count(stderr);
assert_eq!(
omitted, 0,
"a declared-covered nest must not emit the marker"
);
let failed = omitted > 0 || audit_exit_code(&visible) != 0;
assert!(
!failed,
"a declared-covered admin nest must not false-fail the audit gate"
);
}
}