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    /// A prefix matched more than one stored artifact.
91    AmbiguousPrefix {
92        prefix: String,
93        candidates: Vec<ArtifactId>,
94    },
95}
96
97impl std::fmt::Display for StorageError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::Io(e) => write!(f, "storage io: {}", e),
101            Self::Json(e) => write!(f, "storage json: {}", e),
102            Self::EmptyId => write!(f, "artifact_id must not be empty"),
103            Self::AmbiguousPrefix { prefix, candidates } => {
104                let shown: Vec<&str> = candidates.iter().take(10).map(|s| s.as_str()).collect();
105                let more = candidates.len().saturating_sub(shown.len());
106                write!(
107                    f,
108                    "{prefix} is ambiguous: {} artifacts start with it. Give more of the id. Candidates: {}{}",
109                    candidates.len(),
110                    shown.join(", "),
111                    if more > 0 { format!(", and {more} more") } else { String::new() }
112                )
113            }
114            Self::InvalidId(e) => write!(f, "storage: {}", e),
115            Self::NotFound(id) => write!(f, "artifact not found: {}", id),
116        }
117    }
118}
119
120impl std::error::Error for StorageError {}
121impl From<io::Error> for StorageError {
122    fn from(e: io::Error) -> Self {
123        Self::Io(e)
124    }
125}
126impl From<serde_json::Error> for StorageError {
127    fn from(e: serde_json::Error) -> Self {
128        Self::Json(e)
129    }
130}
131
132/// Local artifact store. Thread-safe via internal RwLock.
133///
134/// Artifacts are stored as `<artifact_id>.json` files.
135/// Content-addressed IDs mean same content → same filename → idempotent writes.
136/// An `index.json` tracks all artifact IDs for O(1) listing.
137pub struct Store {
138    dir: PathBuf,
139    index: Arc<RwLock<Index>>,
140}
141
142impl Store {
143    /// Opens or creates an artifact store at `dir`.
144    pub fn open(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
145        let dir = dir.as_ref().to_path_buf();
146        fs::create_dir_all(&dir)?;
147
148        let index = read_index(&dir)?;
149        Ok(Self {
150            dir,
151            index: Arc::new(RwLock::new(index)),
152        })
153    }
154
155    /// Writes an artifact record. Idempotent: writing the same artifact
156    /// twice has no effect beyond overwriting with identical content.
157    pub fn write(&self, record: &Record) -> Result<(), StorageError> {
158        if record.artifact_id.is_empty() {
159            return Err(StorageError::EmptyId);
160        }
161
162        let json = serde_json::to_vec_pretty(record)?;
163        write_600(&self.artifact_path(&record.artifact_id)?, &json)?;
164
165        let mut idx = self.index.write().unwrap();
166        let entry = IndexEntry {
167            id: record.artifact_id.clone(),
168            payload_type: record.payload_type.clone(),
169            signed_at: record.signed_at.clone(),
170            parent_id: record.parent_id.clone(),
171        };
172        add_to_index(&mut idx, entry);
173        write_600(
174            &self.dir.join("index.json"),
175            &serde_json::to_vec_pretty(&*idx)?,
176        )?;
177
178        Ok(())
179    }
180
181    /// Reads an artifact by ID.
182    pub fn read(&self, id: &str) -> Result<Record, StorageError> {
183        let path = self.artifact_path(id)?;
184        if !path.exists() {
185            return Err(StorageError::NotFound(id.to_string()));
186        }
187        let bytes = fs::read(&path)?;
188        Ok(serde_json::from_slice(&bytes)?)
189    }
190
191    /// Resolve a full id, or a unique prefix of one, to the stored id. The
192    /// CLI's own hints used to print a 16-character id that nothing accepted
193    /// (film findings 2026-09-22, #5). A prefix is `art_` plus at least eight
194    /// hex characters; anything shorter, malformed, or matching more than
195    /// one artifact is refused rather than guessed.
196    pub fn resolve_id(&self, id_or_prefix: &str) -> Result<ArtifactId, StorageError> {
197        if self.exists(id_or_prefix) {
198            return Ok(id_or_prefix.to_string());
199        }
200        let p = id_or_prefix.trim();
201        let hex = p.strip_prefix("art_").unwrap_or("");
202        if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
203            return Err(StorageError::NotFound(id_or_prefix.to_string()));
204        }
205        let idx = self.index.read().unwrap();
206        let mut candidates: Vec<ArtifactId> = idx
207            .entries
208            .iter()
209            .filter(|e| e.id.starts_with(p))
210            .map(|e| e.id.clone())
211            .collect();
212        candidates.dedup();
213        match candidates.len() {
214            0 => Err(StorageError::NotFound(id_or_prefix.to_string())),
215            1 => Ok(candidates.remove(0)),
216            _ => Err(StorageError::AmbiguousPrefix {
217                prefix: p.to_string(),
218                candidates,
219            }),
220        }
221    }
222
223    /// Returns true if an artifact with this ID is stored locally.
224    pub fn exists(&self, id: &str) -> bool {
225        // A malformed id cannot name a stored artifact, so it does not exist.
226        // Deliberately not a filesystem probe on an unvalidated path.
227        self.artifact_path(id).is_ok_and(|p| p.exists())
228    }
229
230    /// Lists index entries, most recent first.
231    pub fn list(&self) -> Vec<IndexEntry> {
232        let idx = self.index.read().unwrap();
233        idx.entries.iter().rev().cloned().collect()
234    }
235
236    /// Lists index entries filtered to a specific payload type.
237    pub fn list_by_type(&self, payload_type: &str) -> Vec<IndexEntry> {
238        self.list()
239            .into_iter()
240            .filter(|e| e.payload_type == payload_type)
241            .collect()
242    }
243
244    /// Updates the hub_url on a stored record after a successful dock push.
245    pub fn set_hub_url(&self, id: &str, hub_url: &str) -> Result<(), StorageError> {
246        let mut record = self.read(id)?;
247        record.hub_url = Some(hub_url.to_string());
248        self.write(&record)
249    }
250
251    /// Record that an external witness saw this artifact.
252    ///
253    /// Appends rather than replaces: two witnesses are strictly better
254    /// evidence than one, and they fail differently -- a Hub anchor requires
255    /// trusting the Hub, an OpenTimestamps anchor does not. Collapsing them
256    /// would discard that difference.
257    pub fn add_anchor(&self, id: &str, anchor: RecordAnchor) -> Result<(), StorageError> {
258        let mut record = self.read(id)?;
259        record.anchors.push(anchor);
260        self.write(&record)
261    }
262
263    /// Returns the most recently stored artifact, if any.
264    pub fn latest(&self) -> Option<IndexEntry> {
265        self.index.read().unwrap().entries.last().cloned()
266    }
267
268    /// Resolve an artifact id to its on-disk path.
269    ///
270    /// Validates the id first. `join` on an attacker-influenced string is a
271    /// path-traversal primitive: `../../x` resolved to a file outside the
272    /// store, and `hub pull` writes whatever `artifact_id` the server sends
273    /// back. `parse_artifact_id` already existed and enforces
274    /// `art_<32 hex>` -- which cannot contain a separator or a dot -- it was
275    /// simply never called on this path.
276    fn artifact_path(&self, id: &str) -> Result<PathBuf, StorageError> {
277        let id = parse_artifact_id(id).map_err(StorageError::InvalidId)?;
278        Ok(self.dir.join(format!("{}.json", id)))
279    }
280}
281
282fn read_index(dir: &Path) -> Result<Index, StorageError> {
283    let path = dir.join("index.json");
284    if !path.exists() {
285        return Ok(Index::default());
286    }
287    let bytes = fs::read(&path)?;
288    Ok(serde_json::from_slice(&bytes)?)
289}
290
291fn add_to_index(idx: &mut Index, entry: IndexEntry) {
292    // Deduplicate.
293    if !idx.entries.iter().any(|e| e.id == entry.id) {
294        idx.entries.push(entry);
295    }
296}
297
298fn write_600(path: &Path, data: &[u8]) -> Result<(), StorageError> {
299    let mut f = fs::OpenOptions::new()
300        .write(true)
301        .create(true)
302        .truncate(true)
303        .open(path)?;
304    f.write_all(data)?;
305    #[cfg(unix)]
306    {
307        use std::os::unix::fs::PermissionsExt;
308        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
309    }
310    Ok(())
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
317
318    fn make_record(id: &str, pt: &str) -> Record {
319        Record {
320            artifact_id: id.to_string(),
321            digest: format!("sha256:{}", "a".repeat(64)),
322            payload_type: pt.to_string(),
323            key_id: "key_test".into(),
324            signed_at: "2026-03-26T10:00:00Z".into(),
325            parent_id: None,
326            envelope: Envelope {
327                payload: URL_SAFE_NO_PAD.encode(b"{\"type\":\"test\"}"),
328                payload_type: pt.to_string(),
329                signatures: vec![crate::attestation::Signature {
330                    keyid: "key_test".into(),
331                    sig: URL_SAFE_NO_PAD.encode(b"fake_sig_64_bytes_padded_to_length_xxxxxxxxxx"),
332                }],
333            },
334            hub_url: None,
335            anchors: Vec::new(),
336        }
337    }
338
339    fn tmp_store() -> (Store, PathBuf) {
340        let mut p = std::env::temp_dir();
341        p.push(format!("treeship-storage-test-{}", {
342            use rand::RngCore;
343            let mut b = [0u8; 4];
344            rand::thread_rng().fill_bytes(&mut b);
345            b.iter().fold(String::new(), |mut s, byte| {
346                s.push_str(&format!("{:02x}", byte));
347                s
348            })
349        }));
350        let store = Store::open(&p).unwrap();
351        (store, p)
352    }
353
354    fn rm(p: PathBuf) {
355        let _ = fs::remove_dir_all(p);
356    }
357
358    #[test]
359    fn write_and_read() {
360        let (store, dir) = tmp_store();
361        let id = "art_aabbccdd11223344aabbccdd11223344";
362        let pt = "application/vnd.treeship.action.v1+json";
363        store.write(&make_record(id, pt)).unwrap();
364
365        let rec = store.read(id).unwrap();
366        assert_eq!(rec.artifact_id, id);
367        assert_eq!(rec.payload_type, pt);
368        rm(dir);
369    }
370
371    #[test]
372    fn exists() {
373        let (store, dir) = tmp_store();
374        let id = "art_aabbccdd11223344aabbccdd11223344";
375        assert!(!store.exists(id));
376        store
377            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
378            .unwrap();
379        assert!(store.exists(id));
380        rm(dir);
381    }
382
383    #[test]
384    fn idempotent_write() {
385        let (store, dir) = tmp_store();
386        let id = "art_aabbccdd11223344aabbccdd11223344";
387        let r = make_record(id, "application/vnd.treeship.action.v1+json");
388        store.write(&r).unwrap();
389        store.write(&r).unwrap();
390        assert_eq!(store.list().len(), 1);
391        rm(dir);
392    }
393
394    #[test]
395    fn list_order() {
396        let (store, dir) = tmp_store();
397        let pt = "application/vnd.treeship.action.v1+json";
398        store
399            .write(&make_record("art_aabbccdd11223344aabbccdd11223344", pt))
400            .unwrap();
401        store
402            .write(&make_record("art_bbccddee22334455bbccddee22334455", pt))
403            .unwrap();
404
405        let list = store.list();
406        assert_eq!(list.len(), 2);
407        // Most recent first — second write appears first.
408        assert_eq!(list[0].id, "art_bbccddee22334455bbccddee22334455");
409        rm(dir);
410    }
411
412    #[test]
413    fn list_by_type() {
414        let (store, dir) = tmp_store();
415        store
416            .write(&make_record(
417                "art_aabbccdd11223344aabbccdd11223344",
418                "application/vnd.treeship.action.v1+json",
419            ))
420            .unwrap();
421        store
422            .write(&make_record(
423                "art_bbccddee22334455bbccddee22334455",
424                "application/vnd.treeship.approval.v1+json",
425            ))
426            .unwrap();
427
428        let actions = store.list_by_type("application/vnd.treeship.action.v1+json");
429        assert_eq!(actions.len(), 1);
430        rm(dir);
431    }
432
433    #[test]
434    fn persist_across_opens() {
435        let (store, dir) = tmp_store();
436        let id = "art_aabbccdd11223344aabbccdd11223344";
437        store
438            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
439            .unwrap();
440        drop(store);
441
442        let store2 = Store::open(&dir).unwrap();
443        assert!(store2.exists(id));
444        assert_eq!(store2.list().len(), 1);
445        rm(dir);
446    }
447
448    #[test]
449    fn not_found_error() {
450        let (store, dir) = tmp_store();
451        assert!(store.read("art_doesnotexist1234567890123456").is_err());
452        rm(dir);
453    }
454
455    #[test]
456    fn set_hub_url() {
457        let (store, dir) = tmp_store();
458        let id = "art_aabbccdd11223344aabbccdd11223344";
459        store
460            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
461            .unwrap();
462        store
463            .set_hub_url(
464                id,
465                "https://treeship.dev/verify/art_aabbccdd11223344aabbccdd11223344",
466            )
467            .unwrap();
468        let rec = store.read(id).unwrap();
469        assert_eq!(
470            rec.hub_url.as_deref(),
471            Some("https://treeship.dev/verify/art_aabbccdd11223344aabbccdd11223344")
472        );
473        rm(dir);
474    }
475}
476
477#[cfg(test)]
478mod path_traversal_tests {
479    use super::*;
480
481    fn record_with_id(id: &str) -> Record {
482        Record {
483            artifact_id: id.to_string(),
484            digest: "sha256:00".into(),
485            payload_type: "application/vnd.in-toto+json".into(),
486            key_id: "k".into(),
487            signed_at: "2026-01-01T00:00:00Z".into(),
488            parent_id: None,
489            envelope: Envelope {
490                payload: "e30".into(),
491                payload_type: "application/vnd.in-toto+json".into(),
492                signatures: vec![],
493            },
494            hub_url: None,
495            anchors: Vec::new(),
496        }
497    }
498
499    /// The vulnerability, as it actually behaved.
500    ///
501    /// `hub pull` writes the `artifact_id` the *server* returned, and
502    /// `artifact_path` used to `join` it unvalidated. A malicious or
503    /// compromised Hub could therefore write an attacker-chosen `.json` file
504    /// anywhere the process could write. Before the fix this test's escaped
505    /// file existed.
506    #[test]
507    fn write_cannot_escape_the_store_directory() {
508        let tmp = tempfile::tempdir().unwrap();
509        let store_dir = tmp.path().join("a").join("b").join("store");
510        let store = Store::open(&store_dir).unwrap();
511
512        for id in [
513            "../../escaped",
514            "../../../etc/cron.d/x",
515            "art_/../../escaped",
516            "/tmp/absolute",
517            "..",
518        ] {
519            let err = store.write(&record_with_id(id)).unwrap_err();
520            assert!(
521                matches!(err, StorageError::InvalidId(_)),
522                "id {id:?} should be rejected as malformed, got {err:?}"
523            );
524        }
525
526        let escaped = tmp.path().join("a").join("escaped.json");
527        assert!(!escaped.exists(), "a file was written outside the store");
528        assert!(!tmp.path().join("absolute.json").exists());
529    }
530
531    /// The check must not be so strict it rejects real ids -- a validator that
532    /// blocks everything closes the hole and the feature together.
533    #[test]
534    fn well_formed_ids_still_write_and_read() {
535        let tmp = tempfile::tempdir().unwrap();
536        let store = Store::open(tmp.path()).unwrap();
537
538        let id = "art_0123456789abcdef0123456789abcdef";
539        store.write(&record_with_id(id)).expect("write a valid id");
540        assert!(store.exists(id));
541        assert_eq!(store.read(id).unwrap().artifact_id, id);
542    }
543
544    /// `exists` took an unvalidated path to the filesystem. It now answers
545    /// false rather than probing, which is also the honest answer: a malformed
546    /// id cannot name a stored artifact.
547    #[test]
548    fn exists_reports_false_for_malformed_ids_without_probing() {
549        let tmp = tempfile::tempdir().unwrap();
550        let store = Store::open(tmp.path()).unwrap();
551        assert!(!store.exists("../../../etc/passwd"));
552        assert!(!store.exists(""));
553        assert!(!store.exists("art_nothex0000000000000000000000zz"));
554    }
555}