Skip to main content

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}