use std::collections::BTreeMap;
use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::schema::{PeriodicReport, ReportIntent};
pub const IN_TOTO_STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
pub const PERF_SENTINEL_PREDICATE_TYPE: &str = "https://perf-sentinel.io/attestation/v1";
pub const DEFAULT_SUBJECT_NAME: &str = "perf-sentinel-report.json";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InTotoStatement {
#[serde(rename = "_type")]
pub statement_type: String,
#[serde(rename = "predicateType")]
pub predicate_type: String,
pub subject: Vec<InTotoSubject>,
pub predicate: PerfSentinelPredicate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InTotoSubject {
pub name: String,
pub digest: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerfSentinelPredicate {
pub perf_sentinel_version: String,
pub report_uuid: String,
pub period: PeriodSummary,
pub intent: String,
pub confidentiality_level: String,
pub organisation: OrganisationSummary,
pub methodology_summary: MethodologySummary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeriodSummary {
pub from_date: String,
pub to_date: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrganisationSummary {
pub name: String,
pub country: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub identifiers: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MethodologySummary {
pub sci_specification: String,
pub conformance: String,
pub calibration_applied: bool,
pub period_coverage: f64,
pub core_patterns_count: u32,
pub enabled_patterns_count: u32,
pub disabled_patterns_count: u32,
pub core_patterns_hash: String,
}
#[must_use]
pub fn build_in_toto_statement(
report: &PeriodicReport,
report_file_sha256: &str,
) -> InTotoStatement {
build_in_toto_statement_named(report, report_file_sha256, DEFAULT_SUBJECT_NAME)
}
#[must_use]
pub fn build_in_toto_statement_named(
report: &PeriodicReport,
report_file_sha256: &str,
subject_name: &str,
) -> InTotoStatement {
let mut digest = BTreeMap::new();
digest.insert("sha256".to_string(), report_file_sha256.to_string());
let mut identifiers = BTreeMap::new();
let ids = &report.organisation.identifiers;
if let Some(v) = &ids.siren {
identifiers.insert("siren".to_string(), v.clone());
}
if let Some(v) = &ids.vat {
identifiers.insert("vat".to_string(), v.clone());
}
if let Some(v) = &ids.lei {
identifiers.insert("lei".to_string(), v.clone());
}
if let Some(v) = &ids.opencorporates_url {
identifiers.insert("opencorporates_url".to_string(), v.clone());
}
if let Some(v) = &ids.domain {
identifiers.insert("domain".to_string(), v.clone());
}
InTotoStatement {
statement_type: IN_TOTO_STATEMENT_TYPE.to_string(),
predicate_type: PERF_SENTINEL_PREDICATE_TYPE.to_string(),
subject: vec![InTotoSubject {
name: subject_name.to_string(),
digest,
}],
predicate: PerfSentinelPredicate {
perf_sentinel_version: report.report_metadata.perf_sentinel_version.clone(),
report_uuid: report.report_metadata.report_uuid.to_string(),
period: PeriodSummary {
from_date: report.period.from_date.to_string(),
to_date: report.period.to_date.to_string(),
},
intent: intent_str(report.report_metadata.intent).to_string(),
confidentiality_level: confidentiality_str(
report.report_metadata.confidentiality_level,
)
.to_string(),
organisation: OrganisationSummary {
name: report.organisation.name.clone(),
country: report.organisation.country.clone(),
identifiers,
},
methodology_summary: MethodologySummary {
sci_specification: report.methodology.sci_specification.clone(),
conformance: conformance_str(report.methodology.conformance).to_string(),
calibration_applied: report.methodology.calibration_inputs.calibration_applied,
period_coverage: report.aggregate.period_coverage,
core_patterns_count: u32::try_from(report.methodology.core_patterns_required.len())
.unwrap_or(u32::MAX),
enabled_patterns_count: u32::try_from(report.methodology.enabled_patterns.len())
.unwrap_or(u32::MAX),
disabled_patterns_count: u32::try_from(report.methodology.disabled_patterns.len())
.unwrap_or(u32::MAX),
core_patterns_hash: hash_core_patterns(&report.methodology.core_patterns_required),
},
},
}
}
#[must_use]
pub fn hash_core_patterns(patterns: &[String]) -> String {
let mut sorted: Vec<&str> = patterns.iter().map(String::as_str).collect();
sorted.sort_unstable();
let joined = sorted.join(":");
let digest = Sha256::digest(joined.as_bytes());
let mut out = String::with_capacity(64);
for byte in digest {
let _ = write!(out, "{byte:02x}");
}
out
}
const fn intent_str(intent: ReportIntent) -> &'static str {
match intent {
ReportIntent::Internal => "internal",
ReportIntent::Official => "official",
ReportIntent::Audited => "audited",
}
}
const fn confidentiality_str(c: super::schema::Confidentiality) -> &'static str {
match c {
super::schema::Confidentiality::Internal => "internal",
super::schema::Confidentiality::Public => "public",
}
}
const fn conformance_str(c: super::schema::Conformance) -> &'static str {
match c {
super::schema::Conformance::CoreRequired => "core-required",
super::schema::Conformance::Extended => "extended",
super::schema::Conformance::Partial => "partial",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::periodic::schema::PeriodicReport;
use std::path::PathBuf;
fn load_g2_example() -> PeriodicReport {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let path = PathBuf::from(manifest_dir)
.join("..")
.join("..")
.join("docs/schemas/examples/example-official-public-G2.json");
let raw = std::fs::read_to_string(&path).expect("read G2 example");
serde_json::from_str(&raw).expect("parse G2 example")
}
const DIGEST_64: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
#[test]
fn statement_carries_expected_top_level_fields() {
let r = load_g2_example();
let s = build_in_toto_statement(&r, DIGEST_64);
assert_eq!(s.statement_type, IN_TOTO_STATEMENT_TYPE);
assert_eq!(s.predicate_type, PERF_SENTINEL_PREDICATE_TYPE);
assert_eq!(s.subject.len(), 1);
assert_eq!(s.subject[0].name, DEFAULT_SUBJECT_NAME);
assert_eq!(s.subject[0].digest.get("sha256").unwrap(), DIGEST_64);
}
#[test]
fn predicate_projects_methodology_summary() {
let r = load_g2_example();
let s = build_in_toto_statement(&r, DIGEST_64);
assert_eq!(s.predicate.intent, "official");
assert_eq!(s.predicate.confidentiality_level, "public");
assert_eq!(
s.predicate.methodology_summary.sci_specification,
"ISO/IEC 21031:2024"
);
assert_eq!(s.predicate.methodology_summary.conformance, "core-required");
assert!(
(s.predicate.methodology_summary.period_coverage - r.aggregate.period_coverage).abs()
< f64::EPSILON
);
}
#[test]
fn predicate_pattern_counts_match_g2_methodology() {
let r = load_g2_example();
let s = build_in_toto_statement(&r, DIGEST_64);
let m = &s.predicate.methodology_summary;
assert_eq!(m.core_patterns_count, 4);
assert_eq!(m.enabled_patterns_count, 10);
assert_eq!(m.disabled_patterns_count, 0);
}
#[test]
fn predicate_pattern_counts_reflect_disabled_overrides() {
use crate::report::periodic::schema::DisabledPattern;
let mut r = load_g2_example();
r.methodology.enabled_patterns.truncate(8);
r.methodology.disabled_patterns = vec![
DisabledPattern {
name: "pool_saturation".to_string(),
reason: "noisy on this stack".to_string(),
},
DisabledPattern {
name: "serialized_calls".to_string(),
reason: "false positives in batch jobs".to_string(),
},
];
let s = build_in_toto_statement(&r, DIGEST_64);
let m = &s.predicate.methodology_summary;
assert_eq!(m.enabled_patterns_count, 8);
assert_eq!(m.disabled_patterns_count, 2);
}
#[test]
fn predicate_enabled_count_is_at_least_core_count() {
let r = load_g2_example();
let s = build_in_toto_statement(&r, DIGEST_64);
let m = &s.predicate.methodology_summary;
assert!(m.enabled_patterns_count >= m.core_patterns_count);
}
#[test]
fn predicate_core_patterns_hash_is_64_hex_and_stable() {
let r = load_g2_example();
let s1 = build_in_toto_statement(&r, DIGEST_64);
let s2 = build_in_toto_statement(&r, DIGEST_64);
let h1 = &s1.predicate.methodology_summary.core_patterns_hash;
let h2 = &s2.predicate.methodology_summary.core_patterns_hash;
assert_eq!(h1, h2, "hash must be deterministic");
assert_eq!(h1.len(), 64);
assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn core_patterns_hash_is_invariant_under_input_order() {
let a = hash_core_patterns(&[
"n_plus_one_sql".to_string(),
"n_plus_one_http".to_string(),
"redundant_sql".to_string(),
"redundant_http".to_string(),
]);
let b = hash_core_patterns(&[
"redundant_http".to_string(),
"n_plus_one_http".to_string(),
"redundant_sql".to_string(),
"n_plus_one_sql".to_string(),
]);
assert_eq!(a, b);
}
#[test]
fn core_patterns_hash_changes_on_substitution() {
let baseline = hash_core_patterns(&[
"n_plus_one_sql".to_string(),
"n_plus_one_http".to_string(),
"redundant_sql".to_string(),
"redundant_http".to_string(),
]);
let substituted = hash_core_patterns(&[
"n_plus_one_sql".to_string(),
"n_plus_one_http".to_string(),
"redundant_sql".to_string(),
"slow_sql".to_string(),
]);
assert_ne!(baseline, substituted);
}
#[test]
fn core_patterns_hash_matches_canonical_set_for_g2() {
let r = load_g2_example();
let s = build_in_toto_statement(&r, DIGEST_64);
let canonical =
hash_core_patterns(&crate::report::periodic::schema::core_patterns_required());
assert_eq!(
s.predicate.methodology_summary.core_patterns_hash,
canonical
);
}
#[test]
fn statement_serde_roundtrip() {
let r = load_g2_example();
let s = build_in_toto_statement(&r, DIGEST_64);
let json = serde_json::to_string(&s).unwrap();
let back: InTotoStatement = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
assert!(json.contains("\"_type\""));
assert!(json.contains("\"predicateType\""));
}
#[test]
fn custom_subject_name_overrides_default() {
let r = load_g2_example();
let s = build_in_toto_statement_named(&r, DIGEST_64, "2026-Q1-disclosure.json");
assert_eq!(s.subject[0].name, "2026-Q1-disclosure.json");
}
}