Skip to main content

treeship_core/storage/
mod.rs

1use std::{
2    fs,
3    io::{self, Write},
4    path::{Path, PathBuf},
5    sync::{Arc, RwLock},
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::attestation::{parse_artifact_id, ArtifactId, Envelope};
11
12/// The on-disk record for one stored artifact.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Record {
15    pub artifact_id: ArtifactId,
16    pub digest: String, // "sha256:<hex>"
17    pub payload_type: String,
18    pub key_id: String,
19    pub signed_at: String, // RFC 3339
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub parent_id: Option<String>,
22    pub envelope: Envelope,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub hub_url: Option<String>,
25    /// External witnesses to this artifact's existence, in the order they
26    /// were obtained.
27    ///
28    /// Separate from `hub_url` because a URL records *that* something was
29    /// pushed and not *when* -- and when is the whole value. A receipt's own
30    /// timestamp is the signer's claim about itself; an anchor is somebody
31    /// else's record that these bytes existed by a given moment, which is
32    /// what makes a timeline hard to fabricate after the fact.
33    ///
34    /// `#[serde(default)]` so receipts written before this field parse
35    /// unchanged: an old artifact has no anchors recorded, which is exactly
36    /// what an empty list means.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub anchors: Vec<RecordAnchor>,
39}
40
41/// One external witness to an artifact, as observed locally.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct RecordAnchor {
44    /// Which mechanism witnessed it: "hub", "rekor", "ots", "tsa".
45    pub mechanism: String,
46    /// RFC 3339, from the *local* clock at the moment the witness responded.
47    ///
48    /// Honest caveat, and the reason this is not the end of the story: this
49    /// is still our own clock. It records when we observed the witness, not
50    /// when the witness says it saw us. It is trustworthy against an actor
51    /// who fabricates a timeline afterwards -- you cannot obtain a Hub or
52    /// Rekor response for bytes you have not written yet -- and not
53    /// trustworthy against one who sets the system clock and anchors in real
54    /// time. Closing that needs the witness's own signed time, which is
55    /// `time-anchoring.md` slices 3-5.
56    pub observed_at: String,
57    /// Witness-side identifier where one exists: a Rekor log index, a Hub
58    /// artifact URL. Lets a verifier go and check independently.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub reference: Option<String>,
61}
62
63/// A lightweight index entry — stored in index.json for fast listing
64/// without reading every artifact file.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IndexEntry {
67    pub id: ArtifactId,
68    pub payload_type: String,
69    pub signed_at: String,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub parent_id: Option<String>,
72}
73
74#[derive(Serialize, Deserialize, Default)]
75struct Index {
76    entries: Vec<IndexEntry>,
77}
78
79/// Errors from storage operations.
80#[derive(Debug)]
81pub enum StorageError {
82    Io(io::Error),
83    Json(serde_json::Error),
84    EmptyId,
85    /// The id is not a well-formed `art_<32 hex>`. Returned instead of
86    /// touching the filesystem: an id reaches here from a Hub response, and
87    /// one containing `../` used to resolve to a path outside the store.
88    InvalidId(String),
89    NotFound(ArtifactId),
90}
91
92impl std::fmt::Display for StorageError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Self::Io(e) => write!(f, "storage io: {}", e),
96            Self::Json(e) => write!(f, "storage json: {}", e),
97            Self::EmptyId => write!(f, "artifact_id must not be empty"),
98            Self::InvalidId(e) => write!(f, "storage: {}", e),
99            Self::NotFound(id) => write!(f, "artifact not found: {}", id),
100        }
101    }
102}
103
104impl std::error::Error for StorageError {}
105impl From<io::Error> for StorageError {
106    fn from(e: io::Error) -> Self {
107        Self::Io(e)
108    }
109}
110impl From<serde_json::Error> for StorageError {
111    fn from(e: serde_json::Error) -> Self {
112        Self::Json(e)
113    }
114}
115
116/// Local artifact store. Thread-safe via internal RwLock.
117///
118/// Artifacts are stored as `<artifact_id>.json` files.
119/// Content-addressed IDs mean same content → same filename → idempotent writes.
120/// An `index.json` tracks all artifact IDs for O(1) listing.
121pub struct Store {
122    dir: PathBuf,
123    index: Arc<RwLock<Index>>,
124}
125
126impl Store {
127    /// Opens or creates an artifact store at `dir`.
128    pub fn open(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
129        let dir = dir.as_ref().to_path_buf();
130        fs::create_dir_all(&dir)?;
131
132        let index = read_index(&dir)?;
133        Ok(Self {
134            dir,
135            index: Arc::new(RwLock::new(index)),
136        })
137    }
138
139    /// Writes an artifact record. Idempotent: writing the same artifact
140    /// twice has no effect beyond overwriting with identical content.
141    pub fn write(&self, record: &Record) -> Result<(), StorageError> {
142        if record.artifact_id.is_empty() {
143            return Err(StorageError::EmptyId);
144        }
145
146        let json = serde_json::to_vec_pretty(record)?;
147        write_600(&self.artifact_path(&record.artifact_id)?, &json)?;
148
149        let mut idx = self.index.write().unwrap();
150        let entry = IndexEntry {
151            id: record.artifact_id.clone(),
152            payload_type: record.payload_type.clone(),
153            signed_at: record.signed_at.clone(),
154            parent_id: record.parent_id.clone(),
155        };
156        add_to_index(&mut idx, entry);
157        write_600(
158            &self.dir.join("index.json"),
159            &serde_json::to_vec_pretty(&*idx)?,
160        )?;
161
162        Ok(())
163    }
164
165    /// Reads an artifact by ID.
166    pub fn read(&self, id: &str) -> Result<Record, StorageError> {
167        let path = self.artifact_path(id)?;
168        if !path.exists() {
169            return Err(StorageError::NotFound(id.to_string()));
170        }
171        let bytes = fs::read(&path)?;
172        Ok(serde_json::from_slice(&bytes)?)
173    }
174
175    /// Returns true if an artifact with this ID is stored locally.
176    pub fn exists(&self, id: &str) -> bool {
177        // A malformed id cannot name a stored artifact, so it does not exist.
178        // Deliberately not a filesystem probe on an unvalidated path.
179        self.artifact_path(id).is_ok_and(|p| p.exists())
180    }
181
182    /// Lists index entries, most recent first.
183    pub fn list(&self) -> Vec<IndexEntry> {
184        let idx = self.index.read().unwrap();
185        idx.entries.iter().rev().cloned().collect()
186    }
187
188    /// Lists index entries filtered to a specific payload type.
189    pub fn list_by_type(&self, payload_type: &str) -> Vec<IndexEntry> {
190        self.list()
191            .into_iter()
192            .filter(|e| e.payload_type == payload_type)
193            .collect()
194    }
195
196    /// Updates the hub_url on a stored record after a successful dock push.
197    pub fn set_hub_url(&self, id: &str, hub_url: &str) -> Result<(), StorageError> {
198        let mut record = self.read(id)?;
199        record.hub_url = Some(hub_url.to_string());
200        self.write(&record)
201    }
202
203    /// Record that an external witness saw this artifact.
204    ///
205    /// Appends rather than replaces: two witnesses are strictly better
206    /// evidence than one, and they fail differently -- a Hub anchor requires
207    /// trusting the Hub, an OpenTimestamps anchor does not. Collapsing them
208    /// would discard that difference.
209    pub fn add_anchor(&self, id: &str, anchor: RecordAnchor) -> Result<(), StorageError> {
210        let mut record = self.read(id)?;
211        record.anchors.push(anchor);
212        self.write(&record)
213    }
214
215    /// Returns the most recently stored artifact, if any.
216    pub fn latest(&self) -> Option<IndexEntry> {
217        self.index.read().unwrap().entries.last().cloned()
218    }
219
220    /// Resolve an artifact id to its on-disk path.
221    ///
222    /// Validates the id first. `join` on an attacker-influenced string is a
223    /// path-traversal primitive: `../../x` resolved to a file outside the
224    /// store, and `hub pull` writes whatever `artifact_id` the server sends
225    /// back. `parse_artifact_id` already existed and enforces
226    /// `art_<32 hex>` -- which cannot contain a separator or a dot -- it was
227    /// simply never called on this path.
228    fn artifact_path(&self, id: &str) -> Result<PathBuf, StorageError> {
229        let id = parse_artifact_id(id).map_err(StorageError::InvalidId)?;
230        Ok(self.dir.join(format!("{}.json", id)))
231    }
232}
233
234fn read_index(dir: &Path) -> Result<Index, StorageError> {
235    let path = dir.join("index.json");
236    if !path.exists() {
237        return Ok(Index::default());
238    }
239    let bytes = fs::read(&path)?;
240    Ok(serde_json::from_slice(&bytes)?)
241}
242
243fn add_to_index(idx: &mut Index, entry: IndexEntry) {
244    // Deduplicate.
245    if !idx.entries.iter().any(|e| e.id == entry.id) {
246        idx.entries.push(entry);
247    }
248}
249
250fn write_600(path: &Path, data: &[u8]) -> Result<(), StorageError> {
251    let mut f = fs::OpenOptions::new()
252        .write(true)
253        .create(true)
254        .truncate(true)
255        .open(path)?;
256    f.write_all(data)?;
257    #[cfg(unix)]
258    {
259        use std::os::unix::fs::PermissionsExt;
260        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
261    }
262    Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
269
270    fn make_record(id: &str, pt: &str) -> Record {
271        Record {
272            artifact_id: id.to_string(),
273            digest: format!("sha256:{}", "a".repeat(64)),
274            payload_type: pt.to_string(),
275            key_id: "key_test".into(),
276            signed_at: "2026-03-26T10:00:00Z".into(),
277            parent_id: None,
278            envelope: Envelope {
279                payload: URL_SAFE_NO_PAD.encode(b"{\"type\":\"test\"}"),
280                payload_type: pt.to_string(),
281                signatures: vec![crate::attestation::Signature {
282                    keyid: "key_test".into(),
283                    sig: URL_SAFE_NO_PAD.encode(b"fake_sig_64_bytes_padded_to_length_xxxxxxxxxx"),
284                }],
285            },
286            hub_url: None,
287            anchors: Vec::new(),
288        }
289    }
290
291    fn tmp_store() -> (Store, PathBuf) {
292        let mut p = std::env::temp_dir();
293        p.push(format!("treeship-storage-test-{}", {
294            use rand::RngCore;
295            let mut b = [0u8; 4];
296            rand::thread_rng().fill_bytes(&mut b);
297            b.iter().fold(String::new(), |mut s, byte| {
298                s.push_str(&format!("{:02x}", byte));
299                s
300            })
301        }));
302        let store = Store::open(&p).unwrap();
303        (store, p)
304    }
305
306    fn rm(p: PathBuf) {
307        let _ = fs::remove_dir_all(p);
308    }
309
310    #[test]
311    fn write_and_read() {
312        let (store, dir) = tmp_store();
313        let id = "art_aabbccdd11223344aabbccdd11223344";
314        let pt = "application/vnd.treeship.action.v1+json";
315        store.write(&make_record(id, pt)).unwrap();
316
317        let rec = store.read(id).unwrap();
318        assert_eq!(rec.artifact_id, id);
319        assert_eq!(rec.payload_type, pt);
320        rm(dir);
321    }
322
323    #[test]
324    fn exists() {
325        let (store, dir) = tmp_store();
326        let id = "art_aabbccdd11223344aabbccdd11223344";
327        assert!(!store.exists(id));
328        store
329            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
330            .unwrap();
331        assert!(store.exists(id));
332        rm(dir);
333    }
334
335    #[test]
336    fn idempotent_write() {
337        let (store, dir) = tmp_store();
338        let id = "art_aabbccdd11223344aabbccdd11223344";
339        let r = make_record(id, "application/vnd.treeship.action.v1+json");
340        store.write(&r).unwrap();
341        store.write(&r).unwrap();
342        assert_eq!(store.list().len(), 1);
343        rm(dir);
344    }
345
346    #[test]
347    fn list_order() {
348        let (store, dir) = tmp_store();
349        let pt = "application/vnd.treeship.action.v1+json";
350        store
351            .write(&make_record("art_aabbccdd11223344aabbccdd11223344", pt))
352            .unwrap();
353        store
354            .write(&make_record("art_bbccddee22334455bbccddee22334455", pt))
355            .unwrap();
356
357        let list = store.list();
358        assert_eq!(list.len(), 2);
359        // Most recent first — second write appears first.
360        assert_eq!(list[0].id, "art_bbccddee22334455bbccddee22334455");
361        rm(dir);
362    }
363
364    #[test]
365    fn list_by_type() {
366        let (store, dir) = tmp_store();
367        store
368            .write(&make_record(
369                "art_aabbccdd11223344aabbccdd11223344",
370                "application/vnd.treeship.action.v1+json",
371            ))
372            .unwrap();
373        store
374            .write(&make_record(
375                "art_bbccddee22334455bbccddee22334455",
376                "application/vnd.treeship.approval.v1+json",
377            ))
378            .unwrap();
379
380        let actions = store.list_by_type("application/vnd.treeship.action.v1+json");
381        assert_eq!(actions.len(), 1);
382        rm(dir);
383    }
384
385    #[test]
386    fn persist_across_opens() {
387        let (store, dir) = tmp_store();
388        let id = "art_aabbccdd11223344aabbccdd11223344";
389        store
390            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
391            .unwrap();
392        drop(store);
393
394        let store2 = Store::open(&dir).unwrap();
395        assert!(store2.exists(id));
396        assert_eq!(store2.list().len(), 1);
397        rm(dir);
398    }
399
400    #[test]
401    fn not_found_error() {
402        let (store, dir) = tmp_store();
403        assert!(store.read("art_doesnotexist1234567890123456").is_err());
404        rm(dir);
405    }
406
407    #[test]
408    fn set_hub_url() {
409        let (store, dir) = tmp_store();
410        let id = "art_aabbccdd11223344aabbccdd11223344";
411        store
412            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
413            .unwrap();
414        store
415            .set_hub_url(
416                id,
417                "https://treeship.dev/verify/art_aabbccdd11223344aabbccdd11223344",
418            )
419            .unwrap();
420        let rec = store.read(id).unwrap();
421        assert_eq!(
422            rec.hub_url.as_deref(),
423            Some("https://treeship.dev/verify/art_aabbccdd11223344aabbccdd11223344")
424        );
425        rm(dir);
426    }
427}
428
429#[cfg(test)]
430mod path_traversal_tests {
431    use super::*;
432
433    fn record_with_id(id: &str) -> Record {
434        Record {
435            artifact_id: id.to_string(),
436            digest: "sha256:00".into(),
437            payload_type: "application/vnd.in-toto+json".into(),
438            key_id: "k".into(),
439            signed_at: "2026-01-01T00:00:00Z".into(),
440            parent_id: None,
441            envelope: Envelope {
442                payload: "e30".into(),
443                payload_type: "application/vnd.in-toto+json".into(),
444                signatures: vec![],
445            },
446            hub_url: None,
447            anchors: Vec::new(),
448        }
449    }
450
451    /// The vulnerability, as it actually behaved.
452    ///
453    /// `hub pull` writes the `artifact_id` the *server* returned, and
454    /// `artifact_path` used to `join` it unvalidated. A malicious or
455    /// compromised Hub could therefore write an attacker-chosen `.json` file
456    /// anywhere the process could write. Before the fix this test's escaped
457    /// file existed.
458    #[test]
459    fn write_cannot_escape_the_store_directory() {
460        let tmp = tempfile::tempdir().unwrap();
461        let store_dir = tmp.path().join("a").join("b").join("store");
462        let store = Store::open(&store_dir).unwrap();
463
464        for id in [
465            "../../escaped",
466            "../../../etc/cron.d/x",
467            "art_/../../escaped",
468            "/tmp/absolute",
469            "..",
470        ] {
471            let err = store.write(&record_with_id(id)).unwrap_err();
472            assert!(
473                matches!(err, StorageError::InvalidId(_)),
474                "id {id:?} should be rejected as malformed, got {err:?}"
475            );
476        }
477
478        let escaped = tmp.path().join("a").join("escaped.json");
479        assert!(!escaped.exists(), "a file was written outside the store");
480        assert!(!tmp.path().join("absolute.json").exists());
481    }
482
483    /// The check must not be so strict it rejects real ids -- a validator that
484    /// blocks everything closes the hole and the feature together.
485    #[test]
486    fn well_formed_ids_still_write_and_read() {
487        let tmp = tempfile::tempdir().unwrap();
488        let store = Store::open(tmp.path()).unwrap();
489
490        let id = "art_0123456789abcdef0123456789abcdef";
491        store.write(&record_with_id(id)).expect("write a valid id");
492        assert!(store.exists(id));
493        assert_eq!(store.read(id).unwrap().artifact_id, id);
494    }
495
496    /// `exists` took an unvalidated path to the filesystem. It now answers
497    /// false rather than probing, which is also the honest answer: a malformed
498    /// id cannot name a stored artifact.
499    #[test]
500    fn exists_reports_false_for_malformed_ids_without_probing() {
501        let tmp = tempfile::tempdir().unwrap();
502        let store = Store::open(tmp.path()).unwrap();
503        assert!(!store.exists("../../../etc/passwd"));
504        assert!(!store.exists(""));
505        assert!(!store.exists("art_nothex0000000000000000000000zz"));
506    }
507}