use sha2::{Digest as _, Sha256};
use crate::Error;
use crate::jcs::{self, Member, Value};
use mkit_core::Hash;
pub const IN_TOTO_TYPE: &str = "https://in-toto.io/Statement/v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subject {
pub name: Option<String>,
pub digest_blake3_hex: String,
pub digest_sha256_hex: String,
}
#[must_use]
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(64);
for b in digest {
use core::fmt::Write as _;
write!(out, "{b:02x}").expect("write to String never fails");
}
out
}
#[derive(Debug, Clone)]
pub struct Statement<'a> {
pub subjects: Vec<Subject>,
pub predicate_type: String,
pub predicate_jcs: &'a [u8],
}
pub fn encode(stmt: &Statement<'_>) -> Result<String, Error> {
let pj = stmt.predicate_jcs;
if pj.len() < 2 || pj[0] != b'{' || pj[pj.len() - 1] != b'}' {
return Err(Error::PredicateMustBeJsonObject);
}
let predicate_str = core::str::from_utf8(pj).map_err(|_| Error::PredicateNotUtf8)?;
match serde_json::from_slice::<serde_json::Value>(pj) {
Ok(serde_json::Value::Object(_)) => {}
_ => return Err(Error::PredicateNotJsonObject),
}
let mut out = String::new();
out.push('{');
jcs::write_string(&mut out, "_type");
out.push(':');
jcs::write_string(&mut out, IN_TOTO_TYPE);
out.push(',');
jcs::write_string(&mut out, "predicate");
out.push(':');
out.push_str(predicate_str);
out.push(',');
jcs::write_string(&mut out, "predicateType");
out.push(':');
jcs::write_string(&mut out, &stmt.predicate_type);
out.push(',');
jcs::write_string(&mut out, "subject");
out.push(':');
out.push('[');
for (i, subj) in stmt.subjects.iter().enumerate() {
if i != 0 {
out.push(',');
}
out.push('{');
jcs::write_string(&mut out, "digest");
out.push(':');
let digest_obj = Value::Object(vec![
Member::new("blake3", Value::String(subj.digest_blake3_hex.clone())),
Member::new("sha256", Value::String(subj.digest_sha256_hex.clone())),
]);
out.push_str(&jcs::encode(&digest_obj)?);
if let Some(name) = &subj.name {
out.push(',');
jcs::write_string(&mut out, "name");
out.push(':');
jcs::write_string(&mut out, name);
}
out.push('}');
}
out.push(']');
out.push('}');
Ok(out)
}
pub fn for_commit(
commit: &Hash,
commit_bytes: &[u8],
predicate_type: impl Into<String>,
predicate_jcs: &[u8],
) -> Result<String, Error> {
debug_assert_eq!(
&mkit_core::hash::hash(commit_bytes),
commit,
"for_commit: commit_bytes must hash to commit"
);
let hex = mkit_core::hash::to_hex(commit);
let stmt = Statement {
subjects: vec![Subject {
name: Some("commit".into()),
digest_blake3_hex: hex,
digest_sha256_hex: sha256_hex(commit_bytes),
}],
predicate_type: predicate_type.into(),
predicate_jcs,
};
encode(&stmt)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_subject_empty_predicate() {
let got = encode(&Statement {
subjects: vec![Subject {
name: Some("commit".into()),
digest_blake3_hex: "deadbeef".into(),
digest_sha256_hex: "cafef00d".into(),
}],
predicate_type: "https://example.com/x".into(),
predicate_jcs: b"{}",
})
.unwrap();
assert_eq!(
got,
"{\"_type\":\"https://in-toto.io/Statement/v1\",\
\"predicate\":{},\
\"predicateType\":\"https://example.com/x\",\
\"subject\":[{\"digest\":{\"blake3\":\"deadbeef\",\"sha256\":\"cafef00d\"},\"name\":\"commit\"}]}"
);
}
#[test]
fn subject_without_name_emits_digest_only() {
let got = encode(&Statement {
subjects: vec![Subject {
name: None,
digest_blake3_hex: "abcd".into(),
digest_sha256_hex: "ef01".into(),
}],
predicate_type: "https://example.com/x".into(),
predicate_jcs: b"{}",
})
.unwrap();
assert_eq!(
got,
"{\"_type\":\"https://in-toto.io/Statement/v1\",\
\"predicate\":{},\
\"predicateType\":\"https://example.com/x\",\
\"subject\":[{\"digest\":{\"blake3\":\"abcd\",\"sha256\":\"ef01\"}}]}"
);
}
#[test]
fn sha256_hex_matches_known_answer() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn predicate_body_passes_through_verbatim() {
let predicate = "{\"buildType\":\"https://ex.com/b\",\"stepCount\":7}";
let got = encode(&Statement {
subjects: vec![Subject {
name: Some("commit".into()),
digest_blake3_hex: "00".into(),
digest_sha256_hex: "00".into(),
}],
predicate_type: "https://slsa.dev/provenance/v1".into(),
predicate_jcs: predicate.as_bytes(),
})
.unwrap();
assert!(got.contains(predicate));
}
#[test]
fn rejects_predicate_that_isnt_json_object() {
let mk = |body: &[u8]| {
encode(&Statement {
subjects: vec![Subject {
name: None,
digest_blake3_hex: "00".into(),
digest_sha256_hex: "00".into(),
}],
predicate_type: "x".into(),
predicate_jcs: body,
})
};
assert!(matches!(
mk(b"[1,2,3]"),
Err(Error::PredicateMustBeJsonObject)
));
assert!(matches!(mk(b""), Err(Error::PredicateMustBeJsonObject)));
assert!(matches!(mk(b"{"), Err(Error::PredicateMustBeJsonObject)));
}
#[test]
fn rejects_predicate_with_braces_but_invalid_json() {
let mk = |body: &[u8]| {
encode(&Statement {
subjects: vec![Subject {
name: None,
digest_blake3_hex: "00".into(),
digest_sha256_hex: "00".into(),
}],
predicate_type: "x".into(),
predicate_jcs: body,
})
};
assert!(matches!(
mk(b"{garbage}"),
Err(Error::PredicateNotJsonObject)
));
assert!(matches!(
mk(b"{\"a\":1,}"),
Err(Error::PredicateNotJsonObject)
));
}
#[test]
fn rejects_non_object_top_level() {
let got = encode(&Statement {
subjects: vec![Subject {
name: None,
digest_blake3_hex: "00".into(),
digest_sha256_hex: "00".into(),
}],
predicate_type: "x".into(),
predicate_jcs: b"{42}",
});
assert!(matches!(got, Err(Error::PredicateNotJsonObject)));
}
#[test]
fn accepts_canonical_object_predicate() {
let got = encode(&Statement {
subjects: vec![Subject {
name: None,
digest_blake3_hex: "00".into(),
digest_sha256_hex: "00".into(),
}],
predicate_type: "x".into(),
predicate_jcs: b"{\"k\":\"v\"}",
})
.unwrap();
assert!(got.contains("\"predicate\":{\"k\":\"v\"}"));
}
#[test]
fn for_commit_helper() {
let commit_bytes = b"pretend-serialised-commit-bytes";
let commit = mkit_core::hash::hash(commit_bytes);
let hex = mkit_core::hash::to_hex(&commit);
let got = for_commit(
&commit,
commit_bytes,
"https://example.com/predicate/v1",
b"{\"x\":1}",
)
.unwrap();
assert!(got.contains(&hex));
assert!(got.contains(&sha256_hex(commit_bytes)));
assert!(got.contains("\"x\":1"));
}
#[test]
#[should_panic(expected = "commit_bytes must hash to commit")]
fn for_commit_debug_asserts_matching_bytes() {
let commit: Hash = [0xAB; 32];
let _ = for_commit(&commit, b"wrong bytes", "https://example.com/p", b"{}");
}
}