use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::errors::{Result, SigstoreError};
const IN_TOTO_STATEMENT_V1_TYPE: &str = "https://in-toto.io/Statement/v1";
const COSIGN_SIGN_V1_PREDICATE_TYPE: &str = "https://sigstore.dev/cosign/sign/v1";
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct InTotoStatementV1 {
#[serde(rename = "_type")]
pub statement_type: String,
pub subject: Vec<Subject>,
#[serde(rename = "predicateType")]
pub predicate_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub predicate: Option<serde_json::Value>,
}
impl InTotoStatementV1 {
pub fn from_json(bytes: &[u8]) -> Result<Self> {
serde_json::from_slice(bytes).map_err(|e| {
SigstoreError::UnexpectedError(format!("cannot parse in-toto statement: {e}"))
})
}
pub fn validate_cosign_v1(&self) -> Result<()> {
if self.statement_type != IN_TOTO_STATEMENT_V1_TYPE {
return Err(SigstoreError::UnexpectedError(format!(
"unsupported in-toto _type: expected {IN_TOTO_STATEMENT_V1_TYPE}, got {}",
self.statement_type
)));
}
if self.predicate_type != COSIGN_SIGN_V1_PREDICATE_TYPE {
return Err(SigstoreError::UnexpectedError(format!(
"unsupported in-toto predicateType: expected {COSIGN_SIGN_V1_PREDICATE_TYPE}, got {}",
self.predicate_type
)));
}
if self.subject.is_empty() {
return Err(SigstoreError::UnexpectedError(
"in-toto statement has no subjects".to_string(),
));
}
if self.subject.len() > 1 {
warn!(
subject_count = self.subject.len(),
"in-toto statement has multiple subjects; only the first will be used"
);
}
if !self.subject[0].digest.contains_key("sha256") {
return Err(SigstoreError::UnexpectedError(
"in-toto statement subject[0] has no sha256 digest".to_string(),
));
}
Ok(())
}
pub fn subject_sha256_digest(&self) -> Result<String> {
self.subject
.first()
.and_then(|s| s.digest.get("sha256").cloned())
.ok_or_else(|| {
SigstoreError::UnexpectedError(
"in-toto statement has no subject with a sha256 digest".to_string(),
)
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct Subject {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub digest: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
const REAL_BUNDLE_V03: &str = include_str!("../../tests/data/bundle_v03.json");
#[test]
fn decode_in_toto_statement_from_real_bundle() {
use base64::{Engine as _, engine::general_purpose::STANDARD as base64};
let bundle: serde_json::Value =
serde_json::from_str(REAL_BUNDLE_V03).expect("fixture must be valid JSON");
let payload_b64 = bundle["dsseEnvelope"]["payload"]
.as_str()
.expect("dsseEnvelope.payload must be a string");
let payload_bytes = base64.decode(payload_b64).expect("payload must be base64");
let statement =
InTotoStatementV1::from_json(&payload_bytes).expect("must parse as in-toto statement");
assert_eq!(
statement.subject_sha256_digest().unwrap(),
"c811d58de79c92f03214e63aa339484e488d694ae8a6283b5f3f17a9faf50172"
);
assert_eq!(
statement.predicate_type,
"https://sigstore.dev/cosign/sign/v1"
);
statement
.validate_cosign_v1()
.expect("real fixture should satisfy cosign v1 statement constraints");
}
#[rstest]
#[case::subject_missing_sha256_digest(
InTotoStatementV1 {
statement_type: "https://in-toto.io/Statement/v1".to_string(),
subject: vec![Subject {
name: Some("artifact".to_string()),
digest: BTreeMap::new(), // no sha256 key
annotations: None,
}],
predicate_type: "https://sigstore.dev/cosign/sign/v1".to_string(),
predicate: None,
}
)]
#[case::empty_subject_list(
InTotoStatementV1 {
statement_type: "https://in-toto.io/Statement/v1".to_string(),
subject: vec![],
predicate_type: "https://sigstore.dev/cosign/sign/v1".to_string(),
predicate: None,
}
)]
fn subject_sha256_digest_returns_err_for_missing_digest(#[case] statement: InTotoStatementV1) {
assert!(statement.subject_sha256_digest().is_err());
}
#[test]
fn subject_sha256_digest_returns_err_for_invalid_json() {
let result = serde_json::from_slice::<InTotoStatementV1>(b"not valid json");
assert!(result.is_err());
}
#[rstest]
#[case::valid(
"https://in-toto.io/Statement/v1",
"https://sigstore.dev/cosign/sign/v1",
true
)]
#[case::wrong_statement_type(
"https://example.com/Statement/v1",
"https://sigstore.dev/cosign/sign/v1",
false
)]
#[case::wrong_predicate_type(
"https://in-toto.io/Statement/v1",
"https://example.com/predicate/v1",
false
)]
fn validate_cosign_v1_type_enforcement(
#[case] statement_type: &str,
#[case] predicate_type: &str,
#[case] expected_ok: bool,
) {
let statement = InTotoStatementV1 {
statement_type: statement_type.to_string(),
subject: vec![Subject {
name: Some("artifact".to_string()),
digest: BTreeMap::from([("sha256".to_string(), "abc".to_string())]),
annotations: None,
}],
predicate_type: predicate_type.to_string(),
predicate: None,
};
assert_eq!(statement.validate_cosign_v1().is_ok(), expected_ok);
}
#[rstest]
#[case::empty_subjects(
InTotoStatementV1 {
statement_type: "https://in-toto.io/Statement/v1".to_string(),
subject: vec![],
predicate_type: "https://sigstore.dev/cosign/sign/v1".to_string(),
predicate: None,
}
)]
#[case::subject_without_sha256(
InTotoStatementV1 {
statement_type: "https://in-toto.io/Statement/v1".to_string(),
subject: vec![Subject {
name: Some("artifact".to_string()),
digest: BTreeMap::from([("sha512".to_string(), "abc".to_string())]),
annotations: None,
}],
predicate_type: "https://sigstore.dev/cosign/sign/v1".to_string(),
predicate: None,
}
)]
fn validate_cosign_v1_rejects_invalid_subject(#[case] statement: InTotoStatementV1) {
assert!(statement.validate_cosign_v1().is_err());
}
#[rstest]
#[case::multiple_subjects(
InTotoStatementV1 {
statement_type: "https://in-toto.io/Statement/v1".to_string(),
subject: vec![
Subject {
name: Some("artifact-a".to_string()),
digest: BTreeMap::from([("sha256".to_string(), "aaa".to_string())]),
annotations: None,
},
Subject {
name: Some("artifact-b".to_string()),
digest: BTreeMap::from([("sha256".to_string(), "bbb".to_string())]),
annotations: None,
},
],
predicate_type: "https://sigstore.dev/cosign/sign/v1".to_string(),
predicate: None,
}
)]
#[case::extra_digest_algorithms(
InTotoStatementV1 {
statement_type: "https://in-toto.io/Statement/v1".to_string(),
subject: vec![Subject {
name: Some("artifact".to_string()),
digest: BTreeMap::from([
("sha256".to_string(), "abc".to_string()),
("sha512".to_string(), "def".to_string()),
]),
annotations: None,
}],
predicate_type: "https://sigstore.dev/cosign/sign/v1".to_string(),
predicate: None,
}
)]
fn validate_cosign_v1_accepts_tolerated_cases(#[case] statement: InTotoStatementV1) {
assert!(statement.validate_cosign_v1().is_ok());
}
}