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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Record {
16 pub artifact_id: ArtifactId,
17 pub digest: String, pub payload_type: String,
19 pub key_id: String,
20 pub signed_at: String, #[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#[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#[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
76pub struct Store {
82 dir: PathBuf,
83 index: Arc<RwLock<Index>>,
84}
85
86impl Store {
87 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 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 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 pub fn exists(&self, id: &str) -> bool {
137 self.artifact_path(id).exists()
138 }
139
140 pub fn list(&self) -> Vec<IndexEntry> {
142 let idx = self.index.read().unwrap();
143 idx.entries.iter().rev().cloned().collect()
144 }
145
146 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 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 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 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 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}