use serde::{Deserialize, Serialize};
pub use crate::surface::Surface;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema_gen", derive(schemars::JsonSchema))]
pub struct SurfaceManifest {
pub surface: Surface,
pub surface_event_id: String,
pub command_args: serde_json::Value,
pub output_paths: Vec<String>,
}
fn djb2_u64(data: &str) -> u64 {
let mut hash: u64 = 5381;
for byte in data.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
}
hash
}
fn canonicalize_value(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(map) => {
let mut sorted: std::collections::BTreeMap<String, serde_json::Value> =
std::collections::BTreeMap::new();
for (k, val) in map {
sorted.insert(k.clone(), canonicalize_value(val));
}
let mut out = serde_json::Map::new();
for (k, val) in sorted {
out.insert(k, val);
}
serde_json::Value::Object(out)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(canonicalize_value).collect())
}
other => other.clone(),
}
}
fn canonical_manifest_string(manifest: &SurfaceManifest) -> String {
let surface_token = match manifest.surface {
Surface::Cli => "cli",
Surface::Mcp => "mcp",
Surface::Skill => "skill",
Surface::Plugin => "plugin",
};
let canonical_args = canonicalize_value(&manifest.command_args);
let args_json = serde_json::to_string(&canonical_args).unwrap_or_else(|_| "null".to_string());
let mut sorted_outputs = manifest.output_paths.clone();
sorted_outputs.sort();
let outputs_csv = sorted_outputs.join(",");
format!(
"surface={}|event={}|args={}|outputs={}",
surface_token, manifest.surface_event_id, args_json, outputs_csv
)
}
pub fn compute_surface_audit_hash(manifest: &SurfaceManifest) -> String {
let canonical = canonical_manifest_string(manifest);
let h = djb2_u64(&canonical);
let truncated = (h & 0xFFFF_FFFF) as u32;
format!("djb2:0x{truncated:08x}")
}
pub fn verify_audit_hash(manifest: &SurfaceManifest, expected_hash: &str) -> bool {
compute_surface_audit_hash(manifest) == expected_hash
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_manifest() -> SurfaceManifest {
SurfaceManifest {
surface: Surface::Cli,
surface_event_id: "workflow.audit".to_string(),
command_args: json!({ "ticker": "AAPL", "horizon_years": 5 }),
output_paths: vec!["out/dcf.csv".to_string(), "out/dcf.md".to_string()],
}
}
#[test]
fn ruf_aud_002_djb2_hash_deterministic() {
let m = sample_manifest();
let h1 = compute_surface_audit_hash(&m);
let h2 = compute_surface_audit_hash(&m);
assert_eq!(h1, h2, "djb2 hash must be deterministic");
}
#[test]
fn ruf_aud_inv_002_hash_format() {
let m = sample_manifest();
let h = compute_surface_audit_hash(&m);
let re = regex_lite_format_check(&h);
assert!(re, "expected djb2:0x[0-9a-f]{{8}}, got {h}");
}
fn regex_lite_format_check(s: &str) -> bool {
if s.len() != 15 {
return false;
}
if !s.starts_with("djb2:0x") {
return false;
}
s[7..].chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
}
#[test]
fn ruf_aud_003_surface_hash_content_stable() {
let m1 = SurfaceManifest {
surface: Surface::Mcp,
surface_event_id: "dcf_model".to_string(),
command_args: json!({ "alpha": 1, "beta": 2, "gamma": 3 }),
output_paths: vec!["out/a.csv".to_string(), "out/b.csv".to_string()],
};
let m2 = SurfaceManifest {
surface: Surface::Mcp,
surface_event_id: "dcf_model".to_string(),
command_args: json!({ "gamma": 3, "alpha": 1, "beta": 2 }),
output_paths: vec!["out/b.csv".to_string(), "out/a.csv".to_string()],
};
assert_eq!(
compute_surface_audit_hash(&m1),
compute_surface_audit_hash(&m2),
"hash must be invariant under cosmetic reordering"
);
}
#[test]
fn ruf_aud_003_surface_hash_changes_on_content_change() {
let m1 = sample_manifest();
let mut m2 = sample_manifest();
m2.surface_event_id = "workflow.audit.v2".to_string();
assert_ne!(
compute_surface_audit_hash(&m1),
compute_surface_audit_hash(&m2),
"hash must change when surface_event_id changes"
);
let mut m3 = sample_manifest();
m3.command_args = json!({ "ticker": "MSFT", "horizon_years": 5 });
assert_ne!(
compute_surface_audit_hash(&m1),
compute_surface_audit_hash(&m3),
"hash must change when args change"
);
let mut m4 = sample_manifest();
m4.surface = Surface::Mcp;
assert_ne!(
compute_surface_audit_hash(&m1),
compute_surface_audit_hash(&m4),
"hash must change when surface changes"
);
}
#[test]
fn verify_audit_hash_round_trip() {
let m = sample_manifest();
let h = compute_surface_audit_hash(&m);
assert!(verify_audit_hash(&m, &h));
assert!(!verify_audit_hash(&m, "djb2:0xdeadbeef"));
}
#[test]
fn djb2_byte_identical_to_workflow_djb2() {
fn reference(data: &str) -> u64 {
let mut hash: u64 = 5381;
for byte in data.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
}
hash
}
let inputs = ["", "a", "abc", "the quick brown fox", "djb2 test 123"];
for s in &inputs {
assert_eq!(
djb2_u64(s),
reference(s),
"djb2 implementation drift on input {s:?}"
);
}
}
}