use serde::Serialize;
use serde_json::{Map, Value};
pub const CANDIDATE_CONTENT_ENCODING_V1: &str = "pgroles.io/candidate-content/v1";
pub fn compute_content_digest<T: Serialize>(content: &T) -> String {
sha256_prefixed(&canonical_content_bytes(content))
}
pub fn canonical_content_bytes<T: Serialize>(content: &T) -> Vec<u8> {
let value = serde_json::to_value(content).expect("policy content is serializable");
let mut envelope = Map::new();
envelope.insert(
"encoding".to_string(),
Value::String(CANDIDATE_CONTENT_ENCODING_V1.to_string()),
);
envelope.insert("content".to_string(), canonicalize(value));
serde_json::to_vec(&Value::Object(envelope)).expect("canonical content is serializable")
}
fn canonicalize(value: Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted: std::collections::BTreeMap<String, Value> = Default::default();
for (key, entry) in map {
let entry = canonicalize(entry);
if is_empty(&entry) {
continue;
}
sorted.insert(key, entry);
}
Value::Object(sorted.into_iter().collect())
}
Value::Array(items) => Value::Array(
items
.into_iter()
.map(canonicalize)
.collect(),
),
other => other,
}
}
fn is_empty(value: &Value) -> bool {
match value {
Value::Null => true,
Value::Object(map) => map.is_empty(),
Value::Array(items) => items.is_empty(),
_ => false,
}
}
fn sha256_prefixed(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write;
let digest = Sha256::digest(bytes);
let mut hash = String::with_capacity(7 + digest.len() * 2);
hash.push_str("sha256:");
for byte in digest {
write!(&mut hash, "{byte:02x}").expect("writing to a String cannot fail");
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
fn json(text: &str) -> Value {
serde_json::from_str(text).expect("fixture is valid JSON")
}
#[test]
fn canonical_bytes_are_pinned() {
let content = json(
r#"{
"roles": [{"name": "reporting-reader", "login": true}],
"reconciliation_mode": "authoritative",
"grants": [],
"default_owner": null
}"#,
);
assert_eq!(
String::from_utf8(canonical_content_bytes(&content)).unwrap(),
concat!(
r#"{"content":{"reconciliation_mode":"authoritative","#,
r#""roles":[{"login":true,"name":"reporting-reader"}]},"#,
r#""encoding":"pgroles.io/candidate-content/v1"}"#,
)
);
assert_eq!(
compute_content_digest(&content),
"sha256:5ef20a282cdaa20ea621f17cd5b83b61d789e27deacb72c3ccce7b2678743eba"
);
}
#[test]
fn key_order_does_not_change_the_digest() {
assert_eq!(
compute_content_digest(&json(r#"{"a": 1, "b": 2}"#)),
compute_content_digest(&json(r#"{"b": 2, "a": 1}"#)),
);
}
#[test]
fn omitted_and_empty_collections_are_the_same_content() {
assert_eq!(
compute_content_digest(&json(r#"{"roles": [{"name": "a"}]}"#)),
compute_content_digest(&json(
r#"{"roles": [{"name": "a"}], "grants": [], "profiles": {}}"#
)),
);
}
#[test]
fn explicit_null_and_omitted_are_the_same_content() {
assert_eq!(
compute_content_digest(&json(r#"{"roles": [{"name": "a"}]}"#)),
compute_content_digest(&json(
r#"{"roles": [{"name": "a"}], "default_owner": null}"#
)),
);
}
#[test]
fn an_omitted_field_differs_from_its_written_default() {
assert_ne!(
compute_content_digest(&json(r#"{"schemas": [{"name": "app"}]}"#)),
compute_content_digest(&json(
r#"{"schemas": [{"name": "app", "role_pattern": "{schema}-{profile}"}]}"#
)),
);
}
#[test]
fn list_order_is_significant() {
assert_ne!(
compute_content_digest(&json(r#"{"roles": [{"name": "a"}, {"name": "b"}]}"#)),
compute_content_digest(&json(r#"{"roles": [{"name": "b"}, {"name": "a"}]}"#)),
);
}
}