1use serde::{Deserialize, Serialize};
25
26use crate::Sha256Digest;
27use crate::transcript::Level;
28
29pub const BUNDLE_FORMAT: u32 = 0;
32
33pub const BUNDLE_SCHEMA: &str = concat!(
36 "https://ritely.io/schemas/",
37 env!("CARGO_PKG_VERSION"),
38 "/bundle.schema.json"
39);
40
41pub const INDEX_FILE: &str = "bundle.json";
43
44pub const TRANSCRIPT_FILE: &str = "transcript.jsonl";
46
47pub const DEFINITION_FILE: &str = "definition/ceremony.rite.yaml";
49
50pub const ARTIFACTS_DIR: &str = "artifacts";
52
53#[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 #[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 #[cfg_attr(test, schemars(description = "Version of the bundle format."))]
82 pub rite_bundle: u32,
83 #[serde(flatten)]
86 pub kind: BundleKind,
87 #[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 #[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 #[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 #[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#[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 #[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 #[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 #[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#[must_use]
176pub fn artifact_path(file: &str) -> Option<String> {
177 crate::is_safe_component(file).then(|| format!("{ARTIFACTS_DIR}/{file}"))
178}
179
180#[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 #[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 #[cfg_attr(test, schemars(description = "What the file is."))]
196 pub role: FileRole,
197 #[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 #[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 #[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 #[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#[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 #[cfg_attr(
250 test,
251 schemars(description = "The transcript, which the fingerprint identifies.")
252 )]
253 Transcript,
254 #[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 #[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}