Skip to main content

rite_model/
bundle.rs

1//! The evidence bundle: a ceremony run packaged with its definition.
2//!
3//! A bundle is a directory with a fixed layout:
4//!
5//! ```text
6//! bundle.json                      this index
7//! transcript.jsonl                 the transcript, byte for byte as the run wrote it
8//! definition/ceremony.rite.yaml    the ceremony YAML the run was resolved from
9//! artifacts/<file>                 artifacts the transcript records with a digest
10//! ```
11//!
12//! The index locates files and says what each one is. It repeats no digest:
13//! every file other than the transcript is bound by a digest the transcript
14//! commits to (the template digest on `CeremonyStarted`, the digest on each
15//! `ArtifactWritten`), so a verifier checks files against the transcript and
16//! the index adds nothing that would need protecting or redacting on its own.
17//! The trust anchor is the transcript fingerprint, never the index.
18//!
19//! A disclosure bundle has the same layout. Its transcript withholds every
20//! line above the index's threshold and folds to the same fingerprint as the
21//! complete one; it carries only the artifacts whose facts it discloses, and
22//! never the definition, which can name people.
23
24use serde::{Deserialize, Serialize};
25
26use crate::Sha256Digest;
27use crate::transcript::Level;
28
29/// Bundle format version this crate writes and reads. 0 while rite is before
30/// 1.0, like [`TRANSCRIPT_FORMAT`](crate::TRANSCRIPT_FORMAT).
31pub const BUNDLE_FORMAT: u32 = 0;
32
33/// URL of the JSON Schema for the index this release writes, which the index
34/// names as its `$schema` so editors validate it.
35pub const BUNDLE_SCHEMA: &str = concat!(
36    "https://ritely.io/schemas/",
37    env!("CARGO_PKG_VERSION"),
38    "/bundle.schema.json"
39);
40
41/// Name of the index file at the bundle root.
42pub const INDEX_FILE: &str = "bundle.json";
43
44/// Path of the transcript within a bundle.
45pub const TRANSCRIPT_FILE: &str = "transcript.jsonl";
46
47/// Path of the ceremony definition within a bundle.
48pub const DEFINITION_FILE: &str = "definition/ceremony.rite.yaml";
49
50/// Directory holding artifacts within a bundle.
51pub const ARTIFACTS_DIR: &str = "artifacts";
52
53/// The index at the root of a bundle.
54#[non_exhaustive]
55#[cfg_attr(test, derive(schemars::JsonSchema))]
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[cfg_attr(
58    test,
59    schemars(
60        description = "The bundle.json index of an evidence bundle: what kind of bundle it is \
61        and which files it holds. It repeats no digest: every file other than the transcript is \
62        bound by a digest the transcript records, so a verifier checks the files against the \
63        transcript."
64    )
65)]
66pub struct BundleIndex {
67    /// URL of the JSON Schema the index was written against
68    /// ([`BUNDLE_SCHEMA`]). Editors read it; a reader ignores it.
69    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
70    #[cfg_attr(
71        test,
72        schemars(
73            description = "The URL of the JSON Schema this index was written against, for \
74            editors. A reader ignores it.",
75            extend("format" = "uri")
76        )
77    )]
78    pub schema: Option<String>,
79    /// Bundle format version ([`BUNDLE_FORMAT`]). A reader refuses a value it
80    /// does not know.
81    #[cfg_attr(test, schemars(description = "Version of the bundle format."))]
82    pub rite_bundle: u32,
83    /// Whether this is the complete record or a disclosure derived from it,
84    /// written as `kind`, with a disclosure's `threshold` beside it.
85    #[serde(flatten)]
86    pub kind: BundleKind,
87    /// Fingerprint of the transcript in the bundle. A convenience for
88    /// indexing; a verifier recomputes it from the transcript.
89    #[cfg_attr(
90        test,
91        schemars(
92            description = "The fingerprint of the bundle's transcript, for indexing. A \
93            verifier computes it from the transcript and compares."
94        )
95    )]
96    pub fingerprint: Sha256Digest,
97    /// Every file in the bundle other than the index, in a stable order.
98    #[cfg_attr(
99        test,
100        schemars(description = "Every file in the bundle other than the index.")
101    )]
102    pub files: Vec<BundleFile>,
103}
104
105impl BundleIndex {
106    /// An index for a complete bundle.
107    #[must_use]
108    pub fn complete(fingerprint: Sha256Digest, files: Vec<BundleFile>) -> Self {
109        Self {
110            schema: Some(BUNDLE_SCHEMA.to_string()),
111            rite_bundle: BUNDLE_FORMAT,
112            kind: BundleKind::Complete,
113            fingerprint,
114            files,
115        }
116    }
117
118    /// An index for a disclosure: every line above `threshold` is withheld.
119    #[must_use]
120    pub fn disclosure(fingerprint: Sha256Digest, threshold: Level, files: Vec<BundleFile>) -> Self {
121        Self {
122            schema: Some(BUNDLE_SCHEMA.to_string()),
123            rite_bundle: BUNDLE_FORMAT,
124            kind: BundleKind::Disclosure { threshold },
125            fingerprint,
126            files,
127        }
128    }
129}
130
131/// What a bundle holds relative to the run.
132#[non_exhaustive]
133#[cfg_attr(test, derive(schemars::JsonSchema))]
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(tag = "kind", rename_all = "snake_case")]
136pub enum BundleKind {
137    /// The complete transcript, with every fact.
138    #[cfg_attr(
139        test,
140        schemars(
141            description = "The complete record: the transcript with every fact, the ceremony \
142            definition if it was included, and the artifacts the run kept."
143        )
144    )]
145    Complete,
146    /// A transcript with lines withheld, derived from a complete one.
147    #[cfg_attr(
148        test,
149        schemars(
150            description = "A disclosure derived from a complete bundle: every line above the \
151            threshold is withheld, and only the artifacts whose facts are disclosed are included. \
152            The ceremony definition is never included."
153        )
154    )]
155    Disclosure {
156        /// Every line at or below the threshold is disclosed, every line
157        /// above it withheld.
158        #[cfg_attr(
159            test,
160            schemars(
161                description = "The widest level disclosed: every line at or below it is \
162                disclosed, every line above it withheld."
163            )
164        )]
165        threshold: Level,
166    },
167}
168
169/// Where an artifact recorded under `file` is stored, relative to the bundle
170/// or run directory: `artifacts/<file>`.
171///
172/// The name comes from a transcript, which is untrusted input: a name that is
173/// not a single safe path component yields `None`, so a crafted transcript
174/// cannot point a reader at files outside the artifacts directory.
175#[must_use]
176pub fn artifact_path(file: &str) -> Option<String> {
177    crate::is_safe_component(file).then(|| format!("{ARTIFACTS_DIR}/{file}"))
178}
179
180/// One file in a bundle.
181#[non_exhaustive]
182#[cfg_attr(test, derive(schemars::JsonSchema))]
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[cfg_attr(test, schemars(description = "One file in the bundle."))]
185pub struct BundleFile {
186    /// Path relative to the bundle root, `/`-separated.
187    #[cfg_attr(
188        test,
189        schemars(
190            description = "The file's path from the bundle root, with `/` between components."
191        )
192    )]
193    pub path: String,
194    /// What the file is, which also says what binds it.
195    #[cfg_attr(test, schemars(description = "What the file is."))]
196    pub role: FileRole,
197    /// The artifact's name as declared in the ceremony, for an artifact.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    #[cfg_attr(
200        test,
201        schemars(description = "For an artifact, its name as declared in the ceremony.")
202    )]
203    pub name: Option<String>,
204}
205
206impl BundleFile {
207    /// The transcript.
208    #[must_use]
209    pub fn transcript() -> Self {
210        Self {
211            path: TRANSCRIPT_FILE.to_string(),
212            role: FileRole::Transcript,
213            name: None,
214        }
215    }
216
217    /// The ceremony definition.
218    #[must_use]
219    pub fn definition() -> Self {
220        Self {
221            path: DEFINITION_FILE.to_string(),
222            role: FileRole::Definition,
223            name: None,
224        }
225    }
226
227    /// An artifact, stored at its [`artifact_path`].
228    #[must_use]
229    pub fn artifact(name: &str, path: &str) -> Self {
230        Self {
231            path: path.to_string(),
232            role: FileRole::Artifact,
233            name: Some(name.to_string()),
234        }
235    }
236}
237
238/// What a bundle file is.
239#[non_exhaustive]
240#[cfg_attr(test, derive(schemars::JsonSchema))]
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243#[cfg_attr(
244    test,
245    schemars(description = "What a file is, which also says what binds it to the transcript.")
246)]
247pub enum FileRole {
248    /// The transcript: the record the fingerprint identifies.
249    #[cfg_attr(
250        test,
251        schemars(description = "The transcript, which the fingerprint identifies.")
252    )]
253    Transcript,
254    /// The ceremony YAML, bound by the template digest on `CeremonyStarted`.
255    #[cfg_attr(
256        test,
257        schemars(
258            description = "The ceremony definition, bound by the template digest on \
259            ceremony_started."
260        )
261    )]
262    Definition,
263    /// An artifact, bound by the digest on its `ArtifactWritten`.
264    #[cfg_attr(
265        test,
266        schemars(description = "An artifact, bound by the digest on its artifact_written fact.")
267    )]
268    Artifact,
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use serde_json::json;
275
276    #[test]
277    fn index_wire_shape() {
278        let index = BundleIndex::complete(
279            Sha256Digest::of(b"t"),
280            vec![
281                BundleFile::transcript(),
282                BundleFile::definition(),
283                BundleFile::artifact("root_cert", "artifacts/root_cert.pem"),
284            ],
285        );
286        assert_eq!(
287            serde_json::to_value(&index).expect("serialize"),
288            json!({
289                "$schema": BUNDLE_SCHEMA,
290                "rite_bundle": 0,
291                "kind": "complete",
292                "fingerprint": Sha256Digest::of(b"t").as_str(),
293                "files": [
294                    { "path": "transcript.jsonl", "role": "transcript" },
295                    { "path": "definition/ceremony.rite.yaml", "role": "definition" },
296                    { "path": "artifacts/root_cert.pem", "role": "artifact", "name": "root_cert" },
297                ],
298            })
299        );
300    }
301
302    #[test]
303    fn a_disclosure_writes_its_threshold_beside_its_kind() {
304        let index = BundleIndex::disclosure(Sha256Digest::of(b"t"), Level::PUBLIC, vec![]);
305        let value = serde_json::to_value(&index).expect("serialize");
306        assert_eq!(value.get("kind"), Some(&json!("disclosure")));
307        assert_eq!(value.get("threshold"), Some(&json!(10)));
308        let back: BundleIndex = serde_json::from_value(value).expect("deserialize");
309        assert_eq!(back, index);
310    }
311
312    #[test]
313    fn a_disclosure_without_a_threshold_does_not_parse() {
314        let value = json!({
315            "rite_bundle": 0,
316            "kind": "disclosure",
317            "fingerprint": Sha256Digest::of(b"t").as_str(),
318            "files": [],
319        });
320        assert!(serde_json::from_value::<BundleIndex>(value).is_err());
321    }
322
323    #[test]
324    fn artifact_paths_stay_in_the_artifacts_directory() {
325        assert_eq!(
326            artifact_path("cert.pem").as_deref(),
327            Some("artifacts/cert.pem")
328        );
329        assert_eq!(artifact_path("../cert.pem"), None);
330        assert_eq!(artifact_path("a/b"), None);
331    }
332}