1use 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 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) .join(format!("{}-{}", sane(version), sane(short_ref)))
44 }
45
46 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 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 let p2 = store
117 .ensure("a/b", "1.0.0", Some("deadbeefcafe1234"), b"garbage")
118 .expect("hit");
119 assert_eq!(p1, p2);
120 assert!(!store.contains("a/b", "1.0.0", Some("feedfacefeed5678")));
122 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}