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 = 6;
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 build: BuildConfig,
#[serde(default)]
pub dimensions: Dimensions,
#[serde(skip)]
pub legacy_rows: Vec<RouteEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct BuildConfig {
pub profile: String,
pub features: Vec<String>,
pub all_features: bool,
pub no_default_features: bool,
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
profile: "dev".to_owned(),
features: Vec::new(),
all_features: false,
no_default_features: false,
}
}
}
impl BuildConfig {
#[must_use]
pub fn is_default(&self) -> bool {
*self == Self::default()
}
#[must_use]
pub fn canonical(&self) -> String {
format!(
"profile={}\tfeatures={}\tall-features={}\tno-default-features={}",
escape_field(&self.profile),
escape_list(&self.features),
self.all_features,
self.no_default_features
)
}
#[must_use]
pub fn label(&self) -> String {
let mut parts = vec![format!("{} profile", self.profile)];
if self.no_default_features {
parts.push("no default features".to_owned());
}
if self.all_features {
parts.push("all features".to_owned());
}
if !self.features.is_empty() {
parts.push(format!("features: {}", self.features.join(", ")));
}
if parts.len() == 1 {
parts.push("default features".to_owned());
}
parts.join("; ")
}
}
#[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,
#[serde(default)]
pub provenance: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CsrfDimension {
#[serde(default)]
pub exempt_paths: Vec<String>,
#[serde(default)]
pub entries: Vec<CsrfEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, 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, PartialEq, Eq, 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 is_declared(&self) -> bool {
self.provenance == "declared"
}
#[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 mut 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,
});
}
if manifest.schema_version < 5 {
manifest.legacy_rows = drop_legacy_probe_rows(&mut manifest.dimensions);
}
let d = &manifest.dimensions;
refuse_duplicates(
path,
"routes",
d.routes.entries.iter().map(|e| (e.key(), &e.path)),
)?;
refuse_duplicates(
path,
"csrf",
d.csrf
.entries
.iter()
.map(|e| ((normalize_captures(&e.path), e.method.clone()), &e.path)),
)?;
refuse_duplicates(
path,
"mtls",
d.mtls.entries.iter().map(|e| (e.key(), &e.path)),
)?;
manifest.build.features = crate::routes_audit::normalize_features(&manifest.build.features);
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();
if !self.build.is_default() {
lines.push(format!("build\t{}", self.build.canonical()));
}
for r in self
.dimensions
.routes
.entries
.iter()
.chain(&self.legacy_rows)
{
let roles: Vec<String> = r.role_set().into_iter().collect();
let scopes: Vec<String> = r.scope_set().into_iter().collect();
let declared = if r.is_declared() {
"\tprov=declared"
} else {
""
};
lines.push(format!(
"route\t{}\t{}\t{}\troles={}\tscopes={}\tpolicy={}{declared}",
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())
}
}
fn drop_legacy_probe_rows(d: &mut Dimensions) -> Vec<RouteEntry> {
let owned: BTreeSet<RouteKey> = d
.routes
.entries
.iter()
.filter(|e| e.classification != "framework")
.map(RouteEntry::key)
.collect();
let (dropped, kept) = std::mem::take(&mut d.routes.entries)
.into_iter()
.partition(|e| e.classification == "framework" && owned.contains(&e.key()));
d.routes.entries = kept;
dedup_exact(&mut d.csrf.entries);
dedup_exact(&mut d.mtls.entries);
dropped
}
fn dedup_exact<T: PartialEq>(rows: &mut Vec<T>) {
let mut kept: Vec<T> = Vec::with_capacity(rows.len());
for row in rows.drain(..) {
if !kept.contains(&row) {
kept.push(row);
}
}
*rows = kept;
}
fn refuse_duplicates<'a>(
file: &str,
dimension: &str,
rows: impl Iterator<Item = (RouteKey, &'a String)>,
) -> Result<(), ManifestError> {
let mut seen = BTreeSet::new();
for ((normalized, method), path) in rows {
if !seen.insert((normalized, method.clone())) {
return Err(ManifestError::Malformed {
path: file.to_owned(),
message: format!(
"{dimension} lists {method} {path} more than once, but a route has one entry"
),
});
}
}
Ok(())
}
#[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_without_a_build_reads_as_the_default_build() {
let m = PostureManifest::parse(&manifest(""), "m.json").expect("parses");
assert_eq!(m.build, BuildConfig::default());
assert!(m.build.is_default());
assert_eq!(m.build.profile, "dev");
}
#[test]
fn the_default_build_does_not_move_the_digest() {
let older = PostureManifest::parse(&manifest(""), "m.json").expect("parses");
let v5 = PostureManifest::parse(
&manifest("").replace(
"\"schema_version\": 3,",
r#""schema_version": 5, "build": {"profile": "dev", "features": [],
"all_features": false, "no_default_features": false},"#,
),
"m.json",
)
.expect("parses");
assert_eq!(older.posture_digest(), v5.posture_digest());
}
#[test]
fn a_non_default_build_moves_the_digest() {
let with = |build: &str| {
PostureManifest::parse(
&manifest("").replace(
"\"schema_version\": 3,",
&format!(r#""schema_version": 5, "build": {build},"#),
),
"m.json",
)
.expect("parses")
.posture_digest()
};
let dev = with(r#"{"profile": "dev"}"#);
let release = with(r#"{"profile": "release"}"#);
let featured = with(r#"{"profile": "release", "features": ["embed-assets"]}"#);
assert_ne!(dev, release);
assert_ne!(release, featured);
assert_eq!(
with(r#"{"profile": "release", "features": ["b", "a", "a"]}"#),
with(r#"{"profile": "release", "features": ["a", "b"]}"#)
);
}
#[test]
fn a_duplicate_route_key_is_refused() {
let gated =
r#"{"path":"/a/{id}","method":"GET","classification":"gated","roles":["admin"]}"#;
let renamed = r#"{"path":"/a/{other}","method":"GET","classification":"public"}"#;
let err = PostureManifest::parse(&manifest(&format!("{gated},{renamed}")), "m.json")
.expect_err("a duplicate key must be refused");
assert!(matches!(err, ManifestError::Malformed { .. }), "{err}");
assert!(err.to_string().contains("GET /a/{other}"), "{err}");
let post = r#"{"path":"/a/{id}","method":"POST","classification":"gated"}"#;
assert!(PostureManifest::parse(&manifest(&format!("{gated},{post}")), "m.json").is_ok());
}
#[test]
fn a_pre_v5_probe_row_beside_its_user_route_is_dropped() {
let doc = |version: u32| {
format!(
r#"{{"schema_version":{version},"dimensions":{{
"routes":{{"entries":[
{{"path":"/health","method":"GET","classification":"public"}},
{{"path":"/health","method":"GET","classification":"framework"}}]}},
"mtls":{{"mode":"required","entries":[
{{"path":"/health","method":"GET","mtls_required":true}},
{{"path":"/health","method":"GET","mtls_required":true}}]}}
}}}}"#
)
};
for version in [3, 4] {
let m = PostureManifest::parse(&doc(version), "m.json").expect("legacy baseline reads");
assert_eq!(m.dimensions.routes.entries.len(), 1);
assert_eq!(m.dimensions.routes.entries[0].classification, "public");
assert_eq!(m.dimensions.mtls.entries.len(), 1);
}
assert!(PostureManifest::parse(&doc(5), "m.json").is_err());
}
#[test]
fn a_dropped_legacy_row_stays_in_the_digest() {
let json = r#"{"schema_version":4,"dimensions":{"routes":{"entries":[
{"path":"/health","method":"GET","classification":"public"},
{"path":"/health","method":"GET","classification":"framework"}]}}}"#;
let m = PostureManifest::parse(json, "m.json").expect("legacy baseline reads");
assert_eq!(m.dimensions.routes.entries.len(), 1);
assert_eq!(
m.projection(),
"route\t/health\tGET\tframework\troles=0:\tscopes=0:\tpolicy=false\n\
route\t/health\tGET\tpublic\troles=0:\tscopes=0:\tpolicy=false"
);
}
#[test]
fn other_pre_v5_duplicates_are_still_refused() {
let routes = r#"{"schema_version":4,"dimensions":{"routes":{"entries":[
{"path":"/a","method":"GET","classification":"public"},
{"path":"/a","method":"GET","classification":"gated","roles":["admin"]}]}}}"#;
assert!(PostureManifest::parse(routes, "m.json").is_err());
let mtls = r#"{"schema_version":4,"dimensions":{"mtls":{"mode":"required","entries":[
{"path":"/a","method":"GET","mtls_required":true},
{"path":"/a","method":"GET","mtls_required":false}]}}}"#;
assert!(PostureManifest::parse(mtls, "m.json").is_err());
}
#[test]
fn a_build_label_names_every_flag() {
assert_eq!(
BuildConfig::default().label(),
"dev profile; default features"
);
let build = BuildConfig {
profile: "release".to_owned(),
features: vec!["a".to_owned(), "b".to_owned()],
all_features: true,
no_default_features: true,
};
assert_eq!(
build.label(),
"release profile; no default features; all features; features: a, b"
);
}
#[test]
fn a_duplicate_csrf_or_mtls_row_is_refused() {
let doc = |dimension: &str, first: &str, second: &str| {
format!(
r#"{{"schema_version":5,"dimensions":{{"{dimension}":{{"entries":[{first},{second}]}}}}}}"#
)
};
let csrf = doc(
"csrf",
r#"{"path":"/pay/{id}","method":"POST","csrf_enforced":true}"#,
r#"{"path":"/pay/{other}","method":"POST","csrf_enforced":false}"#,
);
let mtls = doc(
"mtls",
r#"{"path":"/a","method":"GET","mtls_required":true}"#,
r#"{"path":"/a","method":"GET","mtls_required":false}"#,
);
for json in [csrf, mtls] {
let err = PostureManifest::parse(&json, "m.json").expect_err("must be refused");
assert!(matches!(err, ManifestError::Malformed { .. }), "{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)"
);
}
}