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_tar, extract_zip};
10
11/// The archive format of a dist in the store.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DistKind {
14    Zip,
15    Tar,
16}
17use std::path::PathBuf;
18
19pub struct Store {
20    root: PathBuf,
21}
22
23impl Store {
24    pub fn at(root: PathBuf) -> Store {
25        Store { root }
26    }
27
28    pub fn default_location() -> Store {
29        Store::at(crate::platform::cache_dir().join("store"))
30    }
31
32    /// Entry key: version + first 12 hex chars of the dist reference,
33    /// sanitised for the file system.
34    pub fn entry_path(&self, name: &str, version: &str, dist_ref: Option<&str>) -> PathBuf {
35        let sane = |s: &str| -> String {
36            s.chars()
37                .map(|c| {
38                    if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
39                        c
40                    } else {
41                        '-'
42                    }
43                })
44                .collect()
45        };
46        let short_ref = dist_ref.unwrap_or("noref");
47        let short_ref = &short_ref[..short_ref.len().min(12)];
48        self.root
49            .join(name) // vendor/pkg: two safe segments (validated by the lock)
50            .join(format!("{}-{}", sane(version), sane(short_ref)))
51    }
52
53    /// Ensures the entry is present, extracting it if needed.
54    /// Returns the path of the extracted tree.
55    pub fn ensure(
56        &self,
57        name: &str,
58        version: &str,
59        dist_ref: Option<&str>,
60        dist_bytes: &[u8],
61        kind: DistKind,
62    ) -> Result<PathBuf> {
63        let final_path = self.entry_path(name, version, dist_ref);
64        if final_path.is_dir() {
65            return Ok(final_path);
66        }
67        let parent = final_path.parent().unwrap_or(&self.root).to_path_buf();
68        std::fs::create_dir_all(&parent).map_err(Error::io(&parent))?;
69        let tmp = tempfile::Builder::new()
70            .prefix(".tmp-")
71            .tempdir_in(&parent)
72            .map_err(Error::io(&parent))?;
73        match kind {
74            DistKind::Zip => extract_zip(dist_bytes, tmp.path())?,
75            DistKind::Tar => extract_tar(dist_bytes, tmp.path())?,
76        }
77        let tmp_path = tmp.keep();
78        match std::fs::rename(&tmp_path, &final_path) {
79            Ok(()) => Ok(final_path),
80            Err(_) if final_path.is_dir() => {
81                // A concurrent process won the rename: its entry is complete.
82                let _ = std::fs::remove_dir_all(&tmp_path);
83                Ok(final_path)
84            }
85            Err(source) => {
86                let _ = std::fs::remove_dir_all(&tmp_path);
87                Err(Error::Io {
88                    path: final_path,
89                    source,
90                })
91            }
92        }
93    }
94
95    pub fn contains(&self, name: &str, version: &str, dist_ref: Option<&str>) -> bool {
96        self.entry_path(name, version, dist_ref).is_dir()
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use std::io::Write as _;
104    use zip::write::SimpleFileOptions;
105
106    fn sample_zip() -> Vec<u8> {
107        let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
108        w.add_directory("root-x", SimpleFileOptions::default())
109            .expect("dir");
110        w.start_file("root-x/composer.json", SimpleFileOptions::default())
111            .expect("f");
112        w.write_all(b"{}").expect("w");
113        w.finish().expect("finish").into_inner()
114    }
115
116    #[test]
117    fn ensure_is_idempotent_and_atomic() {
118        let dir = tempfile::tempdir().expect("tmp");
119        let store = Store::at(dir.path().join("store"));
120        let zip = sample_zip();
121        let p1 = store
122            .ensure(
123                "a/b",
124                "1.0.0",
125                Some("deadbeefcafe1234"),
126                &zip,
127                DistKind::Zip,
128            )
129            .expect("ensure");
130        assert!(p1.join("composer.json").is_file());
131        assert!(store.contains("a/b", "1.0.0", Some("deadbeefcafe1234")));
132        // Second call: same bytes or not, the existing entry wins.
133        let p2 = store
134            .ensure(
135                "a/b",
136                "1.0.0",
137                Some("deadbeefcafe1234"),
138                b"garbage",
139                DistKind::Zip,
140            )
141            .expect("hit");
142        assert_eq!(p1, p2);
143        // Different key -> other entry.
144        assert!(!store.contains("a/b", "1.0.0", Some("feedfacefeed5678")));
145        // Hostile version sanitised (no traversal).
146        let p3 = store
147            .ensure("a/b", "../../evil", None, &zip, DistKind::Zip)
148            .expect("sane");
149        assert!(p3.starts_with(dir.path().join("store").join("a/b")));
150    }
151}