Skip to main content

pf_core/
store.rs

1// SPDX-License-Identifier: MIT
2//! High-level store: a [`BlobStore`] plus a manifest catalog.
3//!
4//! `PfStore` is the single object the CLI / SDKs talk to for read/write of
5//! whole `.pfimg` images. It hides the distinction between blobs (CAS) and
6//! manifests (also CAS, but with a separate index for `pf log`).
7
8use crate::cas::{BlobStore, FsBlobStore};
9use crate::digest::Digest256;
10use crate::error::Result;
11use crate::manifest::Manifest;
12
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16/// A `PfStore` is the local-filesystem entry point. It owns one [`BlobStore`]
17/// (default [`FsBlobStore`]) plus an `images/` directory of one symlink per
18/// manifest digest, kept for fast `pf log` traversal.
19pub struct PfStore {
20    blobs: Arc<dyn BlobStore>,
21    root: PathBuf,
22}
23
24impl PfStore {
25    /// Open the default on-disk store rooted at `root`.
26    ///
27    /// Creates `root/blobs/sha256/` and `root/images/` if missing.
28    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
29        let root = root.as_ref().to_path_buf();
30        std::fs::create_dir_all(root.join("images"))?;
31        let blobs: Arc<dyn BlobStore> = Arc::new(FsBlobStore::open(&root)?);
32        Ok(Self { blobs, root })
33    }
34
35    /// Open with a caller-supplied blob store (useful for in-memory tests).
36    pub fn with_blobstore(root: impl AsRef<Path>, blobs: Arc<dyn BlobStore>) -> Result<Self> {
37        let root = root.as_ref().to_path_buf();
38        std::fs::create_dir_all(root.join("images"))?;
39        Ok(Self { blobs, root })
40    }
41
42    /// Borrow the underlying blob store.
43    pub fn blobs(&self) -> &dyn BlobStore {
44        self.blobs.as_ref()
45    }
46
47    /// Borrow the underlying blob store as an `Arc` (for cloning into capture
48    /// pipelines).
49    pub fn blobs_arc(&self) -> Arc<dyn BlobStore> {
50        Arc::clone(&self.blobs)
51    }
52
53    /// Root directory of the store.
54    pub fn root(&self) -> &Path {
55        &self.root
56    }
57
58    /// Persist a [`Manifest`] and return its content-id (the digest of its
59    /// canonical JSON serialization).
60    ///
61    /// Also drops a marker file at `images/<cid>.json` so `pf log` can walk
62    /// without scanning every blob.
63    pub fn put_manifest(&self, m: &Manifest) -> Result<Digest256> {
64        let json = serde_json::to_vec(m)?;
65        let cid = self.blobs.put(&json)?;
66        let marker = self.root.join("images").join(format!("{}.json", cid.hex()));
67        if !marker.exists() {
68            std::fs::write(&marker, &json)?;
69        }
70        Ok(cid)
71    }
72
73    /// Load a manifest by content-id.
74    pub fn get_manifest(&self, cid: &Digest256) -> Result<Manifest> {
75        let bytes = self.blobs.get(cid)?;
76        Ok(serde_json::from_slice(&bytes)?)
77    }
78
79    /// Iterate every manifest known to this store. Order is unspecified.
80    pub fn iter_manifests(&self) -> Result<impl Iterator<Item = (Digest256, Manifest)> + '_> {
81        let entries = std::fs::read_dir(self.root.join("images"))?;
82        Ok(entries.filter_map(move |e| {
83            let e = e.ok()?;
84            let name = e.file_name().to_string_lossy().to_string();
85            let hex = name.strip_suffix(".json")?;
86            let cid = Digest256::parse(&format!("sha256:{hex}")).ok()?;
87            let m: Manifest = serde_json::from_slice(&std::fs::read(e.path()).ok()?).ok()?;
88            Some((cid, m))
89        }))
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::manifest::{
97        AgentInfo, CacheLayer, EffectsLayer, MEDIATYPE_V1, ModelLayer, TraceLayer, WorldLayer,
98    };
99    use chrono::Utc;
100    use tempfile::TempDir;
101
102    fn fixture(blobs: &dyn BlobStore) -> Manifest {
103        let d = blobs.put(b"x").unwrap();
104        Manifest {
105            schema_version: 1,
106            media_type: MEDIATYPE_V1.to_owned(),
107            agent: AgentInfo {
108                kind: "test".into(),
109                version: "0".into(),
110                fingerprint: "f".into(),
111            },
112            model: ModelLayer {
113                base: d.clone(),
114                diff: d.clone(),
115            },
116            cache: CacheLayer {
117                layout: "paged-batchinvariant-v1".into(),
118                manifest: d.clone(),
119            },
120            world: WorldLayer {
121                fs: d.clone(),
122                env: d.clone(),
123                procs: d.clone(),
124            },
125            effects: EffectsLayer { ledger: d.clone() },
126            trace: TraceLayer { messages: d },
127            created_at: Utc::now(),
128            parents: vec![],
129        }
130    }
131
132    #[test]
133    fn put_get_manifest_round_trip() {
134        let dir = TempDir::new().unwrap();
135        let store = PfStore::open(dir.path()).unwrap();
136        let m = fixture(store.blobs());
137        let cid = store.put_manifest(&m).unwrap();
138        let back = store.get_manifest(&cid).unwrap();
139        assert_eq!(back.schema_version, 1);
140        assert_eq!(back.parents.len(), 0);
141    }
142
143    #[test]
144    fn iter_manifests_lists_what_was_written() {
145        let dir = TempDir::new().unwrap();
146        let store = PfStore::open(dir.path()).unwrap();
147        let m1 = fixture(store.blobs());
148        let cid1 = store.put_manifest(&m1).unwrap();
149        let mut m2 = m1.clone();
150        m2.agent.version = "1".into();
151        let cid2 = store.put_manifest(&m2).unwrap();
152        assert_ne!(cid1, cid2);
153        let listed: std::collections::HashSet<_> = store
154            .iter_manifests()
155            .unwrap()
156            .map(|(digest, _)| digest)
157            .collect();
158        assert!(listed.contains(&cid1));
159        assert!(listed.contains(&cid2));
160    }
161}