use serde::{Deserialize, Serialize};
use crate::Sha256Digest;
use crate::transcript::Level;
pub const BUNDLE_FORMAT: u32 = 0;
pub const BUNDLE_SCHEMA: &str = concat!(
"https://ritely.io/schemas/",
env!("CARGO_PKG_VERSION"),
"/bundle.schema.json"
);
pub const INDEX_FILE: &str = "bundle.json";
pub const TRANSCRIPT_FILE: &str = "transcript.jsonl";
pub const DEFINITION_FILE: &str = "definition/ceremony.rite.yaml";
pub const ARTIFACTS_DIR: &str = "artifacts";
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
test,
schemars(
description = "The bundle.json index of an evidence bundle: what kind of bundle it is \
and which files it holds. It repeats no digest: every file other than the transcript is \
bound by a digest the transcript records, so a verifier checks the files against the \
transcript."
)
)]
pub struct BundleIndex {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(
description = "The URL of the JSON Schema this index was written against, for \
editors. A reader ignores it.",
extend("format" = "uri")
)
)]
pub schema: Option<String>,
#[cfg_attr(test, schemars(description = "Version of the bundle format."))]
pub rite_bundle: u32,
#[serde(flatten)]
pub kind: BundleKind,
#[cfg_attr(
test,
schemars(
description = "The fingerprint of the bundle's transcript, for indexing. A \
verifier computes it from the transcript and compares."
)
)]
pub fingerprint: Sha256Digest,
#[cfg_attr(
test,
schemars(description = "Every file in the bundle other than the index.")
)]
pub files: Vec<BundleFile>,
}
impl BundleIndex {
#[must_use]
pub fn complete(fingerprint: Sha256Digest, files: Vec<BundleFile>) -> Self {
Self {
schema: Some(BUNDLE_SCHEMA.to_string()),
rite_bundle: BUNDLE_FORMAT,
kind: BundleKind::Complete,
fingerprint,
files,
}
}
#[must_use]
pub fn disclosure(fingerprint: Sha256Digest, threshold: Level, files: Vec<BundleFile>) -> Self {
Self {
schema: Some(BUNDLE_SCHEMA.to_string()),
rite_bundle: BUNDLE_FORMAT,
kind: BundleKind::Disclosure { threshold },
fingerprint,
files,
}
}
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BundleKind {
#[cfg_attr(
test,
schemars(
description = "The complete record: the transcript with every fact, the ceremony \
definition if it was included, and the artifacts the run kept."
)
)]
Complete,
#[cfg_attr(
test,
schemars(
description = "A disclosure derived from a complete bundle: every line above the \
threshold is withheld, and only the artifacts whose facts are disclosed are included. \
The ceremony definition is never included."
)
)]
Disclosure {
#[cfg_attr(
test,
schemars(
description = "The widest level disclosed: every line at or below it is \
disclosed, every line above it withheld."
)
)]
threshold: Level,
},
}
#[must_use]
pub fn artifact_path(file: &str) -> Option<String> {
crate::is_safe_component(file).then(|| format!("{ARTIFACTS_DIR}/{file}"))
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(test, schemars(description = "One file in the bundle."))]
pub struct BundleFile {
#[cfg_attr(
test,
schemars(
description = "The file's path from the bundle root, with `/` between components."
)
)]
pub path: String,
#[cfg_attr(test, schemars(description = "What the file is."))]
pub role: FileRole,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
test,
schemars(description = "For an artifact, its name as declared in the ceremony.")
)]
pub name: Option<String>,
}
impl BundleFile {
#[must_use]
pub fn transcript() -> Self {
Self {
path: TRANSCRIPT_FILE.to_string(),
role: FileRole::Transcript,
name: None,
}
}
#[must_use]
pub fn definition() -> Self {
Self {
path: DEFINITION_FILE.to_string(),
role: FileRole::Definition,
name: None,
}
}
#[must_use]
pub fn artifact(name: &str, path: &str) -> Self {
Self {
path: path.to_string(),
role: FileRole::Artifact,
name: Some(name.to_string()),
}
}
}
#[non_exhaustive]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(
test,
schemars(description = "What a file is, which also says what binds it to the transcript.")
)]
pub enum FileRole {
#[cfg_attr(
test,
schemars(description = "The transcript, which the fingerprint identifies.")
)]
Transcript,
#[cfg_attr(
test,
schemars(
description = "The ceremony definition, bound by the template digest on \
ceremony_started."
)
)]
Definition,
#[cfg_attr(
test,
schemars(description = "An artifact, bound by the digest on its artifact_written fact.")
)]
Artifact,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn index_wire_shape() {
let index = BundleIndex::complete(
Sha256Digest::of(b"t"),
vec![
BundleFile::transcript(),
BundleFile::definition(),
BundleFile::artifact("root_cert", "artifacts/root_cert.pem"),
],
);
assert_eq!(
serde_json::to_value(&index).expect("serialize"),
json!({
"$schema": BUNDLE_SCHEMA,
"rite_bundle": 0,
"kind": "complete",
"fingerprint": Sha256Digest::of(b"t").as_str(),
"files": [
{ "path": "transcript.jsonl", "role": "transcript" },
{ "path": "definition/ceremony.rite.yaml", "role": "definition" },
{ "path": "artifacts/root_cert.pem", "role": "artifact", "name": "root_cert" },
],
})
);
}
#[test]
fn a_disclosure_writes_its_threshold_beside_its_kind() {
let index = BundleIndex::disclosure(Sha256Digest::of(b"t"), Level::PUBLIC, vec![]);
let value = serde_json::to_value(&index).expect("serialize");
assert_eq!(value.get("kind"), Some(&json!("disclosure")));
assert_eq!(value.get("threshold"), Some(&json!(10)));
let back: BundleIndex = serde_json::from_value(value).expect("deserialize");
assert_eq!(back, index);
}
#[test]
fn a_disclosure_without_a_threshold_does_not_parse() {
let value = json!({
"rite_bundle": 0,
"kind": "disclosure",
"fingerprint": Sha256Digest::of(b"t").as_str(),
"files": [],
});
assert!(serde_json::from_value::<BundleIndex>(value).is_err());
}
#[test]
fn artifact_paths_stay_in_the_artifacts_directory() {
assert_eq!(
artifact_path("cert.pem").as_deref(),
Some("artifacts/cert.pem")
);
assert_eq!(artifact_path("../cert.pem"), None);
assert_eq!(artifact_path("a/b"), None);
}
}