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/<layout>/<vendor>/<pkg>/<key>/`, then
3//! cloned into the projects' vendor/ (see clone.rs). Atomic write: extraction
4//! into a staging directory then `rename`; the final directory only exists
5//! complete, and two concurrent processes converge (the loser of the rename
6//! discards its staging directory).
7//!
8//! The staging directory is `<cache>/store/.staging/`, deliberately OUTSIDE the
9//! served layout: an entry is served from `<layout>/…` and nothing else is ever
10//! read, so whatever an extraction writes beside its own directory — the
11//! extractor refuses that now, this is the belt to its braces — cannot become a
12//! package. It is also on the same filesystem as the layout, which a `rename`
13//! requires.
14
15use crate::error::{Error, Result};
16use crate::extract::{extract_tar, extract_zip};
17
18/// The archive format of a dist in the store.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DistKind {
21    Zip,
22    Tar,
23}
24use std::path::PathBuf;
25
26pub struct Store {
27    root: PathBuf,
28}
29
30/// Segment naming the extraction semantics an entry was written with. A store
31/// entry is a *tree*, not an archive, so it carries whatever rule the build
32/// that wrote it applied — and when that rule changes (`v2`: the modes come
33/// from the archive instead of "executable → 0755, default otherwise"), an old
34/// entry keeps serving the old tree forever, silently. Bumping this segment is
35/// what makes the change take effect; the previous trees stay on disk, unused,
36/// until the cache is cleared.
37const LAYOUT: &str = "v2";
38
39/// Where extractions happen, swept of orphans on the way in. Six hours is well
40/// past any install, and short enough that a killed process does not leave a
41/// tree there for a month: a parallel install's staging directory is minutes
42/// old, never hours.
43const STAGING: &str = ".staging";
44const ORPHAN_AFTER: std::time::Duration = std::time::Duration::from_secs(6 * 3600);
45
46impl Store {
47    pub fn at(root: PathBuf) -> Store {
48        Store { root }
49    }
50
51    /// Removes staging directories no live process can own any more. Silent on
52    /// every error: this is housekeeping, not part of the install's contract,
53    /// and another process may be deleting the same entry.
54    fn sweep_staging(&self) {
55        let staging = self.root.join(STAGING);
56        let Ok(entries) = std::fs::read_dir(&staging) else {
57            return;
58        };
59        let now = std::time::SystemTime::now();
60        for e in entries.flatten() {
61            let old = e
62                .metadata()
63                .and_then(|m| m.modified())
64                .and_then(|m| now.duration_since(m).map_err(std::io::Error::other))
65                .is_ok_and(|age| age > ORPHAN_AFTER);
66            if old {
67                let _ = std::fs::remove_dir_all(e.path());
68            }
69        }
70    }
71
72    pub fn default_location() -> Store {
73        Store::at(crate::platform::cache_dir().join("store"))
74    }
75
76    /// Entry key: version + first 12 hex chars of the dist reference,
77    /// sanitised for the file system.
78    pub fn entry_path(&self, name: &str, version: &str, dist_ref: Option<&str>) -> PathBuf {
79        let sane = |s: &str| -> String {
80            s.chars()
81                .map(|c| {
82                    if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
83                        c
84                    } else {
85                        '-'
86                    }
87                })
88                .collect()
89        };
90        let short_ref = dist_ref.unwrap_or("noref");
91        let short_ref = &short_ref[..short_ref.len().min(12)];
92        self.root
93            .join(LAYOUT)
94            .join(name) // vendor/pkg: two safe segments (validated by the lock)
95            .join(format!("{}-{}", sane(version), sane(short_ref)))
96    }
97
98    /// Ensures the entry is present, extracting it if needed.
99    /// Returns the path of the extracted tree.
100    pub fn ensure(
101        &self,
102        name: &str,
103        version: &str,
104        dist_ref: Option<&str>,
105        dist_bytes: &[u8],
106        kind: DistKind,
107    ) -> Result<PathBuf> {
108        let final_path = self.entry_path(name, version, dist_ref);
109        if final_path.is_dir() {
110            return Ok(final_path);
111        }
112        let parent = final_path.parent().unwrap_or(&self.root).to_path_buf();
113        std::fs::create_dir_all(&parent).map_err(Error::io(&parent))?;
114        let staging = self.root.join(STAGING);
115        std::fs::create_dir_all(&staging).map_err(Error::io(&staging))?;
116        self.sweep_staging();
117        let tmp = tempfile::Builder::new()
118            .prefix(".tmp-")
119            .tempdir_in(&staging)
120            .map_err(Error::io(&staging))?;
121        let extracted = match kind {
122            DistKind::Zip => extract_zip(dist_bytes, tmp.path()),
123            DistKind::Tar => extract_tar(dist_bytes, tmp.path()),
124        };
125        // A refusal names the package, not `.tmp-EX4VNV` under the store.
126        extracted.map_err(|e| match e {
127            Error::HostileArchive { reason, .. } => Error::HostileDist {
128                name: name.to_owned(),
129                version: version.to_owned(),
130                reason,
131            },
132            other => other,
133        })?;
134        let tmp_path = tmp.keep();
135        match std::fs::rename(&tmp_path, &final_path) {
136            Ok(()) => Ok(final_path),
137            Err(_) if final_path.is_dir() => {
138                // A concurrent process won the rename: its entry is complete.
139                let _ = std::fs::remove_dir_all(&tmp_path);
140                Ok(final_path)
141            }
142            Err(source) => {
143                let _ = std::fs::remove_dir_all(&tmp_path);
144                Err(Error::Io {
145                    path: final_path,
146                    source,
147                })
148            }
149        }
150    }
151
152    pub fn contains(&self, name: &str, version: &str, dist_ref: Option<&str>) -> bool {
153        self.entry_path(name, version, dist_ref).is_dir()
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::io::Write as _;
161    use zip::write::SimpleFileOptions;
162
163    fn sample_zip() -> Vec<u8> {
164        let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
165        w.add_directory("root-x", SimpleFileOptions::default())
166            .expect("dir");
167        w.start_file("root-x/composer.json", SimpleFileOptions::default())
168            .expect("f");
169        w.write_all(b"{}").expect("w");
170        w.finish().expect("finish").into_inner()
171    }
172
173    #[test]
174    fn ensure_is_idempotent_and_atomic() {
175        let dir = tempfile::tempdir().expect("tmp");
176        let store = Store::at(dir.path().join("store"));
177        let zip = sample_zip();
178        let p1 = store
179            .ensure(
180                "a/b",
181                "1.0.0",
182                Some("deadbeefcafe1234"),
183                &zip,
184                DistKind::Zip,
185            )
186            .expect("ensure");
187        assert!(p1.join("composer.json").is_file());
188        assert!(store.contains("a/b", "1.0.0", Some("deadbeefcafe1234")));
189        // Second call: same bytes or not, the existing entry wins.
190        let p2 = store
191            .ensure(
192                "a/b",
193                "1.0.0",
194                Some("deadbeefcafe1234"),
195                b"garbage",
196                DistKind::Zip,
197            )
198            .expect("hit");
199        assert_eq!(p1, p2);
200        // Different key -> other entry.
201        assert!(!store.contains("a/b", "1.0.0", Some("feedfacefeed5678")));
202        // The extraction happens OUTSIDE the served layout, and the staging
203        // directory is left empty behind it: nothing that an extraction writes
204        // beside its own tree can ever be served as a package.
205        let staging = dir.path().join("store").join(".staging");
206        assert!(staging.is_dir(), "the staging directory is created");
207        assert_eq!(
208            std::fs::read_dir(&staging).expect("read_dir").count(),
209            0,
210            "no staging directory survives a successful extraction"
211        );
212        // An orphan older than the cutoff is collected; a fresh one is not.
213        let orphan = staging.join(".tmp-orphan");
214        let fresh = staging.join(".tmp-fresh");
215        std::fs::create_dir_all(orphan.join("inside")).expect("mkdir");
216        std::fs::create_dir_all(&fresh).expect("mkdir");
217        let long_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(7 * 3600);
218        filetime::set_file_mtime(&orphan, filetime::FileTime::from_system_time(long_ago))
219            .expect("set mtime");
220        store
221            .ensure(
222                "c/d",
223                "1.0.0",
224                Some("feedfacefeed5678"),
225                &zip,
226                DistKind::Zip,
227            )
228            .expect("extract");
229        assert!(!orphan.exists(), "the orphan is swept");
230        assert!(fresh.exists(), "a fresh staging directory is left alone");
231
232        // Hostile version sanitised (no traversal).
233        let p3 = store
234            .ensure("a/b", "../../evil", None, &zip, DistKind::Zip)
235            .expect("sane");
236        assert!(p3.starts_with(dir.path().join("store").join(LAYOUT).join("a/b")));
237        // The layout segment is part of the key: a tree written by another
238        // extraction rule is never served under this one.
239        assert!(store
240            .entry_path("a/b", "1.0.0", Some("deadbeefcafe1234"))
241            .components()
242            .any(|c| c.as_os_str() == LAYOUT));
243    }
244}