Skip to main content

vasari_core/store/
mod.rs

1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3
4use flate2::read::GzDecoder;
5use flate2::write::GzEncoder;
6use flate2::Compression;
7
8use crate::error::VasariError;
9use crate::schema::{Attribution, AttributionTarget, Node, NodeId};
10
11/// Git-like content-addressed object store at `<repo>/.vasari/`.
12///
13/// Layout:
14///   objects/<sha[0..2]>/<sha[2..]>   canonical JSON nodes, gzip-compressed
15///   index/targets/<encoded-path>/<start>-<end>  → attribution node IDs (newline-separated)
16///   refs/                             human-readable refs
17///   HEAD                              current intent context
18#[derive(Debug)]
19pub struct ObjectStore {
20    root: PathBuf,
21}
22
23impl ObjectStore {
24    /// On-disk store format generation. Bumped when a change to node identity
25    /// or layout makes older stores unreadable. v2 introduced per-turn Intents
26    /// and the `WholeFile` attribution target — both change node hashes, so a
27    /// pre-v2 store no longer resolves and must be re-ingested.
28    pub const FORMAT_VERSION: &'static str = "2";
29
30    /// Open (or initialize) the store at `<repo_root>/.vasari/`.
31    ///
32    /// A fresh store is stamped with [`FORMAT_VERSION`]. An existing store whose
33    /// stamp differs (or is missing, i.e. pre-v2) is refused with
34    /// [`VasariError::IncompatibleStore`] — re-ingest is the migration path.
35    pub fn open(repo_root: &Path) -> Result<Self, VasariError> {
36        let root = repo_root.join(".vasari");
37        // Detect a pre-existing store BEFORE create_dir_all materializes objects/.
38        let preexisting = root.join("objects").exists();
39        let format_path = root.join("format");
40
41        std::fs::create_dir_all(root.join("objects"))?;
42        std::fs::create_dir_all(root.join("index").join("targets"))?;
43        std::fs::create_dir_all(root.join("refs"))?;
44        if !root.join("HEAD").exists() {
45            std::fs::write(root.join("HEAD"), "")?;
46        }
47
48        if preexisting {
49            // Missing format file == pre-v2 store.
50            let found = std::fs::read_to_string(&format_path)
51                .ok()
52                .map(|s| s.trim().to_string())
53                .filter(|s| !s.is_empty())
54                .unwrap_or_else(|| "1 (pre-v2)".to_string());
55            if found != Self::FORMAT_VERSION {
56                return Err(VasariError::IncompatibleStore {
57                    found,
58                    expected: Self::FORMAT_VERSION.to_string(),
59                });
60            }
61        } else {
62            std::fs::write(&format_path, Self::FORMAT_VERSION)?;
63        }
64
65        Ok(Self { root })
66    }
67
68    /// Write a node to the object store. Idempotent: re-writing the same ID is a no-op.
69    pub fn put(&self, node: &Node) -> Result<NodeId, VasariError> {
70        let id = node.id();
71        let (dir, file) = self.object_path(id)?;
72        if file.exists() {
73            return Ok(id.clone());
74        }
75        std::fs::create_dir_all(&dir)?;
76        let json = serde_json::to_vec(node)?;
77        let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
78        encoder.write_all(&json)?;
79        let compressed = encoder.finish()?;
80        std::fs::write(&file, compressed)?;
81
82        // Update the derivable index for Attribution nodes.
83        if let Node::Attribution(attr) = node {
84            self.index_attribution(attr)?;
85        }
86
87        Ok(id.clone())
88    }
89
90    /// Read a node by ID. Returns None if not present.
91    pub fn get(&self, id: &NodeId) -> Result<Option<Node>, VasariError> {
92        let (_, file) = self.object_path(id)?;
93        if !file.exists() {
94            return Ok(None);
95        }
96        let compressed = std::fs::read(&file)?;
97        let mut decoder = GzDecoder::new(&compressed[..]);
98        let mut json = Vec::new();
99        decoder.read_to_end(&mut json)?;
100        let node: Node = serde_json::from_slice(&json)?;
101        Ok(Some(node))
102    }
103
104    /// Look up attribution node IDs for a specific file:line target.
105    /// The index is rebuilt by `vasari fsck` if stale.
106    pub fn lookup_attributions(&self, path: &str, line: u32) -> Result<Vec<NodeId>, VasariError> {
107        // Scan all index entries for this path and find ranges covering `line`.
108        let path_dir = self
109            .root
110            .join("index")
111            .join("targets")
112            .join(encode_path(path));
113        if !path_dir.exists() {
114            return Ok(vec![]);
115        }
116        let mut ids = Vec::new();
117        for entry in std::fs::read_dir(&path_dir)? {
118            let entry = entry?;
119            let name = entry.file_name();
120            let name_str = name.to_string_lossy();
121            // Index file names: "<start>-<end>" for a range, or "whole" for a
122            // whole-file attribution (matches every line in the file).
123            let covers_line = if name_str == "whole" {
124                true
125            } else if let Some((start_s, end_s)) = name_str.split_once('-') {
126                match (start_s.parse::<u32>(), end_s.parse::<u32>()) {
127                    (Ok(start), Ok(end)) => line >= start && line <= end,
128                    _ => false,
129                }
130            } else {
131                false
132            };
133            if covers_line {
134                let content = std::fs::read_to_string(entry.path())?;
135                for id_str in content.lines() {
136                    // Validate hex format before accepting IDs from index files.
137                    if !id_str.is_empty()
138                        && id_str.len() >= 4
139                        && id_str.chars().all(|c| c.is_ascii_hexdigit())
140                    {
141                        ids.push(NodeId(id_str.to_string()));
142                    }
143                }
144            }
145        }
146        ids.sort_unstable_by(|a, b| a.0.cmp(&b.0));
147        ids.dedup();
148        Ok(ids)
149    }
150
151    /// Iterate all nodes in the object store. Used by `vasari sessions` and `vasari files`.
152    /// Walks the objects/ directory; no ordering guarantee.
153    pub fn iter_all(&self) -> Result<Vec<Node>, VasariError> {
154        let objects_dir = self.root.join("objects");
155        if !objects_dir.exists() {
156            return Ok(vec![]);
157        }
158        let mut nodes = Vec::new();
159        for prefix_entry in std::fs::read_dir(&objects_dir)? {
160            let prefix_entry = prefix_entry?;
161            let prefix = prefix_entry.file_name().to_string_lossy().to_string();
162            for obj_entry in std::fs::read_dir(prefix_entry.path())? {
163                let obj_entry = obj_entry?;
164                let suffix = obj_entry.file_name().to_string_lossy().to_string();
165                let id = NodeId(format!("{prefix}{suffix}"));
166                // Skip non-hex entries (e.g. .DS_Store or injected names).
167                match self.get(&id) {
168                    Ok(Some(node)) => nodes.push(node),
169                    Ok(None) | Err(VasariError::InvalidNodeId(_)) => continue,
170                    Err(e) => return Err(e),
171                }
172            }
173        }
174        Ok(nodes)
175    }
176
177    /// Resolve a (possibly abbreviated) node-id prefix to a full `NodeId`.
178    ///
179    /// Matches against object FILENAMES under `objects/<shard>/` — never
180    /// deserializes a node, never panics. Returns:
181    ///   • `Ok(id)`                          on a unique match
182    ///   • `Err(InvalidNodeId)`              for non-hex / too-short input
183    ///   • `Err(NodeNotFound)`               when nothing matches
184    ///   • `Err(AmbiguousPrefix { count })`  when 2+ nodes share the prefix
185    ///
186    /// Minimum length is 4 (2-char shard + 2-char body) to avoid matching an
187    /// entire shard. The on-disk layout is `objects/<sha[0..2]>/<sha[2..]>`,
188    /// so the shard is the lookup directory and the remainder is a filename
189    /// prefix — an O(files-in-shard) scan, not an O(all-nodes) deserialize.
190    pub fn resolve_prefix(&self, prefix: &str) -> Result<NodeId, VasariError> {
191        if prefix.len() < 4 || !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
192            return Err(VasariError::InvalidNodeId(prefix.to_string()));
193        }
194        let lower = prefix.to_ascii_lowercase();
195        let (shard, rest) = lower.split_at(2);
196        let shard_dir = self.root.join("objects").join(shard);
197        if !shard_dir.exists() {
198            return Err(VasariError::NodeNotFound(prefix.to_string()));
199        }
200
201        let mut matches: Vec<NodeId> = Vec::new();
202        for entry in std::fs::read_dir(&shard_dir)? {
203            let entry = entry?;
204            let fname = entry.file_name().to_string_lossy().to_string();
205            if fname.starts_with(rest) {
206                matches.push(NodeId(format!("{shard}{fname}")));
207            }
208        }
209
210        match matches.len() {
211            0 => Err(VasariError::NodeNotFound(prefix.to_string())),
212            1 => Ok(matches.pop().expect("len checked == 1")),
213            count => Err(VasariError::AmbiguousPrefix {
214                prefix: prefix.to_string(),
215                count,
216            }),
217        }
218    }
219
220    /// Rebuild all indexes from the object store. Called by `vasari fsck`.
221    pub fn rebuild_index(&self) -> Result<usize, VasariError> {
222        let objects_dir = self.root.join("objects");
223        let index_targets = self.root.join("index").join("targets");
224        if index_targets.exists() {
225            std::fs::remove_dir_all(&index_targets)?;
226        }
227        std::fs::create_dir_all(&index_targets)?;
228
229        let mut count = 0;
230        for prefix_entry in std::fs::read_dir(&objects_dir)? {
231            let prefix_entry = prefix_entry?;
232            for obj_entry in std::fs::read_dir(prefix_entry.path())? {
233                let obj_entry = obj_entry?;
234                let prefix = prefix_entry.file_name();
235                let suffix = obj_entry.file_name();
236                let id = NodeId(format!(
237                    "{}{}",
238                    prefix.to_string_lossy(),
239                    suffix.to_string_lossy()
240                ));
241                // Skip non-hex entries (defense-in-depth against injected names).
242                match self.get(&id) {
243                    Ok(Some(Node::Attribution(attr))) => {
244                        self.index_attribution(&attr)?;
245                        count += 1;
246                    }
247                    Ok(_) | Err(VasariError::InvalidNodeId(_)) => continue,
248                    Err(e) => return Err(e),
249                }
250            }
251        }
252        Ok(count)
253    }
254
255    fn object_path(&self, id: &NodeId) -> Result<(PathBuf, PathBuf), VasariError> {
256        let s = id.as_str();
257        if s.len() < 4 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
258            return Err(VasariError::InvalidNodeId(s.to_string()));
259        }
260        let dir = self.root.join("objects").join(&s[..2]);
261        let file = dir.join(&s[2..]);
262        Ok((dir, file))
263    }
264
265    fn index_attribution(&self, attr: &Attribution) -> Result<(), VasariError> {
266        // Index file name encodes the covered range: "<start>-<end>" for a
267        // LineRange, or the literal "whole" for a WholeFile (matches any line).
268        let (path, index_name) = match &attr.target {
269            AttributionTarget::LineRange { path, start, end } => (path, format!("{start}-{end}")),
270            AttributionTarget::WholeFile { path } => (path, "whole".to_string()),
271            AttributionTarget::CommitSha { .. } => return Ok(()),
272        };
273        let path_dir = self
274            .root
275            .join("index")
276            .join("targets")
277            .join(encode_path(path));
278        std::fs::create_dir_all(&path_dir)?;
279        let index_file = path_dir.join(index_name);
280        let mut f = std::fs::OpenOptions::new()
281            .create(true)
282            .append(true)
283            .open(&index_file)?;
284        writeln!(f, "{}", attr.id.as_str())?;
285        Ok(())
286    }
287}
288
289/// Encode a file path as a safe directory name for the index.
290/// Slashes become `%2F`, percent signs become `%25`.
291/// The `..` component is rejected: Path::join("..")` resolves to the parent
292/// directory, which would let an adversarially crafted session file write
293/// index entries outside the targets/ subdirectory.
294fn encode_path(path: &str) -> String {
295    // Split on '/', sanitize each component, join with encoded slash.
296    // ".." and "." are replaced before percent-encoding to avoid the double-encoding
297    // bug that would occur if we encoded '%' after injecting "%2E%2E".
298    // Path::join("..") in Rust traverses to the parent directory, so without this
299    // fix an adversarially crafted session file could write index entries outside
300    // the targets/ subdirectory.
301    path.split('/')
302        .map(|component| match component {
303            ".." => "%2E%2E".to_string(),
304            "." => "%2E".to_string(),
305            other => other.replace('%', "%25"),
306        })
307        .collect::<Vec<_>>()
308        .join("%2F")
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::schema::{Attribution, AttributionTarget, Intent, Node};
315
316    #[test]
317    fn round_trip_intent() {
318        let dir = tempfile::tempdir().unwrap();
319        let store = ObjectStore::open(dir.path()).unwrap();
320        let intent = Intent::new("ACME-411".into(), "Add JWT verification".into(), vec![]);
321        let id = store.put(&Node::Intent(intent.clone())).unwrap();
322        let got = store.get(&id).unwrap().unwrap();
323        match got {
324            Node::Intent(i) => assert_eq!(i.id, intent.id),
325            _ => panic!("wrong node type"),
326        }
327    }
328
329    #[test]
330    fn put_is_idempotent() {
331        let dir = tempfile::tempdir().unwrap();
332        let store = ObjectStore::open(dir.path()).unwrap();
333        let intent = Intent::new("src".into(), "test".into(), vec![]);
334        let node = Node::Intent(intent);
335        let id1 = store.put(&node).unwrap();
336        let id2 = store.put(&node).unwrap();
337        assert_eq!(id1, id2);
338    }
339
340    #[test]
341    fn attribution_index_lookup() {
342        let dir = tempfile::tempdir().unwrap();
343        let store = ObjectStore::open(dir.path()).unwrap();
344        let action_id = NodeId("deadbeef".repeat(8));
345        let attr = Attribution::new(
346            action_id,
347            AttributionTarget::LineRange {
348                path: "src/auth.ts".into(),
349                start: 40,
350                end: 55,
351            },
352            1.0,
353            vec![],
354            vec![],
355        );
356        let attr_id = attr.id.clone();
357        store.put(&Node::Attribution(attr)).unwrap();
358        let found = store.lookup_attributions("src/auth.ts", 47).unwrap();
359        assert!(found.contains(&attr_id));
360        let not_found = store.lookup_attributions("src/auth.ts", 60).unwrap();
361        assert!(not_found.is_empty());
362    }
363
364    #[test]
365    fn fresh_store_is_stamped_and_reopens() {
366        let dir = tempfile::tempdir().unwrap();
367        let store = ObjectStore::open(dir.path()).unwrap();
368        let version = std::fs::read_to_string(dir.path().join(".vasari").join("format")).unwrap();
369        assert_eq!(version.trim(), ObjectStore::FORMAT_VERSION);
370        drop(store);
371        // Reopening a store of the current version succeeds.
372        assert!(ObjectStore::open(dir.path()).is_ok());
373    }
374
375    #[test]
376    fn pre_v2_store_without_format_marker_is_refused() {
377        let dir = tempfile::tempdir().unwrap();
378        // Simulate a pre-v2 store: objects/ exists, no format file.
379        std::fs::create_dir_all(dir.path().join(".vasari").join("objects")).unwrap();
380        let err = ObjectStore::open(dir.path()).unwrap_err();
381        match err {
382            VasariError::IncompatibleStore { expected, .. } => {
383                assert_eq!(expected, ObjectStore::FORMAT_VERSION);
384            }
385            other => panic!("expected IncompatibleStore, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn whole_file_attribution_matches_any_line() {
391        let dir = tempfile::tempdir().unwrap();
392        let store = ObjectStore::open(dir.path()).unwrap();
393        let attr = Attribution::new(
394            NodeId("deadbeef".repeat(8)),
395            AttributionTarget::WholeFile {
396                path: "src/auth.rs".into(),
397            },
398            0.7,
399            vec![],
400            vec![],
401        );
402        let attr_id = attr.id.clone();
403        store.put(&Node::Attribution(attr)).unwrap();
404        // A whole-file attribution covers every line.
405        for line in [1u32, 47, 9999] {
406            let found = store.lookup_attributions("src/auth.rs", line).unwrap();
407            assert!(
408                found.contains(&attr_id),
409                "whole-file should match line {line}"
410            );
411        }
412        // But not other files.
413        assert!(store
414            .lookup_attributions("src/other.rs", 1)
415            .unwrap()
416            .is_empty());
417    }
418
419    #[test]
420    fn resolve_prefix_unique_match() {
421        let dir = tempfile::tempdir().unwrap();
422        let store = ObjectStore::open(dir.path()).unwrap();
423        let intent = Intent::new("s1".into(), "unique intent".into(), vec![]);
424        let full = store.put(&Node::Intent(intent)).unwrap();
425        let resolved = store.resolve_prefix(&full.as_str()[..10]).unwrap();
426        assert_eq!(resolved, full);
427        // Full id resolves to itself.
428        assert_eq!(store.resolve_prefix(full.as_str()).unwrap(), full);
429    }
430
431    #[test]
432    fn resolve_prefix_not_found() {
433        let dir = tempfile::tempdir().unwrap();
434        let store = ObjectStore::open(dir.path()).unwrap();
435        store
436            .put(&Node::Intent(Intent::new("s".into(), "x".into(), vec![])))
437            .unwrap();
438        // "ffff..." is valid hex but matches nothing in the (single-node) store.
439        let err = store.resolve_prefix(&"f".repeat(12)).unwrap_err();
440        assert!(matches!(err, VasariError::NodeNotFound(_)));
441    }
442
443    #[test]
444    fn resolve_prefix_rejects_short_and_non_hex_without_panic() {
445        let dir = tempfile::tempdir().unwrap();
446        let store = ObjectStore::open(dir.path()).unwrap();
447        // Too short.
448        assert!(matches!(
449            store.resolve_prefix("ab").unwrap_err(),
450            VasariError::InvalidNodeId(_)
451        ));
452        // Empty.
453        assert!(matches!(
454            store.resolve_prefix("").unwrap_err(),
455            VasariError::InvalidNodeId(_)
456        ));
457        // Non-hex (would otherwise index a shard dir that can't exist).
458        assert!(matches!(
459            store.resolve_prefix("zzzz").unwrap_err(),
460            VasariError::InvalidNodeId(_)
461        ));
462    }
463
464    #[test]
465    fn resolve_prefix_ambiguous_reports_count() {
466        let dir = tempfile::tempdir().unwrap();
467        let store = ObjectStore::open(dir.path()).unwrap();
468        // Forge two nodes whose ids share a 6-char prefix in the same shard.
469        for suffix in ["aaaa", "bbbb"] {
470            let id = NodeId(format!("abcdef{}", suffix.repeat(14)));
471            let (dir_p, file_p) = store.object_path(&id).unwrap();
472            std::fs::create_dir_all(&dir_p).unwrap();
473            std::fs::write(&file_p, b"x").unwrap();
474        }
475        let err = store.resolve_prefix("abcdef").unwrap_err();
476        match err {
477            VasariError::AmbiguousPrefix { count, .. } => assert_eq!(count, 2),
478            other => panic!("expected AmbiguousPrefix, got {other:?}"),
479        }
480    }
481
482    #[test]
483    fn iter_all_on_empty_store_returns_empty() {
484        let dir = tempfile::tempdir().unwrap();
485        let store = ObjectStore::open(dir.path()).unwrap();
486        let nodes = store.iter_all().unwrap();
487        assert!(nodes.is_empty());
488    }
489
490    #[test]
491    fn iter_all_returns_all_stored_nodes() {
492        let dir = tempfile::tempdir().unwrap();
493        let store = ObjectStore::open(dir.path()).unwrap();
494        let i1 = Intent::new("s1".into(), "first intent".into(), vec![]);
495        let i2 = Intent::new("s2".into(), "second intent".into(), vec![]);
496        store.put(&Node::Intent(i1.clone())).unwrap();
497        store.put(&Node::Intent(i2.clone())).unwrap();
498        let nodes = store.iter_all().unwrap();
499        assert_eq!(nodes.len(), 2);
500    }
501
502    #[test]
503    fn rebuild_index_restores_attribution_lookup() {
504        let dir = tempfile::tempdir().unwrap();
505        let store = ObjectStore::open(dir.path()).unwrap();
506        let action_id = NodeId("deadbeef".repeat(8));
507        let attr = Attribution::new(
508            action_id,
509            AttributionTarget::LineRange {
510                path: "src/lib.rs".into(),
511                start: 1,
512                end: u32::MAX,
513            },
514            0.9,
515            vec![],
516            vec![],
517        );
518        let attr_id = attr.id.clone();
519        store.put(&Node::Attribution(attr)).unwrap();
520
521        // Wipe and rebuild the index.
522        let index_dir = dir.path().join(".vasari").join("index").join("targets");
523        std::fs::remove_dir_all(&index_dir).unwrap();
524        std::fs::create_dir_all(&index_dir).unwrap();
525
526        // Lookup should return empty now (index gone).
527        let before = store.lookup_attributions("src/lib.rs", 42).unwrap();
528        assert!(before.is_empty());
529
530        // Rebuild.
531        let count = store.rebuild_index().unwrap();
532        assert_eq!(count, 1);
533
534        // Lookup should work again.
535        let after = store.lookup_attributions("src/lib.rs", 42).unwrap();
536        assert!(after.contains(&attr_id));
537    }
538
539    #[test]
540    fn lookup_attributions_deduplicates_on_re_ingest() {
541        let dir = tempfile::tempdir().unwrap();
542        let store = ObjectStore::open(dir.path()).unwrap();
543        let action_id = NodeId("deadbeef".repeat(8));
544        let attr = Attribution::new(
545            action_id,
546            AttributionTarget::LineRange {
547                path: "src/dup.rs".into(),
548                start: 1,
549                end: 100,
550            },
551            1.0,
552            vec![],
553            vec![],
554        );
555        let attr_id = attr.id.clone();
556        // Manually append the same ID twice to simulate re-ingest writing to the index.
557        store.put(&Node::Attribution(attr.clone())).unwrap();
558        // Directly append duplicate to the index file (filename = "<start>-<end>").
559        let encoded = encode_path("src/dup.rs");
560        let index_file = dir
561            .path()
562            .join(".vasari")
563            .join("index")
564            .join("targets")
565            .join(&encoded)
566            .join("1-100");
567        let mut f = std::fs::OpenOptions::new()
568            .append(true)
569            .open(&index_file)
570            .unwrap();
571        use std::io::Write;
572        writeln!(f, "{}", attr_id.as_str()).unwrap();
573
574        let found = store.lookup_attributions("src/dup.rs", 50).unwrap();
575        assert_eq!(found.len(), 1, "dedup-on-read should remove the duplicate");
576    }
577
578    #[test]
579    fn encode_path_encodes_slashes() {
580        let encoded = encode_path("src/auth/mod.rs");
581        assert!(!encoded.contains('/'));
582        assert!(encoded.contains("%2F"));
583    }
584
585    #[test]
586    fn encode_path_encodes_dotdot() {
587        let encoded = encode_path("../escape/path.rs");
588        assert!(!encoded.contains(".."));
589        assert!(encoded.contains("%2E%2E"));
590    }
591
592    #[test]
593    fn encode_path_encodes_single_dot() {
594        let encoded = encode_path("./relative.rs");
595        assert!(
596            encoded.contains("%2E"),
597            "single dot should be percent-encoded"
598        );
599        assert!(
600            !encoded.contains(".."),
601            "single dot should not be mistaken for dotdot"
602        );
603    }
604
605    #[test]
606    fn encode_path_encodes_percent() {
607        let encoded = encode_path("src/100%done.rs");
608        assert!(encoded.contains("%25"));
609    }
610
611    #[test]
612    fn lookup_attributions_returns_empty_for_unknown_path() {
613        let dir = tempfile::tempdir().unwrap();
614        let store = ObjectStore::open(dir.path()).unwrap();
615        let result = store.lookup_attributions("nonexistent/file.rs", 1).unwrap();
616        assert!(result.is_empty());
617    }
618}