aube_store/index.rs
1use crate::{
2 Error, Store, cas_file_matches_len, integrity_to_hex, validate_and_encode_name,
3 validate_version,
4};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// Metadata about a file stored in the CAS.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct StoredFile {
11 /// The hex hash of the file content.
12 pub hex_hash: String,
13 /// The path within the store.
14 #[serde(with = "stored_path")]
15 pub store_path: PathBuf,
16 /// Whether the file is executable.
17 pub executable: bool,
18 /// File size in bytes when the entry was imported.
19 #[serde(default)]
20 pub size: Option<u64>,
21}
22
23mod stored_path {
24 use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
25 use std::path::{Path, PathBuf};
26
27 #[derive(Serialize, Deserialize)]
28 #[serde(rename_all = "camelCase")]
29 enum NativePath {
30 UnixBytes(Vec<u8>),
31 WindowsWide(Vec<u16>),
32 }
33
34 #[derive(Deserialize)]
35 #[serde(untagged)]
36 enum StoredPath {
37 Utf8(String),
38 Native(NativePath),
39 }
40
41 pub(super) fn serialize<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
42 where
43 S: Serializer,
44 {
45 if let Some(path) = path.to_str() {
46 return serializer.serialize_str(path);
47 }
48
49 #[cfg(unix)]
50 {
51 use std::os::unix::ffi::OsStrExt;
52 NativePath::UnixBytes(path.as_os_str().as_bytes().to_vec()).serialize(serializer)
53 }
54 #[cfg(windows)]
55 {
56 use std::os::windows::ffi::OsStrExt;
57 NativePath::WindowsWide(path.as_os_str().encode_wide().collect()).serialize(serializer)
58 }
59 #[cfg(not(any(unix, windows)))]
60 {
61 Err(serde::ser::Error::custom(
62 "path contains characters unsupported by this platform",
63 ))
64 }
65 }
66
67 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
68 where
69 D: Deserializer<'de>,
70 {
71 match StoredPath::deserialize(deserializer)? {
72 StoredPath::Utf8(path) => Ok(PathBuf::from(path)),
73 StoredPath::Native(NativePath::UnixBytes(bytes)) => {
74 #[cfg(unix)]
75 {
76 use std::os::unix::ffi::OsStringExt;
77 Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
78 }
79 #[cfg(not(unix))]
80 {
81 let _ = bytes;
82 Err(de::Error::custom("Unix path cache read on a non-Unix host"))
83 }
84 }
85 StoredPath::Native(NativePath::WindowsWide(wide)) => {
86 #[cfg(windows)]
87 {
88 use std::os::windows::ffi::OsStringExt;
89 Ok(PathBuf::from(std::ffi::OsString::from_wide(&wide)))
90 }
91 #[cfg(not(windows))]
92 {
93 let _ = wide;
94 Err(de::Error::custom(
95 "Windows path cache read on a non-Windows host",
96 ))
97 }
98 }
99 }
100 }
101}
102
103/// Index of all files in a package, keyed by relative path within the package.
104///
105/// Backed by `FxMap` (foldhash) rather than `BTreeMap`: the linker
106/// iterates this map per package and only two non-hot call sites do
107/// keyed lookups (`ignored_builds` checks for `"package.json"` and
108/// `"binding.gyp"`). Hash-based lookup is O(1) for those, and the
109/// flat-bucket layout deserializes/clones with one allocation
110/// instead of one per entry. Iteration order is no longer
111/// lexicographic — cache JSON files now ship in hash order, which
112/// doesn't affect any caller (caches are keyed by tarball path, not
113/// file content).
114pub type PackageIndex = aube_util::collections::FxMap<String, StoredFile>;
115
116/// Deterministic content fingerprint of a materialized package.
117///
118/// Hashes the package's full file set — every relative path plus its
119/// CAS content hash and executable bit — in sorted order, so two
120/// imports with byte-identical trees produce the same fingerprint and
121/// two imports that differ in any file (presence, contents, or mode)
122/// produce different fingerprints.
123///
124/// Used by the global virtual store to disambiguate source-backed
125/// dependencies (git / remote tarball) whose lockfile coordinate is
126/// identical but whose materialized bytes are not — e.g. the same git
127/// commit installed once normally (its `prepare` script built `dist/`)
128/// and once under `--ignore-scripts` (raw checkout, no `dist/`). The
129/// graph hash folds this in so the two land at distinct GVS paths
130/// instead of the first writer's tree leaking into the second project.
131///
132/// `PackageIndex` is an `FxMap` with non-deterministic iteration order,
133/// so the entries are collected and sorted by path before hashing.
134pub fn index_content_fingerprint(index: &PackageIndex) -> String {
135 let mut entries: Vec<(&str, &str, bool)> = index
136 .iter()
137 .map(|(path, file)| (path.as_str(), file.hex_hash.as_str(), file.executable))
138 .collect();
139 entries.sort_unstable();
140 let mut hasher = blake3::Hasher::new();
141 for (path, hex_hash, executable) in entries {
142 hasher.update(path.as_bytes());
143 hasher.update(b"\0");
144 hasher.update(hex_hash.as_bytes());
145 hasher.update(if executable { b"\x01" } else { b"\x00" });
146 }
147 hasher.finalize().to_hex().to_string()
148}
149
150fn index_files_match_metadata(index: &PackageIndex, verify_all: bool) -> bool {
151 let mut files = index.values();
152 if verify_all {
153 return files.all(stored_file_matches_metadata);
154 }
155 // Hot install path: one metadata check catches the common crash
156 // residue class (zero-byte/missing CAS files) without turning every
157 // warm lockfile install into a full store walk.
158 files.next().is_none_or(stored_file_matches_metadata)
159}
160
161fn stored_file_matches_metadata(file: &StoredFile) -> bool {
162 file.size
163 .map(|size| cas_file_matches_len(&file.store_path, size))
164 .unwrap_or_else(|| file.store_path.exists())
165}
166
167impl Store {
168 /// Load a cached package index, if it exists.
169 ///
170 /// `integrity`, when `Some`, is the registry-advertised SRI
171 /// digest (`sha512-`, or legacy `sha1-` / `sha256-` / `sha384-`)
172 /// of the tarball these cache files came from —
173 /// part of the cache key so the same `(name, version)` resolved
174 /// from different sources (npm registry vs. github codeload vs. a
175 /// proxy that served different bytes) can't alias on disk and
176 /// return each other's file lists to the linker. `None` falls
177 /// back to an unsuffixed `<name>@<version>.json` key so packages
178 /// fetched through a registry proxy that strips `dist.integrity`
179 /// can still warm-install — an integrity-less setup is already a
180 /// degraded mode the user opted into via `strict-store-integrity=false`.
181 pub fn load_index(
182 &self,
183 name: &str,
184 version: &str,
185 integrity: Option<&str>,
186 ) -> Option<PackageIndex> {
187 self.load_index_inner(name, version, integrity, false)
188 }
189
190 /// Load a package index, optionally verifying that all store files still exist.
191 /// The verified variant is slower (stat per file) but detects a corrupted store.
192 pub fn load_index_verified(
193 &self,
194 name: &str,
195 version: &str,
196 integrity: Option<&str>,
197 ) -> Option<PackageIndex> {
198 self.load_index_inner(name, version, integrity, true)
199 }
200
201 fn load_index_inner(
202 &self,
203 name: &str,
204 version: &str,
205 integrity: Option<&str>,
206 verify_files: bool,
207 ) -> Option<PackageIndex> {
208 let index_path = self.index_path(name, version, integrity)?;
209 let buf = xx::file::read(&index_path).ok()?;
210 let index: PackageIndex = sonic_rs::from_slice(&buf).ok()?;
211 if !index_files_match_metadata(&index, verify_files) {
212 trace!("cache stale: {name}@{version}");
213 if self.prepare_for_write().is_ok() {
214 let _ = xx::file::remove_file(&index_path);
215 }
216 return None;
217 }
218 trace!("cache hit: {name}@{version}");
219 Some(index)
220 }
221
222 /// Delete the cached package index for `(name, version, integrity)` if
223 /// it exists. Used as a recovery hatch when the linker discovers a
224 /// CAS shard referenced by the index has gone missing — the cached
225 /// JSON points at a dead `store_path`, so the next install must
226 /// re-derive the index by re-importing the tarball.
227 ///
228 /// `Ok(true)` when an entry was removed; `Ok(false)` when there
229 /// was nothing to remove (or the coordinate was invalid). Errors
230 /// surface only on real I/O failure, not on the missing-file case.
231 pub fn invalidate_cached_index(
232 &self,
233 name: &str,
234 version: &str,
235 integrity: Option<&str>,
236 ) -> Result<bool, Error> {
237 self.prepare_for_write()?;
238 let Some(index_path) = self.index_path(name, version, integrity) else {
239 return Ok(false);
240 };
241 match std::fs::remove_file(&index_path) {
242 Ok(()) => Ok(true),
243 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
244 Err(e) => Err(Error::Io(index_path, e)),
245 }
246 }
247
248 /// Save a package index to the cache.
249 ///
250 /// See [`load_index`](Self::load_index) for the semantics of
251 /// `integrity` and the integrity-less fallback.
252 pub fn save_index(
253 &self,
254 name: &str,
255 version: &str,
256 integrity: Option<&str>,
257 index: &PackageIndex,
258 ) -> Result<(), Error> {
259 self.prepare_for_write()?;
260 let index_path = self.index_path(name, version, integrity).ok_or_else(|| {
261 Error::Tar(format!(
262 "refusing to cache: invalid coordinate {name:?}@{version:?} or integrity {integrity:?}"
263 ))
264 })?;
265 let json =
266 serde_json::to_string(index).map_err(|e| Error::Tar(format!("serialize: {e}")))?;
267 xx::file::write(&index_path, json).map_err(|e| Error::Xx(e.to_string()))?;
268 trace!("cached index: {name}@{version}");
269 Ok(())
270 }
271
272 /// Build the on-disk path for a cached index.
273 ///
274 /// Layout:
275 /// - With integrity: `index/<16 hex>/<name>@<version>.json`. The
276 /// integrity hex lives in a subdirectory (not as part of the
277 /// filename) so a version whose semver build metadata happens
278 /// to be 16 lowercase hex chars (e.g. `1.0.0+a1b2c3d4e5f6a7b8`)
279 /// can never collide with an integrity-keyed entry for
280 /// `1.0.0` — they land in distinct directories by construction.
281 /// - Without integrity: `index/<name>@<version>.json` at the
282 /// index dir root. Used for registry proxies that strip
283 /// `dist.integrity`; the user has already opted out of
284 /// cross-source integrity enforcement.
285 ///
286 /// Returns `None` when any component is invalid (including an
287 /// integrity string we can't hex-decode).
288 pub(crate) fn index_path(
289 &self,
290 name: &str,
291 version: &str,
292 integrity: Option<&str>,
293 ) -> Option<PathBuf> {
294 let safe_name = validate_and_encode_name(name)?;
295 if !validate_version(version) {
296 return None;
297 }
298 let filename = format!("{safe_name}@{version}.json");
299 let dir = self.index_dir();
300 match integrity {
301 Some(i) => {
302 let hex = integrity_to_hex(i)?;
303 // 16 hex chars = 64 bits of tarball SHA-512 prefix.
304 // Two tarballs whose SHA-512 prefixes collide would
305 // both have to be valid registry responses for the
306 // same (name, version) *and* survive `verify_integrity`
307 // on fetch, so birthday-bound collisions aren't a
308 // correctness risk; 16 chars is plenty.
309 let short = &hex[..16.min(hex.len())];
310 Some(dir.join(short).join(filename))
311 }
312 None => Some(dir.join(filename)),
313 }
314 }
315}
316
317#[cfg(all(test, unix))]
318mod tests {
319 use super::StoredFile;
320 use std::os::unix::ffi::OsStringExt;
321
322 #[test]
323 fn stored_file_round_trips_non_utf8_store_path() {
324 let stored = StoredFile {
325 hex_hash: "abc123".into(),
326 store_path: std::path::PathBuf::from(std::ffi::OsString::from_vec(
327 b"/store/path-\xff".to_vec(),
328 )),
329 executable: false,
330 size: Some(3),
331 };
332
333 let json = serde_json::to_string(&stored).unwrap();
334 let decoded: StoredFile = serde_json::from_str(&json).unwrap();
335
336 assert_eq!(decoded.store_path, stored.store_path);
337 assert!(json.contains("unixBytes"));
338 }
339}