Skip to main content

vivacity_core/
store.rs

1//! Local content-addressed store: each (package, version, dist reference) is
2//! extracted ONCE into `<cache>/store/<vendor>/<pkg>/<key>/`, then cloned
3//! into the projects' vendor/ (see clone.rs). Atomic write: extraction into a
4//! sibling temporary directory then `rename`; the final directory only exists
5//! complete, and two concurrent processes converge (the loser of the rename
6//! discards its temporary directory).
7
8use crate::error::{Error, Result};
9use crate::extract::extract_zip;
10use std::path::PathBuf;
11
12pub struct Store {
13    root: PathBuf,
14}
15
16impl Store {
17    pub fn at(root: PathBuf) -> Store {
18        Store { root }
19    }
20
21    pub fn default_location() -> Store {
22        Store::at(crate::platform::cache_dir().join("store"))
23    }
24
25    /// Entry key: version + first 12 hex chars of the dist reference,
26    /// sanitised for the file system.
27    pub fn entry_path(&self, name: &str, version: &str, dist_ref: Option<&str>) -> PathBuf {
28        let sane = |s: &str| -> String {
29            s.chars()
30                .map(|c| {
31                    if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
32                        c
33                    } else {
34                        '-'
35                    }
36                })
37                .collect()
38        };
39        let short_ref = dist_ref.unwrap_or("noref");
40        let short_ref = &short_ref[..short_ref.len().min(12)];
41        self.root
42            .join(name) // vendor/pkg: two safe segments (validated by the lock)
43            .join(format!("{}-{}", sane(version), sane(short_ref)))
44    }
45
46    /// Ensures the entry is present, extracting it if needed.
47    /// Returns the path of the extracted tree.
48    pub fn ensure(
49        &self,
50        name: &str,
51        version: &str,
52        dist_ref: Option<&str>,
53        zip_bytes: &[u8],
54    ) -> Result<PathBuf> {
55        let final_path = self.entry_path(name, version, dist_ref);
56        if final_path.is_dir() {
57            return Ok(final_path);
58        }
59        let parent = final_path.parent().unwrap_or(&self.root).to_path_buf();
60        std::fs::create_dir_all(&parent).map_err(Error::io(&parent))?;
61        let tmp = tempfile::Builder::new()
62            .prefix(".tmp-")
63            .tempdir_in(&parent)
64            .map_err(Error::io(&parent))?;
65        extract_zip(zip_bytes, tmp.path())?;
66        let tmp_path = tmp.keep();
67        match std::fs::rename(&tmp_path, &final_path) {
68            Ok(()) => Ok(final_path),
69            Err(_) if final_path.is_dir() => {
70                // A concurrent process won the rename: its entry is complete.
71                let _ = std::fs::remove_dir_all(&tmp_path);
72                Ok(final_path)
73            }
74            Err(source) => {
75                let _ = std::fs::remove_dir_all(&tmp_path);
76                Err(Error::Io {
77                    path: final_path,
78                    source,
79                })
80            }
81        }
82    }
83
84    pub fn contains(&self, name: &str, version: &str, dist_ref: Option<&str>) -> bool {
85        self.entry_path(name, version, dist_ref).is_dir()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::io::Write as _;
93    use zip::write::SimpleFileOptions;
94
95    fn sample_zip() -> Vec<u8> {
96        let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
97        w.add_directory("root-x", SimpleFileOptions::default())
98            .expect("dir");
99        w.start_file("root-x/composer.json", SimpleFileOptions::default())
100            .expect("f");
101        w.write_all(b"{}").expect("w");
102        w.finish().expect("finish").into_inner()
103    }
104
105    #[test]
106    fn ensure_is_idempotent_and_atomic() {
107        let dir = tempfile::tempdir().expect("tmp");
108        let store = Store::at(dir.path().join("store"));
109        let zip = sample_zip();
110        let p1 = store
111            .ensure("a/b", "1.0.0", Some("deadbeefcafe1234"), &zip)
112            .expect("ensure");
113        assert!(p1.join("composer.json").is_file());
114        assert!(store.contains("a/b", "1.0.0", Some("deadbeefcafe1234")));
115        // Second call: same bytes or not, the existing entry wins.
116        let p2 = store
117            .ensure("a/b", "1.0.0", Some("deadbeefcafe1234"), b"garbage")
118            .expect("hit");
119        assert_eq!(p1, p2);
120        // Different key -> other entry.
121        assert!(!store.contains("a/b", "1.0.0", Some("feedfacefeed5678")));
122        // Hostile version sanitised (no traversal).
123        let p3 = store.ensure("a/b", "../../evil", None, &zip).expect("sane");
124        assert!(p3.starts_with(dir.path().join("store").join("a/b")));
125    }
126}