1use crate::error::{Error, Result};
16use crate::extract::{extract_tar, extract_zip};
17
18#[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
30const LAYOUT: &str = "v2";
38
39const 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 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 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) .join(format!("{}-{}", sane(version), sane(short_ref)))
96 }
97
98 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 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 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 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 assert!(!store.contains("a/b", "1.0.0", Some("feedfacefeed5678")));
202 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 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 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 assert!(store
240 .entry_path("a/b", "1.0.0", Some("deadbeefcafe1234"))
241 .components()
242 .any(|c| c.as_os_str() == LAYOUT));
243 }
244}