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 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
132pub struct Store {
138 dir: PathBuf,
139 index: Arc<RwLock<Index>>,
140}
141
142impl Store {
143 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 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 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 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 pub fn exists(&self, id: &str) -> bool {
225 self.artifact_path(id).is_ok_and(|p| p.exists())
228 }
229
230 pub fn list(&self) -> Vec<IndexEntry> {
232 let idx = self.index.read().unwrap();
233 idx.entries.iter().rev().cloned().collect()
234 }
235
236 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 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 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 pub fn latest(&self) -> Option<IndexEntry> {
265 self.index.read().unwrap().entries.last().cloned()
266 }
267
268 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 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 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 #[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 #[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 #[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}