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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Record {
15 pub artifact_id: ArtifactId,
16 pub digest: String, pub payload_type: String,
18 pub key_id: String,
19 pub signed_at: String, #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub anchors: Vec<RecordAnchor>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct RecordAnchor {
44 pub mechanism: String,
46 pub observed_at: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub reference: Option<String>,
61}
62
63#[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#[derive(Debug)]
81pub enum StorageError {
82 Io(io::Error),
83 Json(serde_json::Error),
84 EmptyId,
85 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
116pub struct Store {
122 dir: PathBuf,
123 index: Arc<RwLock<Index>>,
124}
125
126impl Store {
127 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 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 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 pub fn exists(&self, id: &str) -> bool {
177 self.artifact_path(id).is_ok_and(|p| p.exists())
180 }
181
182 pub fn list(&self) -> Vec<IndexEntry> {
184 let idx = self.index.read().unwrap();
185 idx.entries.iter().rev().cloned().collect()
186 }
187
188 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 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 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 pub fn latest(&self) -> Option<IndexEntry> {
217 self.index.read().unwrap().entries.last().cloned()
218 }
219
220 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 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 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 #[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 #[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 #[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}