#[cfg(feature = "alloc")]
extern crate alloc;
#[cfg(feature = "alloc")]
use alloc::{string::String, vec::Vec};
use crate::error::PqRascvError;
#[cfg(feature = "alloc")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Subject {
pub name: String,
pub digest_sha3_256: String,
}
#[cfg(feature = "alloc")]
impl Subject {
pub fn new(name: impl Into<String>, digest: &[u8; 32]) -> Self {
use core::fmt::Write as _;
let mut hex = String::with_capacity(64);
for byte in digest {
write!(hex, "{byte:02x}").expect("write to String never fails");
}
Self {
name: name.into(),
digest_sha3_256: hex,
}
}
}
#[cfg(feature = "alloc")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct BuildMetadata {
pub builder_id: String,
pub build_config_ref: String,
pub build_started_on: u64,
pub build_finished_on: u64,
pub sbom_hash: [u8; 32],
pub slsa_level: u8,
}
#[cfg(feature = "alloc")]
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct InTotoAttestation {
pub predicate_type: String,
pub subjects: Vec<Subject>,
pub build: BuildMetadata,
}
#[cfg(feature = "alloc")]
impl InTotoAttestation {
#[must_use]
pub fn slsa_level(&self) -> u8 {
self.build.slsa_level
}
}
#[cfg(feature = "alloc")]
pub struct SlsaPredicateBuilder {
builder_id: String,
build_config_ref: String,
started_on: u64,
finished_on: u64,
sbom_hash: [u8; 32],
slsa_level: u8,
subjects: Vec<Subject>,
}
#[cfg(feature = "alloc")]
impl SlsaPredicateBuilder {
pub fn new(builder_id: impl Into<String>) -> Self {
Self {
builder_id: builder_id.into(),
build_config_ref: String::new(),
started_on: 0,
finished_on: 0,
sbom_hash: [0u8; 32],
slsa_level: 1,
subjects: Vec::new(),
}
}
pub fn with_build_config_ref(mut self, r#ref: impl Into<String>) -> Self {
self.build_config_ref = r#ref.into();
self
}
pub fn with_timestamps(mut self, started_on: u64, finished_on: u64) -> Self {
self.started_on = started_on;
self.finished_on = finished_on;
self
}
pub fn with_sbom_hash(mut self, hash: [u8; 32]) -> Self {
self.sbom_hash = hash;
self
}
pub fn with_slsa_level(mut self, level: u8) -> Self {
self.slsa_level = level.clamp(1, 4);
self
}
pub fn add_subject(mut self, name: impl Into<String>, digest: &[u8; 32]) -> Self {
self.subjects.push(Subject::new(name, digest));
self
}
pub fn build(self) -> Result<InTotoAttestation, PqRascvError> {
if self.subjects.is_empty() {
return Err(PqRascvError::InvalidProvenance);
}
Ok(InTotoAttestation {
predicate_type: String::from("https://slsa.dev/provenance/v1"),
subjects: self.subjects,
build: BuildMetadata {
builder_id: self.builder_id,
build_config_ref: self.build_config_ref,
build_started_on: self.started_on,
build_finished_on: self.finished_on,
sbom_hash: self.sbom_hash,
slsa_level: self.slsa_level,
},
})
}
}
#[cfg(all(test, feature = "alloc"))]
mod tests {
use super::*;
#[test]
fn builder_roundtrip() {
let att = SlsaPredicateBuilder::new("https://ci.example.com")
.with_build_config_ref("deadbeef")
.with_timestamps(1_000, 2_000)
.with_slsa_level(2)
.add_subject("fw.bin", &[0xabu8; 32])
.build()
.expect("build failed");
assert_eq!(att.slsa_level(), 2);
assert_eq!(att.subjects.len(), 1);
assert_eq!(att.predicate_type, "https://slsa.dev/provenance/v1");
}
#[test]
fn builder_rejects_empty_subjects() {
let result = SlsaPredicateBuilder::new("https://ci.example.com").build();
assert_eq!(result, Err(PqRascvError::InvalidProvenance));
}
#[test]
fn subject_digest_is_hex_encoded() {
let digest = [0xffu8; 32];
let subject = Subject::new("test", &digest);
assert_eq!(subject.digest_sha3_256.len(), 64);
assert!(subject.digest_sha3_256.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn slsa_level_clamps_to_range() {
let att = SlsaPredicateBuilder::new("x")
.with_slsa_level(99)
.add_subject("fw", &[0u8; 32])
.build()
.unwrap();
assert_eq!(att.slsa_level(), 4);
}
}