1use std::ffi::OsStr;
4use std::fs::{self, File, OpenOptions};
5use std::io::{ErrorKind, Write};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use runx_contracts::{RECEIPT_SCHEMA, Receipt};
10use runx_receipts::{
11 ReceiptProofContextProvider, content_addressed_receipt_id, verify_receipt_proof,
12};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use super::paths::{
17 ReceiptStoreLabel, ReceiptStorePublicProjection, safe_receipt_store_projection,
18};
19use super::seal::{RuntimeReceiptProofContextProvider, RuntimeReceiptSignaturePolicy};
20
21const RECEIPT_STORE_INDEX_SCHEMA: &str = "runx.receipt_store_index.v1";
22const INDEX_FILE_NAME: &str = "index.json";
23const EFFECT_STATE_FILE_NAME: &str = "effect-state.json";
24const SHA256_RECEIPT_ID_PREFIX: &str = "sha256:";
25const SHA256_RECEIPT_FILE_PREFIX: &str = "sha256-";
26
27#[derive(Clone, Debug)]
28pub struct LocalReceiptStore {
29 root: PathBuf,
30}
31
32impl LocalReceiptStore {
33 #[must_use]
34 pub fn new(root: impl Into<PathBuf>) -> Self {
35 Self { root: root.into() }
36 }
37
38 #[must_use]
39 pub fn root(&self) -> &Path {
40 &self.root
41 }
42
43 #[must_use]
44 pub fn public_projection(
45 &self,
46 workspace_base: &Path,
47 project_runx_dir: &Path,
48 ) -> ReceiptStorePublicProjection {
49 safe_receipt_store_projection(&self.root, workspace_base, project_runx_dir)
50 }
51
52 pub fn receipt_path(&self, receipt_id: &str) -> Result<PathBuf, ReceiptStoreError> {
53 Ok(self.root.join(receipt_file_name(receipt_id)?))
54 }
55
56 pub fn read_exact(&self, receipt_id: &str) -> Result<Receipt, ReceiptStoreError> {
57 self.read_exact_with_policy(
58 receipt_id,
59 RuntimeReceiptSignaturePolicy::local_development(),
60 )
61 }
62
63 pub fn read_exact_with_policy(
64 &self,
65 receipt_id: &str,
66 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
67 ) -> Result<Receipt, ReceiptStoreError> {
68 let file_path = self.receipt_path(receipt_id)?;
69 self.ensure_store_dir()?;
70 read_receipt_file(&file_path, receipt_id, signature_policy)
71 }
72
73 pub fn write_receipt(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError> {
74 self.write_receipt_with_policy(receipt, RuntimeReceiptSignaturePolicy::local_development())
75 }
76
77 pub fn write_receipt_with_policy(
78 &self,
79 receipt: &Receipt,
80 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
81 ) -> Result<(), ReceiptStoreError> {
82 let file_name = receipt_file_name(&receipt.id)?;
83 self.ensure_or_create_store_dir()?;
84 let file_path = self.root.join(&file_name);
85 let contents =
86 serde_json::to_vec(receipt).map_err(|source| ReceiptStoreError::MalformedReceipt {
87 path: file_path.clone(),
88 message: source.to_string(),
89 })?;
90
91 if file_path.exists() {
92 let existing =
93 fs::read(&file_path).map_err(|source| ReceiptStoreError::ReceiptUnreadable {
94 path: file_path.clone(),
95 source,
96 })?;
97 if existing == contents {
98 verify_stored_receipt_proof(&file_path, receipt, signature_policy)?;
99 return Ok(());
100 }
101 return Err(ReceiptStoreError::ReceiptAlreadyExists {
102 receipt_id: receipt.id.to_string(),
103 });
104 }
105
106 verify_stored_receipt_proof(&file_path, receipt, signature_policy)?;
107 write_atomic(&self.root, &file_name, &contents)?;
108 self.update_index_after_write(receipt, signature_policy)
109 }
110
111 pub fn list(&self) -> Result<Vec<Receipt>, ReceiptStoreError> {
112 self.list_with_policy(RuntimeReceiptSignaturePolicy::local_development())
113 }
114
115 pub fn list_with_policy(
116 &self,
117 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
118 ) -> Result<Vec<Receipt>, ReceiptStoreError> {
119 self.ensure_store_dir()?;
120 let mut receipts = Vec::new();
121 for entry in
122 fs::read_dir(&self.root).map_err(|source| ReceiptStoreError::StoreUnreadable {
123 path: self.root.clone(),
124 source,
125 })?
126 {
127 let entry = entry.map_err(|source| ReceiptStoreError::StoreUnreadable {
128 path: self.root.clone(),
129 source,
130 })?;
131 let path = entry.path();
132 if !is_receipt_json_path(&path) {
133 continue;
134 }
135 let Some(receipt_id) = path
136 .file_stem()
137 .and_then(OsStr::to_str)
138 .and_then(receipt_id_from_file_stem)
139 else {
140 continue;
141 };
142 receipts.push(read_receipt_file(&path, &receipt_id, signature_policy)?);
143 }
144 receipts.sort_by(|left, right| left.id.cmp(&right.id));
145 Ok(receipts)
146 }
147
148 pub(crate) fn list_without_proof_for_history(&self) -> Result<Vec<Receipt>, ReceiptStoreError> {
149 self.ensure_store_dir()?;
150 let mut receipts = Vec::new();
151 for entry in
152 fs::read_dir(&self.root).map_err(|source| ReceiptStoreError::StoreUnreadable {
153 path: self.root.clone(),
154 source,
155 })?
156 {
157 let entry = entry.map_err(|source| ReceiptStoreError::StoreUnreadable {
158 path: self.root.clone(),
159 source,
160 })?;
161 let path = entry.path();
162 if !is_receipt_json_path(&path) {
163 continue;
164 }
165 let Some(receipt_id) = path
166 .file_stem()
167 .and_then(OsStr::to_str)
168 .and_then(receipt_id_from_file_stem)
169 else {
170 continue;
171 };
172 receipts.push(read_receipt_file_without_proof(&path, &receipt_id)?);
173 }
174 receipts.sort_by(|left, right| left.id.cmp(&right.id));
175 Ok(receipts)
176 }
177
178 pub fn load_index(&self) -> Result<ReceiptStoreIndex, ReceiptStoreError> {
179 self.load_index_with_policy(RuntimeReceiptSignaturePolicy::local_development())
180 }
181
182 pub fn load_index_with_policy(
183 &self,
184 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
185 ) -> Result<ReceiptStoreIndex, ReceiptStoreError> {
186 self.ensure_store_dir()?;
187 let index_path = self.index_path();
188 let contents = match fs::read_to_string(&index_path) {
189 Ok(contents) => contents,
190 Err(source) if source.kind() == ErrorKind::NotFound => {
191 return self.rebuild_index_with_policy(signature_policy);
192 }
193 Err(source) => {
194 return Err(ReceiptStoreError::StoreUnreadable {
195 path: index_path,
196 source,
197 });
198 }
199 };
200 let index = parse_index(&contents, &index_path)?;
201 self.verify_index(&index, signature_policy)?;
202 Ok(index)
203 }
204
205 pub fn rebuild_index(&self) -> Result<ReceiptStoreIndex, ReceiptStoreError> {
206 self.rebuild_index_with_policy(RuntimeReceiptSignaturePolicy::local_development())
207 }
208
209 pub fn rebuild_index_with_policy(
210 &self,
211 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
212 ) -> Result<ReceiptStoreIndex, ReceiptStoreError> {
213 let entries = self
214 .list_with_policy(signature_policy)?
215 .into_iter()
216 .map(|receipt| {
217 let receipt_id = receipt.id.to_string();
218 Ok(ReceiptStoreIndexEntry {
219 file_name: receipt_file_name(&receipt_id)?,
220 receipt_id,
221 created_at: receipt.created_at.to_string(),
222 })
223 })
224 .collect::<Result<Vec<_>, ReceiptStoreError>>()?;
225 let index = ReceiptStoreIndex {
226 schema: RECEIPT_STORE_INDEX_SCHEMA.to_owned(),
227 generated_at: generated_at_nanos(),
228 entries,
229 };
230 self.write_index(&index)?;
231 Ok(index)
232 }
233
234 fn verify_index(
235 &self,
236 index: &ReceiptStoreIndex,
237 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
238 ) -> Result<(), ReceiptStoreError> {
239 let listed = self.list_with_policy(signature_policy)?;
240 let listed_entries = listed
241 .iter()
242 .map(|receipt| {
243 let receipt_id = receipt.id.to_string();
244 Ok(ReceiptStoreIndexEntry {
245 file_name: receipt_file_name(&receipt_id)?,
246 receipt_id,
247 created_at: receipt.created_at.to_string(),
248 })
249 })
250 .collect::<Result<Vec<_>, ReceiptStoreError>>()?;
251 if listed_entries != index.entries {
252 return Err(ReceiptStoreError::ReceiptIndexStale {
253 path: self.index_path(),
254 message: "index entries do not match receipt JSON files".to_owned(),
255 });
256 }
257 Ok(())
258 }
259
260 fn update_index_after_write(
261 &self,
262 receipt: &Receipt,
263 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
264 ) -> Result<(), ReceiptStoreError> {
265 match self.append_index_entry(receipt) {
266 Ok(()) => Ok(()),
267 Err(_) => match self.rebuild_index_with_policy(signature_policy) {
268 Ok(_) => Ok(()),
269 Err(error) => Err(ReceiptStoreError::ReceiptIndexStale {
270 path: self.index_path(),
271 message: error.to_string(),
272 }),
273 },
274 }
275 }
276
277 fn append_index_entry(&self, receipt: &Receipt) -> Result<(), ReceiptStoreError> {
278 let mut index = self.read_index_without_verification()?;
279 ensure_index_shape_for_append(&index)?;
280 let receipt_id = receipt.id.to_string();
281 if index
282 .entries
283 .iter()
284 .any(|entry| entry.receipt_id == receipt_id)
285 {
286 return Err(ReceiptStoreError::ReceiptIndexStale {
287 path: self.index_path(),
288 message: "index already contains receipt id".to_owned(),
289 });
290 }
291 if self.receipt_file_count()? != index.entries.len().saturating_add(1) {
292 return Err(ReceiptStoreError::ReceiptIndexStale {
293 path: self.index_path(),
294 message: "index entry count does not match receipt JSON files".to_owned(),
295 });
296 }
297 index.entries.push(ReceiptStoreIndexEntry {
298 receipt_id: receipt_id.clone(),
299 file_name: receipt_file_name(&receipt_id)?,
300 created_at: receipt.created_at.to_string(),
301 });
302 index
303 .entries
304 .sort_by(|left, right| left.receipt_id.cmp(&right.receipt_id));
305 index.generated_at = generated_at_nanos();
306 self.write_index(&index)
307 }
308
309 fn read_index_without_verification(&self) -> Result<ReceiptStoreIndex, ReceiptStoreError> {
310 let index_path = self.index_path();
311 let contents = fs::read_to_string(&index_path).map_err(|source| {
312 ReceiptStoreError::StoreUnreadable {
313 path: index_path.clone(),
314 source,
315 }
316 })?;
317 parse_index(&contents, &index_path)
318 }
319
320 fn receipt_file_count(&self) -> Result<usize, ReceiptStoreError> {
321 let mut count = 0usize;
322 for entry in
323 fs::read_dir(&self.root).map_err(|source| ReceiptStoreError::StoreUnreadable {
324 path: self.root.clone(),
325 source,
326 })?
327 {
328 let entry = entry.map_err(|source| ReceiptStoreError::StoreUnreadable {
329 path: self.root.clone(),
330 source,
331 })?;
332 let path = entry.path();
333 if is_receipt_json_path(&path) {
334 count += 1;
335 }
336 }
337 Ok(count)
338 }
339
340 fn write_index(&self, index: &ReceiptStoreIndex) -> Result<(), ReceiptStoreError> {
341 let contents =
342 serde_json::to_vec(index).map_err(|source| ReceiptStoreError::MalformedIndex {
343 path: self.index_path(),
344 message: source.to_string(),
345 })?;
346 write_atomic_cache(&self.root, INDEX_FILE_NAME, &contents)
350 }
351
352 fn index_path(&self) -> PathBuf {
353 self.root.join(INDEX_FILE_NAME)
354 }
355
356 fn ensure_store_dir(&self) -> Result<(), ReceiptStoreError> {
357 match fs::metadata(&self.root) {
358 Ok(metadata) if metadata.is_dir() => Ok(()),
359 Ok(_) => Err(ReceiptStoreError::StoreNotDirectory {
360 path: self.root.clone(),
361 }),
362 Err(source) if source.kind() == ErrorKind::NotFound => {
363 Err(ReceiptStoreError::MissingStore {
364 path: self.root.clone(),
365 })
366 }
367 Err(source) => Err(ReceiptStoreError::StoreUnreadable {
368 path: self.root.clone(),
369 source,
370 }),
371 }
372 }
373
374 fn ensure_or_create_store_dir(&self) -> Result<(), ReceiptStoreError> {
375 match fs::metadata(&self.root) {
376 Ok(metadata) if metadata.is_dir() => Ok(()),
377 Ok(_) => Err(ReceiptStoreError::StoreNotDirectory {
378 path: self.root.clone(),
379 }),
380 Err(source) if source.kind() == ErrorKind::NotFound => fs::create_dir_all(&self.root)
381 .map_err(|source| ReceiptStoreError::StoreUnreadable {
382 path: self.root.clone(),
383 source,
384 }),
385 Err(source) => Err(ReceiptStoreError::StoreUnreadable {
386 path: self.root.clone(),
387 source,
388 }),
389 }
390 }
391}
392
393#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
394pub struct ReceiptStoreIndex {
395 pub schema: String,
396 pub generated_at: String,
397 pub entries: Vec<ReceiptStoreIndexEntry>,
398}
399
400#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
401pub struct ReceiptStoreIndexEntry {
402 pub receipt_id: String,
403 pub file_name: String,
404 pub created_at: String,
405}
406
407#[derive(Debug, Error)]
408pub enum ReceiptStoreError {
409 #[error("receipt store is missing")]
410 MissingStore { path: PathBuf },
411 #[error("receipt store path is not a directory")]
412 StoreNotDirectory { path: PathBuf },
413 #[error("receipt store is unreadable: {source}")]
414 StoreUnreadable {
415 path: PathBuf,
416 #[source]
417 source: std::io::Error,
418 },
419 #[error("receipt id is invalid for local store lookup: {receipt_id}")]
420 InvalidReceiptId { receipt_id: String },
421 #[error("receipt is missing")]
422 MissingReceipt { path: PathBuf },
423 #[error("receipt is unreadable: {source}")]
424 ReceiptUnreadable {
425 path: PathBuf,
426 #[source]
427 source: std::io::Error,
428 },
429 #[error("receipt JSON is malformed: {message}")]
430 MalformedJson { path: PathBuf, message: String },
431 #[error("receipt has unsupported schema: {schema}")]
432 WrongSchema { path: PathBuf, schema: String },
433 #[error("receipt shape is invalid: {message}")]
434 MalformedReceipt { path: PathBuf, message: String },
435 #[error("receipt id '{receipt_id}' does not match file name '{file_stem}'")]
436 IdFilenameMismatch {
437 path: PathBuf,
438 receipt_id: String,
439 file_stem: String,
440 },
441 #[error("receipt proof is invalid for {receipt_id}: {message}")]
442 ReceiptProofInvalid {
443 path: PathBuf,
444 receipt_id: String,
445 message: String,
446 },
447 #[error("receipt already exists with different content: {receipt_id}")]
448 ReceiptAlreadyExists { receipt_id: String },
449 #[error("receipt store index is malformed: {message}")]
450 MalformedIndex { path: PathBuf, message: String },
451 #[error("receipt store index is stale: {message}")]
452 ReceiptIndexStale { path: PathBuf, message: String },
453 #[error("receipt store path cannot be projected safely: {reason}")]
454 UnsafePathProjection { reason: String },
455}
456
457impl ReceiptStoreError {
458 #[must_use]
459 pub fn public_message(&self, store_label: &ReceiptStoreLabel) -> String {
460 match self {
461 Self::MissingStore { .. } => format!("receipt store {store_label} is missing"),
462 Self::StoreNotDirectory { .. } => {
463 format!("receipt store {store_label} is not a directory")
464 }
465 Self::StoreUnreadable { .. } => format!("receipt store {store_label} is unreadable"),
466 Self::InvalidReceiptId { .. } => {
467 "receipt id is invalid for local store lookup".to_owned()
468 }
469 Self::MissingReceipt { .. } => format!("receipt is missing in store {store_label}"),
470 Self::ReceiptUnreadable { .. } => {
471 format!("receipt is unreadable in store {store_label}")
472 }
473 Self::MalformedJson { .. } => {
474 format!("receipt JSON is malformed in store {store_label}")
475 }
476 Self::WrongSchema { schema, .. } => {
477 format!("receipt has unsupported schema in store {store_label}: {schema}")
478 }
479 Self::MalformedReceipt { .. } => {
480 format!("receipt shape is invalid in store {store_label}")
481 }
482 Self::IdFilenameMismatch { .. } => {
483 format!("receipt id does not match file name in store {store_label}")
484 }
485 Self::ReceiptProofInvalid { .. } => {
486 format!("receipt proof is invalid in store {store_label}")
487 }
488 Self::ReceiptAlreadyExists { .. } => {
489 format!("receipt already exists with different content in store {store_label}")
490 }
491 Self::MalformedIndex { .. } => {
492 format!("receipt store index is malformed in store {store_label}")
493 }
494 Self::ReceiptIndexStale { .. } => {
495 format!("receipt store index is stale in store {store_label}")
496 }
497 Self::UnsafePathProjection { .. } => {
498 "receipt store path cannot be projected safely".to_owned()
499 }
500 }
501 }
502}
503
504fn receipt_file_name(receipt_id: &str) -> Result<String, ReceiptStoreError> {
505 if let Some(digest) = receipt_id.strip_prefix(SHA256_RECEIPT_ID_PREFIX) {
506 if is_sha256_hex_digest(digest) {
507 return Ok(format!("{SHA256_RECEIPT_FILE_PREFIX}{digest}.json"));
508 }
509 } else if is_safe_literal_receipt_file_stem(receipt_id) {
510 return Ok(format!("{receipt_id}.json"));
511 }
512 Err(ReceiptStoreError::InvalidReceiptId {
513 receipt_id: receipt_id.to_owned(),
514 })
515}
516
517fn is_receipt_json_path(path: &Path) -> bool {
518 path.extension() == Some(OsStr::new("json"))
519 && path.file_name().is_some_and(|file_name| {
520 file_name != OsStr::new(INDEX_FILE_NAME)
521 && file_name != OsStr::new(EFFECT_STATE_FILE_NAME)
522 })
523 && path
524 .file_stem()
525 .and_then(OsStr::to_str)
526 .and_then(receipt_id_from_file_stem)
527 .is_some()
528}
529
530fn receipt_id_from_file_stem(stem: &str) -> Option<String> {
531 if let Some(digest) = stem.strip_prefix(SHA256_RECEIPT_FILE_PREFIX) {
532 return is_sha256_hex_digest(digest).then(|| format!("{SHA256_RECEIPT_ID_PREFIX}{digest}"));
533 };
534 None
535}
536
537fn is_sha256_hex_digest(digest: &str) -> bool {
538 digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
539}
540
541fn is_safe_literal_receipt_file_stem(stem: &str) -> bool {
542 !(stem.is_empty() || stem == "." || stem == "..")
543 && stem
544 .bytes()
545 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
546}
547
548fn read_receipt_file(
549 path: &Path,
550 expected_id: &str,
551 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
552) -> Result<Receipt, ReceiptStoreError> {
553 let contents = fs::read_to_string(path).map_err(|source| {
554 if source.kind() == ErrorKind::NotFound {
555 ReceiptStoreError::MissingReceipt {
556 path: path.to_path_buf(),
557 }
558 } else {
559 ReceiptStoreError::ReceiptUnreadable {
560 path: path.to_path_buf(),
561 source,
562 }
563 }
564 })?;
565 parse_receipt_contents(&contents, path, expected_id, signature_policy)
566}
567
568fn read_receipt_file_without_proof(
569 path: &Path,
570 expected_id: &str,
571) -> Result<Receipt, ReceiptStoreError> {
572 let contents = fs::read_to_string(path).map_err(|source| {
573 if source.kind() == ErrorKind::NotFound {
574 ReceiptStoreError::MissingReceipt {
575 path: path.to_path_buf(),
576 }
577 } else {
578 ReceiptStoreError::ReceiptUnreadable {
579 path: path.to_path_buf(),
580 source,
581 }
582 }
583 })?;
584 parse_receipt_contents_without_proof(&contents, path, expected_id)
585}
586
587fn parse_index(contents: &str, path: &Path) -> Result<ReceiptStoreIndex, ReceiptStoreError> {
588 let index = serde_json::from_str::<ReceiptStoreIndex>(contents).map_err(|source| {
589 ReceiptStoreError::MalformedIndex {
590 path: path.to_path_buf(),
591 message: source.to_string(),
592 }
593 })?;
594 if index.schema != RECEIPT_STORE_INDEX_SCHEMA {
595 return Err(ReceiptStoreError::MalformedIndex {
596 path: path.to_path_buf(),
597 message: format!("unsupported index schema {}", index.schema),
598 });
599 }
600 Ok(index)
601}
602
603fn ensure_index_shape_for_append(index: &ReceiptStoreIndex) -> Result<(), ReceiptStoreError> {
604 let mut previous_id: Option<&str> = None;
605 for entry in &index.entries {
606 let expected_file_name = receipt_file_name(&entry.receipt_id)?;
607 if entry.file_name != expected_file_name {
608 return Err(ReceiptStoreError::ReceiptIndexStale {
609 path: PathBuf::from(INDEX_FILE_NAME),
610 message: "index file name does not match receipt id".to_owned(),
611 });
612 }
613 if previous_id.is_some_and(|previous| previous >= entry.receipt_id.as_str()) {
614 return Err(ReceiptStoreError::ReceiptIndexStale {
615 path: PathBuf::from(INDEX_FILE_NAME),
616 message: "index receipt ids must be sorted and unique".to_owned(),
617 });
618 }
619 previous_id = Some(entry.receipt_id.as_str());
620 }
621 Ok(())
622}
623
624fn parse_receipt_contents(
625 contents: &str,
626 path: &Path,
627 expected_id: &str,
628 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
629) -> Result<Receipt, ReceiptStoreError> {
630 let receipt = parse_receipt_contents_without_proof(contents, path, expected_id)?;
631 verify_stored_receipt_proof(path, &receipt, signature_policy)?;
632 Ok(receipt)
633}
634
635fn parse_receipt_contents_without_proof(
636 contents: &str,
637 path: &Path,
638 expected_id: &str,
639) -> Result<Receipt, ReceiptStoreError> {
640 let probe = serde_json::from_str::<ReceiptSchemaProbe>(contents).map_err(|source| {
641 ReceiptStoreError::MalformedJson {
642 path: path.to_path_buf(),
643 message: source.to_string(),
644 }
645 })?;
646 let schema = probe.schema.as_deref().unwrap_or("<missing>");
647 if schema != RECEIPT_SCHEMA {
648 return Err(ReceiptStoreError::WrongSchema {
649 path: path.to_path_buf(),
650 schema: schema.to_owned(),
651 });
652 }
653 let receipt = serde_json::from_str::<Receipt>(contents).map_err(|source| {
654 ReceiptStoreError::MalformedReceipt {
655 path: path.to_path_buf(),
656 message: source.to_string(),
657 }
658 })?;
659 if receipt.id != expected_id {
660 return Err(ReceiptStoreError::IdFilenameMismatch {
661 path: path.to_path_buf(),
662 receipt_id: receipt.id.into_string(),
663 file_stem: expected_id.to_owned(),
664 });
665 }
666 Ok(receipt)
667}
668
669#[derive(Debug, Deserialize)]
670struct ReceiptSchemaProbe {
671 schema: Option<String>,
672}
673
674fn verify_stored_receipt_proof(
675 path: &Path,
676 receipt: &Receipt,
677 signature_policy: RuntimeReceiptSignaturePolicy<'_>,
678) -> Result<(), ReceiptStoreError> {
679 let expected_id = content_addressed_receipt_id(receipt).map_err(|error| {
680 ReceiptStoreError::ReceiptProofInvalid {
681 path: path.to_path_buf(),
682 receipt_id: receipt.id.to_string(),
683 message: format!("receipt content address could not be recomputed: {error}"),
684 }
685 })?;
686 if receipt.id != expected_id {
687 return Err(ReceiptStoreError::ReceiptProofInvalid {
688 path: path.to_path_buf(),
689 receipt_id: receipt.id.to_string(),
690 message: format!(
691 "receipt id must match content address: expected {expected_id}, got {}",
692 receipt.id
693 ),
694 });
695 }
696 let proof_contexts = RuntimeReceiptProofContextProvider::new(signature_policy);
697 let context = proof_contexts.proof_context(receipt);
698 let verification = verify_receipt_proof(receipt, &context);
699 if verification.valid {
700 Ok(())
701 } else {
702 Err(ReceiptStoreError::ReceiptProofInvalid {
703 path: path.to_path_buf(),
704 receipt_id: receipt.id.to_string(),
705 message: format!("{:?}", verification.findings),
706 })
707 }
708}
709
710fn write_atomic(dir: &Path, file_name: &str, contents: &[u8]) -> Result<(), ReceiptStoreError> {
711 write_atomic_with(dir, file_name, contents, true)
712}
713
714fn write_atomic_cache(
715 dir: &Path,
716 file_name: &str,
717 contents: &[u8],
718) -> Result<(), ReceiptStoreError> {
719 write_atomic_with(dir, file_name, contents, false)
720}
721
722fn write_atomic_with(
723 dir: &Path,
724 file_name: &str,
725 contents: &[u8],
726 durable: bool,
727) -> Result<(), ReceiptStoreError> {
728 let temp_name = temp_file_name(file_name);
729 let temp_path = dir.join(&temp_name);
730 let final_path = dir.join(file_name);
731 let write_result = write_temp_file(&temp_path, contents, durable)
732 .and_then(|()| fs::rename(&temp_path, &final_path))
733 .and_then(|()| if durable { sync_directory(dir) } else { Ok(()) });
734 if let Err(source) = write_result {
735 let _ignored = fs::remove_file(&temp_path);
736 return Err(ReceiptStoreError::StoreUnreadable {
737 path: final_path,
738 source,
739 });
740 }
741 Ok(())
742}
743
744fn write_temp_file(path: &Path, contents: &[u8], durable: bool) -> Result<(), std::io::Error> {
745 let mut options = OpenOptions::new();
746 options.write(true).create_new(true);
747 #[cfg(unix)]
748 {
749 use std::os::unix::fs::OpenOptionsExt;
750 options.mode(0o600);
751 }
752 let mut file = options.open(path)?;
753 file.write_all(contents)?;
754 file.flush()?;
755 if durable {
756 file.sync_all()?;
757 }
758 Ok(())
759}
760
761fn sync_directory(path: &Path) -> Result<(), std::io::Error> {
762 File::open(path)?.sync_all()
763}
764
765fn temp_file_name(file_name: &str) -> String {
766 format!(".{file_name}.tmp.{}-{}", std::process::id(), unix_nanos())
767}
768
769fn generated_at_nanos() -> String {
770 unix_nanos().to_string()
771}
772
773fn unix_nanos() -> u128 {
774 SystemTime::now()
775 .duration_since(UNIX_EPOCH)
776 .map_or(0, |duration| duration.as_nanos())
777}