use std::collections::BTreeSet;
use std::fmt::Write as _;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::routes_audit::MANIFEST_SCHEMA_VERSION;
pub const MIN_SCHEMA_VERSION: u32 = 3;
pub const MAX_SCHEMA_VERSION: u32 = 4;
const _: () = assert!(
MAX_SCHEMA_VERSION == MANIFEST_SCHEMA_VERSION,
"bump both: this differ reads exactly the schema this CLI emits, and a bump means re-reading `projection`"
);
#[derive(Debug)]
pub enum ManifestError {
Io {
path: String,
source: std::io::Error,
},
Malformed { path: String, message: String },
UnsupportedSchema { path: String, found: u32 },
}
impl std::fmt::Display for ManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => write!(f, "cannot read {path}: {source}"),
Self::Malformed { path, message } => {
write!(f, "{path} is not a security posture manifest: {message}")
}
Self::UnsupportedSchema { path, found } => write!(
f,
"{path} declares manifest schema v{found}, but this CLI understands \
v{MIN_SCHEMA_VERSION}..=v{MAX_SCHEMA_VERSION} — upgrade `autumn` \
(or regenerate the manifest with the CLI this project pins)"
),
}
}
}
impl std::error::Error for ManifestError {}
#[derive(Debug, Clone, Deserialize)]
pub struct PostureManifest {
pub schema_version: u32,
#[serde(default)]
pub dimensions: Dimensions,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Dimensions {
#[serde(default)]
pub routes: RoutesDimension,
#[serde(default)]
pub csrf: CsrfDimension,
#[serde(default)]
pub security_headers: HeadersDimension,
#[serde(default)]
pub authorization_policies: AuthorizationPoliciesDimension,
#[serde(default)]
pub mtls: MtlsDimension,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RoutesDimension {
#[serde(default)]
pub entries: Vec<RouteEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RouteEntry {
pub path: String,
pub method: String,
#[serde(default)]
pub classification: String,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub policy: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CsrfDimension {
#[serde(default)]
pub exempt_paths: Vec<String>,
#[serde(default)]
pub entries: Vec<CsrfEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CsrfEntry {
pub path: String,
pub method: String,
#[serde(default)]
pub csrf_enforced: bool,
#[serde(default)]
pub exempt: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct HeadersDimension {
#[serde(default)]
pub entries: Vec<HeaderEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HeaderEntry {
pub header: String,
#[serde(default)]
pub value: String,
#[serde(default)]
pub emitted: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AuthorizationPoliciesDimension {
#[serde(default)]
pub entries: Vec<AuthorizationPolicyEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthorizationPolicyEntry {
pub path: String,
pub method: String,
pub action: String,
pub resource: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MtlsDimension {
#[serde(default)]
pub mode: String,
#[serde(default)]
pub required_paths: Vec<String>,
#[serde(default)]
pub entries: Vec<MtlsEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MtlsEntry {
pub path: String,
pub method: String,
#[serde(default)]
pub mtls_required: bool,
}
pub type RouteKey = (String, String);
#[must_use]
pub fn normalize_captures(path: &str) -> String {
let mut out = String::with_capacity(path.len());
let mut rest = path;
loop {
let Some(brace) = rest.find(['{', '}']) else {
out.push_str(rest);
return out;
};
out.push_str(&rest[..brace]);
let tail = &rest[brace..];
if let Some(escaped) = tail.strip_prefix("{{") {
out.push('{');
rest = escaped;
continue;
}
if let Some(escaped) = tail.strip_prefix("}}") {
out.push('}');
rest = escaped;
continue;
}
if let Some(stray) = tail.strip_prefix('}') {
out.push('}');
rest = stray;
continue;
}
let Some(close) = tail.find('}') else {
out.push_str(tail);
return out;
};
out.push(if tail[1..close].starts_with('*') {
CATCH_ALL
} else {
CAPTURE
});
rest = &tail[close + 1..];
}
}
pub const CAPTURE: char = '\u{1}';
pub const CATCH_ALL: char = '\u{2}';
impl MtlsDimension {
#[must_use]
pub fn requests_certificate(&self) -> bool {
!(self.mode.is_empty() || self.mode == "off")
}
}
impl MtlsEntry {
#[must_use]
pub fn key(&self) -> RouteKey {
(normalize_captures(&self.path), self.method.clone())
}
}
impl RouteEntry {
#[must_use]
pub fn key(&self) -> RouteKey {
(normalize_captures(&self.path), self.method.clone())
}
#[must_use]
pub fn role_set(&self) -> BTreeSet<String> {
self.roles.iter().cloned().collect()
}
#[must_use]
pub fn scope_set(&self) -> BTreeSet<String> {
self.scopes.iter().cloned().collect()
}
#[must_use]
pub fn posture_label(&self) -> String {
let mut label = if self.classification.is_empty() {
"unclassified".to_owned()
} else {
self.classification.clone()
};
let mut parts = Vec::new();
if !self.roles.is_empty() {
let mut roles: Vec<&str> = self.roles.iter().map(String::as_str).collect();
roles.sort_unstable();
parts.push(format!("roles: {}", roles.join(", ")));
}
if !self.scopes.is_empty() {
let mut scopes: Vec<&str> = self.scopes.iter().map(String::as_str).collect();
scopes.sort_unstable();
parts.push(format!("scopes: {}", scopes.join(", ")));
}
if self.policy {
parts.push("policy".to_owned());
}
if !parts.is_empty() {
let _ = write!(label, " ({})", parts.join("; "));
}
label
}
}
#[must_use]
pub fn is_open(classification: &str) -> bool {
!matches!(classification, "gated" | "framework")
}
impl PostureManifest {
pub fn parse(json: &str, path: &str) -> Result<Self, ManifestError> {
let manifest: Self = serde_json::from_str(json).map_err(|e| ManifestError::Malformed {
path: path.to_owned(),
message: e.to_string(),
})?;
if manifest.schema_version < MIN_SCHEMA_VERSION
|| manifest.schema_version > MAX_SCHEMA_VERSION
{
return Err(ManifestError::UnsupportedSchema {
path: path.to_owned(),
found: manifest.schema_version,
});
}
Ok(manifest)
}
pub fn read(path: &str) -> Result<Self, ManifestError> {
let json = std::fs::read_to_string(path).map_err(|source| ManifestError::Io {
path: path.to_owned(),
source,
})?;
Self::parse(&json, path)
}
#[must_use]
pub fn projection(&self) -> String {
let mut lines: Vec<String> = Vec::new();
for r in &self.dimensions.routes.entries {
let roles: Vec<String> = r.role_set().into_iter().collect();
let scopes: Vec<String> = r.scope_set().into_iter().collect();
lines.push(format!(
"route\t{}\t{}\t{}\troles={}\tscopes={}\tpolicy={}",
escape_field(&normalize_captures(&r.path)),
escape_field(&r.method),
escape_field(&r.classification),
escape_list(&roles),
escape_list(&scopes),
r.policy
));
}
if !self.dimensions.csrf.exempt_paths.is_empty() {
let mut prefixes = self.dimensions.csrf.exempt_paths.clone();
prefixes.sort();
prefixes.dedup();
lines.push(format!("csrf-exempt\t{}", escape_list(&prefixes)));
}
for c in &self.dimensions.csrf.entries {
lines.push(format!(
"csrf\t{}\t{}\tenforced={}",
escape_field(&normalize_captures(&c.path)),
escape_field(&c.method),
c.csrf_enforced
));
}
for h in &self.dimensions.security_headers.entries {
lines.push(format!(
"header\t{}\temitted={}\tvalue={}",
escape_field(&h.header),
h.emitted,
escape_field(&h.value)
));
}
for a in &self.dimensions.authorization_policies.entries {
lines.push(format!(
"authz\t{}\t{}\t{}\t{}",
escape_field(&normalize_captures(&a.path)),
escape_field(&a.method),
escape_field(&a.action),
escape_field(&a.resource)
));
}
if self.dimensions.mtls.requests_certificate() {
lines.push(format!(
"mtls-mode\t{}",
escape_field(&self.dimensions.mtls.mode)
));
if !self.dimensions.mtls.required_paths.is_empty() {
let mut prefixes = self.dimensions.mtls.required_paths.clone();
prefixes.sort();
prefixes.dedup();
lines.push(format!("mtls-required-paths\t{}", escape_list(&prefixes)));
}
}
for m in &self.dimensions.mtls.entries {
if m.mtls_required {
lines.push(format!(
"mtls\t{}\t{}",
escape_field(&normalize_captures(&m.path)),
escape_field(&m.method)
));
}
}
lines.sort();
lines.dedup();
lines.join("\n")
}
#[must_use]
pub fn posture_digest(&self) -> String {
hex_digest(self.projection().as_bytes())
}
}
#[must_use]
pub fn escape_field(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
',' => out.push_str("\\,"),
other => out.push(other),
}
}
out
}
#[must_use]
pub fn escape_list(items: &[String]) -> String {
let joined = items
.iter()
.map(|item| escape_field(item))
.collect::<Vec<_>>()
.join(",");
format!("{}:{joined}", items.len())
}
#[must_use]
pub fn hex_digest(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(routes_json: &str) -> String {
format!(
r#"{{
"schema_version": 3,
"dimensions": {{
"routes": {{ "provenance": "provable", "source": "macro", "entries": [{routes_json}] }},
"csrf": {{ "provenance": "declared", "source": "config", "exempt_paths": [], "entries": [] }},
"security_headers": {{ "provenance": "declared", "source": "config", "entries": [] }},
"authorization_policies": {{ "provenance": "provable", "source": "macro", "runtime_caveat": "x", "entries": [] }}
}},
"excluded": []
}}"#
)
}
#[test]
fn parses_a_v3_manifest_and_ignores_unknown_fields() {
let json = manifest(
r#"{"path":"/admin","method":"GET","name":"admin","classification":"gated",
"roles":["admin"],"scopes":[],"policy":false,"source":"user",
"location":"src/admin.rs:1","provenance":"provable","future_field":42}"#,
);
let m = PostureManifest::parse(&json, "m.json").expect("v3 manifest parses");
assert_eq!(m.schema_version, 3);
assert_eq!(m.dimensions.routes.entries.len(), 1);
assert_eq!(m.dimensions.routes.entries[0].roles, vec!["admin"]);
}
#[test]
fn refuses_a_newer_schema_than_this_cli_understands() {
let json = manifest("").replace("\"schema_version\": 3", "\"schema_version\": 99");
let err = PostureManifest::parse(&json, "m.json").expect_err("v99 must be refused");
assert!(matches!(
err,
ManifestError::UnsupportedSchema { found: 99, .. }
));
assert!(err.to_string().contains("upgrade"), "{err}");
}
#[test]
fn refuses_an_older_schema_than_this_cli_understands() {
let json = manifest("").replace("\"schema_version\": 3", "\"schema_version\": 1");
assert!(matches!(
PostureManifest::parse(&json, "m.json"),
Err(ManifestError::UnsupportedSchema { found: 1, .. })
));
}
#[test]
fn malformed_json_names_the_file() {
let err = PostureManifest::parse("{ not json", "base.json").expect_err("must fail");
assert!(err.to_string().contains("base.json"), "{err}");
}
#[test]
fn a_manifest_missing_dimensions_reads_as_empty() {
let m = PostureManifest::parse(r#"{"schema_version":3}"#, "m.json").expect("parses");
assert!(m.dimensions.routes.entries.is_empty());
assert!(m.dimensions.csrf.entries.is_empty());
}
#[test]
fn renaming_a_capture_does_not_move_the_digest() {
let with = |path: &str| {
PostureManifest::parse(
&manifest(&format!(
r#"{{"path":"{path}","method":"GET","name":"h","classification":"gated",
"roles":["admin"],"scopes":[],"policy":false,"location":"src/a.rs:1"}}"#
)),
"m",
)
.expect("fixture parses")
.posture_digest()
};
assert_eq!(with("/users/{id}"), with("/users/{user_id}"));
assert_ne!(
with("/users/{id}"),
with("/users/{*rest}"),
"a catch-all is a different URL set, not a rename"
);
}
#[test]
fn projection_drops_cosmetic_fields() {
let a = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","name":"old_name","classification":"gated",
"roles":["admin"],"scopes":[],"policy":false,"location":"src/a.rs:10"}"#,
),
"a",
)
.unwrap();
let b = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","name":"new_name","classification":"gated",
"roles":["admin"],"scopes":[],"policy":false,"location":"src/other.rs:400"}"#,
),
"b",
)
.unwrap();
assert_eq!(a.posture_digest(), b.posture_digest());
}
#[test]
fn projection_is_order_insensitive_for_roles_and_entries() {
let a = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":["admin","editor"],"scopes":["read","write"],"policy":false},
{"path":"/b","method":"GET","classification":"public","roles":[],"scopes":[],"policy":false}"#,
),
"a",
)
.unwrap();
let b = PostureManifest::parse(
&manifest(
r#"{"path":"/b","method":"GET","classification":"public","roles":[],"scopes":[],"policy":false},
{"path":"/a","method":"GET","classification":"gated","roles":["editor","admin"],"scopes":["write","read"],"policy":false}"#,
),
"b",
)
.unwrap();
assert_eq!(a.posture_digest(), b.posture_digest());
}
#[test]
fn a_comma_inside_a_role_name_does_not_forge_a_second_role() {
let one_odd_role = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":["a,b"],"scopes":[],"policy":false}"#,
),
"a",
)
.unwrap();
let two_roles = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":["a","b"],"scopes":[],"policy":false}"#,
),
"b",
)
.unwrap();
assert_ne!(one_odd_role.posture_digest(), two_roles.posture_digest());
}
#[test]
fn a_comma_inside_a_scope_name_does_not_forge_a_second_scope() {
let one = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":[],"scopes":["x,y"],"policy":false}"#,
),
"a",
)
.unwrap();
let two = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":[],"scopes":["x","y"],"policy":false}"#,
),
"b",
)
.unwrap();
assert_ne!(one.posture_digest(), two.posture_digest());
}
#[test]
fn an_empty_role_list_is_not_a_list_holding_an_empty_role() {
let none = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":[],"scopes":[],"policy":false}"#,
),
"a",
)
.unwrap();
let one_empty = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":[""],"scopes":[],"policy":false}"#,
),
"b",
)
.unwrap();
assert_ne!(none.posture_digest(), one_empty.posture_digest());
}
#[test]
fn the_list_encoding_distinguishes_every_shape_of_emptiness() {
let empty: Vec<String> = vec![];
let one_empty = vec![String::new()];
let two_empty = vec![String::new(), String::new()];
let one_value = vec!["a".to_owned()];
let encodings = [
escape_list(&empty),
escape_list(&one_empty),
escape_list(&two_empty),
escape_list(&one_value),
escape_list(&["a,b".to_owned()]),
escape_list(&["a".to_owned(), "b".to_owned()]),
];
for (i, a) in encodings.iter().enumerate() {
for (j, b) in encodings.iter().enumerate() {
assert!(
i == j || a != b,
"encodings {i} and {j} collide: {a:?} == {b:?}"
);
}
}
}
#[test]
fn escaping_is_reversible_enough_to_keep_fields_distinct() {
assert_ne!(escape_field("a\tb"), escape_field("a\\tb"));
assert_ne!(escape_field("a,b"), escape_list(&["a".into(), "b".into()]));
assert_ne!(
escape_list(&["a,b".into()]),
escape_list(&["a".into(), "b".into()])
);
}
#[test]
fn a_real_posture_change_moves_the_digest() {
let gated = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"gated","roles":["admin"],"scopes":[],"policy":false}"#,
),
"a",
)
.unwrap();
let public = PostureManifest::parse(
&manifest(
r#"{"path":"/a","method":"GET","classification":"public","roles":[],"scopes":[],"policy":false}"#,
),
"b",
)
.unwrap();
assert_ne!(gated.posture_digest(), public.posture_digest());
}
#[test]
fn open_classifications_are_the_ones_without_a_proven_guard() {
assert!(is_open("public"));
assert!(is_open("unclassified"));
assert!(is_open(""));
assert!(!is_open("gated"));
assert!(!is_open("framework"));
}
#[test]
fn posture_label_reads_like_the_attribute_it_came_from() {
let entry: RouteEntry = serde_json::from_str(
r#"{"path":"/a","method":"GET","classification":"gated","roles":["editor","admin"],"scopes":["posts:write"],"policy":true}"#,
)
.unwrap();
assert_eq!(
entry.posture_label(),
"gated (roles: admin, editor; scopes: posts:write; policy)"
);
}
}