Skip to main content

runx_runtime/receipts/
store.rs

1// rust-style-allow: large-file -- local store read/write/index semantics stay
2// together until the receipt-store API finishes the hard-cutover review.
3use 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        // `index.json` is a recoverable projection of receipt files. Receipt
347        // writes remain durable; index writes stay atomic but skip fsync so an
348        // append does not pay the full durability cost twice.
349        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}