Skip to main content

treeship_core/storage/
mod.rs

1use std::{
2    collections::HashMap,
3    fs,
4    io::{self, Write},
5    path::{Path, PathBuf},
6    sync::{Arc, RwLock},
7};
8
9use serde::{Deserialize, Serialize};
10
11use crate::attestation::{ArtifactId, Envelope};
12
13/// The on-disk record for one stored artifact.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Record {
16    pub artifact_id: ArtifactId,
17    pub digest: String, // "sha256:<hex>"
18    pub payload_type: String,
19    pub key_id: String,
20    pub signed_at: String, // RFC 3339
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub parent_id: Option<String>,
23    pub envelope: Envelope,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub hub_url: Option<String>,
26}
27
28/// A lightweight index entry — stored in index.json for fast listing
29/// without reading every artifact file.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct IndexEntry {
32    pub id: ArtifactId,
33    pub payload_type: String,
34    pub signed_at: String,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub parent_id: Option<String>,
37}
38
39#[derive(Serialize, Deserialize, Default)]
40struct Index {
41    entries: Vec<IndexEntry>,
42}
43
44/// Errors from storage operations.
45#[derive(Debug)]
46pub enum StorageError {
47    Io(io::Error),
48    Json(serde_json::Error),
49    EmptyId,
50    NotFound(ArtifactId),
51}
52
53impl std::fmt::Display for StorageError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::Io(e) => write!(f, "storage io: {}", e),
57            Self::Json(e) => write!(f, "storage json: {}", e),
58            Self::EmptyId => write!(f, "artifact_id must not be empty"),
59            Self::NotFound(id) => write!(f, "artifact not found: {}", id),
60        }
61    }
62}
63
64impl std::error::Error for StorageError {}
65impl From<io::Error> for StorageError {
66    fn from(e: io::Error) -> Self {
67        Self::Io(e)
68    }
69}
70impl From<serde_json::Error> for StorageError {
71    fn from(e: serde_json::Error) -> Self {
72        Self::Json(e)
73    }
74}
75
76/// Local artifact store. Thread-safe via internal RwLock.
77///
78/// Artifacts are stored as `<artifact_id>.json` files.
79/// Content-addressed IDs mean same content → same filename → idempotent writes.
80/// An `index.json` tracks all artifact IDs for O(1) listing.
81pub struct Store {
82    dir: PathBuf,
83    index: Arc<RwLock<Index>>,
84}
85
86impl Store {
87    /// Opens or creates an artifact store at `dir`.
88    pub fn open(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
89        let dir = dir.as_ref().to_path_buf();
90        fs::create_dir_all(&dir)?;
91
92        let index = read_index(&dir)?;
93        Ok(Self {
94            dir,
95            index: Arc::new(RwLock::new(index)),
96        })
97    }
98
99    /// Writes an artifact record. Idempotent: writing the same artifact
100    /// twice has no effect beyond overwriting with identical content.
101    pub fn write(&self, record: &Record) -> Result<(), StorageError> {
102        if record.artifact_id.is_empty() {
103            return Err(StorageError::EmptyId);
104        }
105
106        let json = serde_json::to_vec_pretty(record)?;
107        write_600(&self.artifact_path(&record.artifact_id), &json)?;
108
109        let mut idx = self.index.write().unwrap();
110        let entry = IndexEntry {
111            id: record.artifact_id.clone(),
112            payload_type: record.payload_type.clone(),
113            signed_at: record.signed_at.clone(),
114            parent_id: record.parent_id.clone(),
115        };
116        add_to_index(&mut idx, entry);
117        write_600(
118            &self.dir.join("index.json"),
119            &serde_json::to_vec_pretty(&*idx)?,
120        )?;
121
122        Ok(())
123    }
124
125    /// Reads an artifact by ID.
126    pub fn read(&self, id: &str) -> Result<Record, StorageError> {
127        let path = self.artifact_path(id);
128        if !path.exists() {
129            return Err(StorageError::NotFound(id.to_string()));
130        }
131        let bytes = fs::read(&path)?;
132        Ok(serde_json::from_slice(&bytes)?)
133    }
134
135    /// Returns true if an artifact with this ID is stored locally.
136    pub fn exists(&self, id: &str) -> bool {
137        self.artifact_path(id).exists()
138    }
139
140    /// Lists index entries, most recent first.
141    pub fn list(&self) -> Vec<IndexEntry> {
142        let idx = self.index.read().unwrap();
143        idx.entries.iter().rev().cloned().collect()
144    }
145
146    /// Lists index entries filtered to a specific payload type.
147    pub fn list_by_type(&self, payload_type: &str) -> Vec<IndexEntry> {
148        self.list()
149            .into_iter()
150            .filter(|e| e.payload_type == payload_type)
151            .collect()
152    }
153
154    /// Updates the hub_url on a stored record after a successful dock push.
155    pub fn set_hub_url(&self, id: &str, hub_url: &str) -> Result<(), StorageError> {
156        let mut record = self.read(id)?;
157        record.hub_url = Some(hub_url.to_string());
158        self.write(&record)
159    }
160
161    /// Returns the most recently stored artifact, if any.
162    pub fn latest(&self) -> Option<IndexEntry> {
163        self.index.read().unwrap().entries.last().cloned()
164    }
165
166    fn artifact_path(&self, id: &str) -> PathBuf {
167        self.dir.join(format!("{}.json", id))
168    }
169}
170
171fn read_index(dir: &Path) -> Result<Index, StorageError> {
172    let path = dir.join("index.json");
173    if !path.exists() {
174        return Ok(Index::default());
175    }
176    let bytes = fs::read(&path)?;
177    Ok(serde_json::from_slice(&bytes)?)
178}
179
180fn add_to_index(idx: &mut Index, entry: IndexEntry) {
181    // Deduplicate.
182    if !idx.entries.iter().any(|e| e.id == entry.id) {
183        idx.entries.push(entry);
184    }
185}
186
187fn write_600(path: &Path, data: &[u8]) -> Result<(), StorageError> {
188    let mut f = fs::OpenOptions::new()
189        .write(true)
190        .create(true)
191        .truncate(true)
192        .open(path)?;
193    f.write_all(data)?;
194    #[cfg(unix)]
195    {
196        use std::os::unix::fs::PermissionsExt;
197        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
198    }
199    Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
206
207    fn make_record(id: &str, pt: &str) -> Record {
208        Record {
209            artifact_id: id.to_string(),
210            digest: format!("sha256:{}", "a".repeat(64)),
211            payload_type: pt.to_string(),
212            key_id: "key_test".into(),
213            signed_at: "2026-03-26T10:00:00Z".into(),
214            parent_id: None,
215            envelope: Envelope {
216                payload: URL_SAFE_NO_PAD.encode(b"{\"type\":\"test\"}"),
217                payload_type: pt.to_string(),
218                signatures: vec![crate::attestation::Signature {
219                    keyid: "key_test".into(),
220                    sig: URL_SAFE_NO_PAD.encode(b"fake_sig_64_bytes_padded_to_length_xxxxxxxxxx"),
221                }],
222            },
223            hub_url: None,
224        }
225    }
226
227    fn tmp_store() -> (Store, PathBuf) {
228        let mut p = std::env::temp_dir();
229        p.push(format!("treeship-storage-test-{}", {
230            use rand::RngCore;
231            let mut b = [0u8; 4];
232            rand::thread_rng().fill_bytes(&mut b);
233            b.iter().fold(String::new(), |mut s, byte| {
234                s.push_str(&format!("{:02x}", byte));
235                s
236            })
237        }));
238        let store = Store::open(&p).unwrap();
239        (store, p)
240    }
241
242    fn rm(p: PathBuf) {
243        let _ = fs::remove_dir_all(p);
244    }
245
246    #[test]
247    fn write_and_read() {
248        let (store, dir) = tmp_store();
249        let id = "art_aabbccdd11223344aabbccdd11223344";
250        let pt = "application/vnd.treeship.action.v1+json";
251        store.write(&make_record(id, pt)).unwrap();
252
253        let rec = store.read(id).unwrap();
254        assert_eq!(rec.artifact_id, id);
255        assert_eq!(rec.payload_type, pt);
256        rm(dir);
257    }
258
259    #[test]
260    fn exists() {
261        let (store, dir) = tmp_store();
262        let id = "art_aabbccdd11223344aabbccdd11223344";
263        assert!(!store.exists(id));
264        store
265            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
266            .unwrap();
267        assert!(store.exists(id));
268        rm(dir);
269    }
270
271    #[test]
272    fn idempotent_write() {
273        let (store, dir) = tmp_store();
274        let id = "art_aabbccdd11223344aabbccdd11223344";
275        let r = make_record(id, "application/vnd.treeship.action.v1+json");
276        store.write(&r).unwrap();
277        store.write(&r).unwrap();
278        assert_eq!(store.list().len(), 1);
279        rm(dir);
280    }
281
282    #[test]
283    fn list_order() {
284        let (store, dir) = tmp_store();
285        let pt = "application/vnd.treeship.action.v1+json";
286        store
287            .write(&make_record("art_aabbccdd11223344aabbccdd11223344", pt))
288            .unwrap();
289        store
290            .write(&make_record("art_bbccddee22334455bbccddee22334455", pt))
291            .unwrap();
292
293        let list = store.list();
294        assert_eq!(list.len(), 2);
295        // Most recent first — second write appears first.
296        assert_eq!(list[0].id, "art_bbccddee22334455bbccddee22334455");
297        rm(dir);
298    }
299
300    #[test]
301    fn list_by_type() {
302        let (store, dir) = tmp_store();
303        store
304            .write(&make_record(
305                "art_aabbccdd11223344aabbccdd11223344",
306                "application/vnd.treeship.action.v1+json",
307            ))
308            .unwrap();
309        store
310            .write(&make_record(
311                "art_bbccddee22334455bbccddee22334455",
312                "application/vnd.treeship.approval.v1+json",
313            ))
314            .unwrap();
315
316        let actions = store.list_by_type("application/vnd.treeship.action.v1+json");
317        assert_eq!(actions.len(), 1);
318        rm(dir);
319    }
320
321    #[test]
322    fn persist_across_opens() {
323        let (store, dir) = tmp_store();
324        let id = "art_aabbccdd11223344aabbccdd11223344";
325        store
326            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
327            .unwrap();
328        drop(store);
329
330        let store2 = Store::open(&dir).unwrap();
331        assert!(store2.exists(id));
332        assert_eq!(store2.list().len(), 1);
333        rm(dir);
334    }
335
336    #[test]
337    fn not_found_error() {
338        let (store, dir) = tmp_store();
339        assert!(store.read("art_doesnotexist1234567890123456").is_err());
340        rm(dir);
341    }
342
343    #[test]
344    fn set_hub_url() {
345        let (store, dir) = tmp_store();
346        let id = "art_aabbccdd11223344aabbccdd11223344";
347        store
348            .write(&make_record(id, "application/vnd.treeship.action.v1+json"))
349            .unwrap();
350        store
351            .set_hub_url(
352                id,
353                "https://treeship.dev/verify/art_aabbccdd11223344aabbccdd11223344",
354            )
355            .unwrap();
356        let rec = store.read(id).unwrap();
357        assert_eq!(
358            rec.hub_url.as_deref(),
359            Some("https://treeship.dev/verify/art_aabbccdd11223344aabbccdd11223344")
360        );
361        rm(dir);
362    }
363}