Skip to main content

aube_store/
lib.rs

1#[macro_use]
2extern crate log;
3
4pub mod dirs;
5
6mod cas;
7mod git;
8mod index;
9mod integrity;
10mod tarball;
11
12pub use git::{
13    codeload_cache_integrity, codeload_cache_lookup, extract_codeload_tarball, git_host_in_list,
14    git_resolve_ref, git_shallow_clone, git_url_host,
15};
16
17#[cfg(test)]
18pub(crate) use cas::blake3_hex;
19pub(crate) use cas::cas_file_matches_len;
20use cas::copy_dir_recursive;
21#[cfg(test)]
22pub(crate) use cas::parse_compress_store_gate;
23#[cfg(test)]
24use git::{
25    codeload_cache_paths, extract_codeload_tarball_at, git_command, git_commit_matches,
26    validate_git_positional,
27};
28pub use index::{PackageIndex, StoredFile, index_content_fingerprint};
29pub use integrity::{
30    SHA512_INTEGRITY_PREFIX, integrity_to_hex, sha512_integrity, sha512_integrity_from_digest,
31    shasum_to_sri, validate_and_encode_name, validate_pkg_content, validate_version,
32    verify_integrity, verify_precomputed_sha512,
33};
34#[cfg(test)]
35pub(crate) use tarball::normalize_tar_entry_path;
36pub(crate) use tarball::{
37    CappedReader, MAX_TARBALL_DECOMPRESSED_BYTES, MAX_TARBALL_ENTRIES, MAX_TARBALL_ENTRY_BYTES,
38};
39pub use tarball::{
40    directory_content_fingerprint, directory_fingerprints, directory_metadata_fingerprint,
41};
42
43#[cfg(test)]
44use sha1::Sha1;
45#[cfg(test)]
46use sha2::{Digest as _, Sha256, Sha384, Sha512};
47use std::path::{Path, PathBuf};
48use std::sync::atomic::AtomicBool;
49#[cfg(target_os = "macos")]
50use std::sync::atomic::Ordering;
51use std::sync::{Arc, Mutex, OnceLock};
52
53pub const CACHE_DIR_NAME: &str = "aube-cache";
54pub const INDEX_SUBDIR: &str = "index";
55pub const VIRTUAL_STORE_SUBDIR: &str = "virtual-store";
56pub const PACKUMENT_CACHE_SUBDIR: &str = "packuments-v1";
57pub const PACKUMENT_FULL_CACHE_SUBDIR: &str = "packuments-full-v1";
58pub const MAINTENANCE_LOCK_FILE: &str = ".maintenance.lock";
59
60#[derive(Default)]
61struct MaintenanceState {
62    shared: Mutex<Option<std::fs::File>>,
63}
64
65/// Exclusive store-maintenance lease held by `aube store prune`.
66///
67/// Every CAS/index writer takes the corresponding shared lease through
68/// [`Store::prepare_for_write`], so holding this guard freezes one complete
69/// prune snapshot across the GVS, cached indexes, and CAS files.
70pub struct StoreMaintenanceGuard(std::fs::File);
71
72impl Drop for StoreMaintenanceGuard {
73    fn drop(&mut self) {
74        let _ = self.0.unlock();
75    }
76}
77
78/// The global content-addressable store, owned by aube.
79///
80/// Default location: `$XDG_DATA_HOME/aube/store/v1/files/` (falling
81/// back to `~/.local/share/aube/store/v1/files/`).
82/// Files are stored by BLAKE3 hash with two-char hex directory sharding.
83/// (Tarball-level integrity is still SHA-512 because that's the format the
84/// npm registry returns; the per-file CAS key is an internal choice.)
85///
86/// Layout under the store-version directory (`v1/`):
87/// - `v1/files/` — CAS shards, content-addressed by BLAKE3 hex
88/// - `v1/index/` — cached package indexes (kept next to `files/` so a
89///   single backup/mount captures the whole store; matches pnpm's
90///   `~/.pnpm-store/v11/{files,index.db}` grouping)
91///
92/// `cache_dir` (the `cacheDir` setting, default: the platform cache
93/// dir) still holds genuinely regenerable caches: the global virtual
94/// store and packument metadata.
95#[derive(Clone)]
96pub struct Store {
97    root: PathBuf,
98    cache_dir: PathBuf,
99    /// Root of the global virtual store. Defaults to
100    /// `<cache_dir>/virtual-store` and is overridden wholesale by the
101    /// `globalVirtualStoreDir` setting, which users point at the
102    /// `storeDir` volume so materialized packages can be hardlinked
103    /// out of the CAS.
104    virtual_store_dir: PathBuf,
105    maintenance: Arc<MaintenanceState>,
106    migration_done: Arc<OnceLock<()>>,
107    /// When set, `create_cas_file` writes directly to the final
108    /// content-addressed path on non-Linux platforms instead of the
109    /// tempfile-then-rename dance. Caller must guarantee no concurrent
110    /// installer is writing into this store — typically via an exclusive
111    /// file lock taken at install start. Linux is unaffected because the
112    /// O_TMPFILE+linkat path is already atomic-by-construction.
113    fast_path: Arc<AtomicBool>,
114}
115
116impl Store {
117    /// Open the store at the platform default location (see
118    /// [`dirs::store_dir`] and [`dirs::cache_dir`]).
119    ///
120    /// aube's own CLI resolves `storeDir` / `cacheDir` /
121    /// `globalVirtualStoreDir` first and goes through [`Store::with_dirs`];
122    /// this is the entry point for embedders that just want the same
123    /// directories a default install would use.
124    pub fn default_location() -> Result<Self, Error> {
125        let root = dirs::store_dir().ok_or(Error::NoHome)?;
126        let cache_dir = dirs::cache_dir().ok_or(Error::NoHome)?;
127        Ok(Self::with_dirs(root, cache_dir))
128    }
129
130    /// Open the store with an explicit CAS root, keeping the platform
131    /// cache dir for the global virtual store and packument caches.
132    /// Equivalent to `with_dirs(root, dirs::cache_dir())`.
133    pub fn with_root(root: PathBuf) -> Result<Self, Error> {
134        let cache_dir = dirs::cache_dir().ok_or(Error::NoHome)?;
135        Ok(Self::with_dirs(root, cache_dir))
136    }
137
138    /// Open the store with an explicit CAS root and cache dir. Used when
139    /// a user overrides `storeDir` (the CAS) and/or `cacheDir` (the
140    /// global virtual store + packument caches); the two are independent
141    /// settings, but the global virtual store hardlinks out of the CAS,
142    /// so a caller pointing them at different volumes gives up the
143    /// hardlink fast path.
144    ///
145    /// `root` is the CAS shard directory (`<storeDir>/v1/files`), not the
146    /// user-facing store dir. The global virtual store lands under
147    /// `cache_dir` unless [`Store::with_virtual_store_dir`] moves it.
148    pub fn with_dirs(root: PathBuf, cache_dir: PathBuf) -> Self {
149        Self {
150            root,
151            virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR),
152            cache_dir,
153            maintenance: Arc::new(MaintenanceState::default()),
154            migration_done: Arc::new(OnceLock::new()),
155            fast_path: Arc::new(AtomicBool::new(false)),
156        }
157    }
158
159    /// Point the global virtual store somewhere other than
160    /// `<cache_dir>/virtual-store` (the `globalVirtualStoreDir`
161    /// setting). The path is used verbatim.
162    #[must_use]
163    pub fn with_virtual_store_dir(mut self, dir: PathBuf) -> Self {
164        self.virtual_store_dir = dir;
165        self
166    }
167
168    /// Open the store at a specific path (cache dir derived from store root).
169    /// Used by tests that need a fully isolated layout; production code
170    /// should prefer `with_dirs`.
171    pub fn at(root: PathBuf) -> Self {
172        let cache_dir = root.parent().unwrap_or(&root).join(CACHE_DIR_NAME);
173        Self {
174            root,
175            virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR),
176            cache_dir,
177            maintenance: Arc::new(MaintenanceState::default()),
178            migration_done: Arc::new(OnceLock::new()),
179            fast_path: Arc::new(AtomicBool::new(false)),
180        }
181    }
182
183    /// Enable the macOS direct-write fast path for CAS imports. Bypasses
184    /// the tempfile + persist_noclobber pattern and writes straight to
185    /// the final content-addressed path, saving ~80µs/file on APFS. The
186    /// caller MUST hold an exclusive lock against the store for the
187    /// duration any thread might invoke `import_bytes`; otherwise a
188    /// concurrent installer can observe a partial file and the
189    /// `AlreadyExisted` recovery dance can clobber an in-flight write.
190    ///
191    /// macOS-gated rather than just declared inert on other platforms.
192    /// On Linux the `O_TMPFILE+linkat` path has no inline length-check
193    /// recovery — that recovery only lives inside the macOS fast-path
194    /// branch — so the outer skip in `import_bytes` (also macOS-gated
195    /// via `cfg!`) must never see the flag set on Linux. Removing the
196    /// method on non-macOS platforms makes that mismatch a build error
197    /// rather than a silent acceptance of torn CAS files.
198    #[cfg(target_os = "macos")]
199    pub fn enable_fast_path(&self) {
200        self.fast_path.store(true, Ordering::Release);
201    }
202
203    pub fn root(&self) -> &Path {
204        &self.root
205    }
206
207    /// The store-version directory containing `files/` and `index/`.
208    ///
209    /// For the default layout this is `<storeDir>/v1/` (parent of
210    /// `root`, which is the `files/` subdir). Matches the granularity
211    /// of `pnpm store path` — a single cache-mount or backup covering
212    /// this directory captures both the CAS shards and the cached
213    /// package indexes, so they cannot drift apart.
214    ///
215    /// Falls back to `root` itself when `root` has no parent (only
216    /// possible at the filesystem root, which is never a real store).
217    pub fn store_v1_dir(&self) -> PathBuf {
218        self.root
219            .parent()
220            .map(Path::to_path_buf)
221            .unwrap_or_else(|| self.root.clone())
222    }
223
224    /// Directory for cached package indexes. Lives next to `files/`
225    /// at `<v1_dir>/index/` so the whole store is one mount/backup
226    /// unit. Public so introspection commands (`aube find-hash`,
227    /// `aube store status`, `aube store prune`) can walk it directly.
228    pub fn index_dir(&self) -> PathBuf {
229        self.store_v1_dir().join(INDEX_SUBDIR)
230    }
231
232    /// Legacy index location at `$XDG_CACHE_HOME/aube/index/`, where
233    /// aube wrote cached package indexes before they were moved next
234    /// to the CAS files. Used only by [`migrate_legacy_index_dir`]; new
235    /// code should always go through [`index_dir`].
236    pub fn legacy_index_dir(&self) -> PathBuf {
237        self.cache_dir.join(INDEX_SUBDIR)
238    }
239
240    /// Whether opening this store for writes would migrate the legacy index.
241    pub fn legacy_index_migration_needed(&self) -> bool {
242        self.legacy_index_dir().exists() && !self.index_dir().exists()
243    }
244
245    /// One-shot migration from the legacy XDG-cache index location to
246    /// the in-store `v1/index/` directory. Runs when the store first prepares
247    /// for a write, after its shared maintenance lease is acquired.
248    ///
249    /// The legacy location was a footgun under Docker BuildKit cache
250    /// mounts: users would mount the CAS files dir, the indexes would
251    /// silently land on the image layer instead, and the next install
252    /// would hit `MissingStoreFile` on every package whose CAS shards
253    /// the cache mount dropped. Co-locating index with files matches
254    /// pnpm's grouping and removes the drift class entirely.
255    ///
256    /// Best-effort: a same-filesystem rename is one syscall; on
257    /// `EXDEV` (cache dir and store dir on different filesystems, e.g.
258    /// tmpfs cache + persistent data) we fall back to a recursive
259    /// copy + remove. Either failure logs a warning and proceeds —
260    /// the worst-case is re-fetching tarballs on the next install,
261    /// which is what would have happened without the migration anyway.
262    fn migrate_legacy_index_dir(&self) {
263        let legacy = self.legacy_index_dir();
264        let new = self.index_dir();
265        if !legacy.exists() || new.exists() {
266            return;
267        }
268        if let Some(parent) = new.parent()
269            && let Err(e) = std::fs::create_dir_all(parent)
270        {
271            warn!(
272                "failed to create {} for index migration: {e}",
273                parent.display()
274            );
275            return;
276        }
277        if std::fs::rename(&legacy, &new).is_ok() {
278            debug!(
279                "migrated cached indexes from {} to {}",
280                legacy.display(),
281                new.display()
282            );
283            return;
284        }
285        // Rename lost (cross-FS, or a concurrent process already won
286        // the race). If `legacy` is gone, a concurrent process already
287        // migrated successfully — leave `new` alone.
288        if !legacy.exists() {
289            return;
290        }
291        // Cross-filesystem rename (cache dir on tmpfs, data dir on a
292        // persistent FS — common in containers) or any other rename
293        // failure: fall back to recursive copy + remove.
294        if let Err(e) = copy_dir_recursive(&legacy, &new) {
295            warn!(
296                "failed to migrate cached indexes from {} to {}: {e}; will be rebuilt on next install",
297                legacy.display(),
298                new.display()
299            );
300            // Only roll back `new` if `legacy` is still here — meaning
301            // we own the half-copied content and have a recovery
302            // path (next install re-fetches). If `legacy` is also gone,
303            // a concurrent rename succeeded between our two checks and
304            // `new` holds that process's valid data; removing it would
305            // silently delete the only good copy.
306            if legacy.exists() {
307                let _ = std::fs::remove_dir_all(&new);
308            }
309            return;
310        }
311        if let Err(e) = std::fs::remove_dir_all(&legacy) {
312            warn!(
313                "migrated indexes to {} but failed to remove old {}: {e}",
314                new.display(),
315                legacy.display()
316            );
317        }
318    }
319
320    pub fn maintenance_lock_path(&self) -> PathBuf {
321        self.store_v1_dir().join(MAINTENANCE_LOCK_FILE)
322    }
323
324    fn open_maintenance_lock(&self) -> Result<std::fs::File, Error> {
325        let path = self.maintenance_lock_path();
326        let Some(parent) = path.parent() else {
327            return Err(Error::Io(
328                path,
329                std::io::Error::new(
330                    std::io::ErrorKind::InvalidInput,
331                    "store maintenance lock has no parent",
332                ),
333            ));
334        };
335        std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?;
336        std::fs::OpenOptions::new()
337            .create(true)
338            .truncate(false)
339            .write(true)
340            .open(&path)
341            .map_err(|e| Error::Io(path, e))
342    }
343
344    /// Acquire the shared writer lease and perform any pending legacy-index
345    /// migration. The lease is retained by this `Store` and all of its clones.
346    pub fn prepare_for_write(&self) -> Result<(), Error> {
347        let mut shared = self.maintenance.shared.lock().map_err(|_| {
348            Error::Io(
349                self.maintenance_lock_path(),
350                std::io::Error::other("store maintenance lock state is poisoned"),
351            )
352        })?;
353        if shared.is_none() {
354            let file = self.open_maintenance_lock()?;
355            file.lock_shared()
356                .map_err(|e| Error::Io(self.maintenance_lock_path(), e))?;
357            *shared = Some(file);
358        }
359        drop(shared);
360        self.migration_done.get_or_init(|| {
361            self.migrate_legacy_index_dir();
362        });
363        Ok(())
364    }
365
366    /// Acquire an exclusive lease for a complete prune plan/apply operation.
367    pub fn lock_for_maintenance(&self) -> Result<StoreMaintenanceGuard, Error> {
368        let shared = self.maintenance.shared.lock().map_err(|_| {
369            Error::Io(
370                self.maintenance_lock_path(),
371                std::io::Error::other("store maintenance lock state is poisoned"),
372            )
373        })?;
374        if shared.is_some() {
375            return Err(Error::Io(
376                self.maintenance_lock_path(),
377                std::io::Error::new(
378                    std::io::ErrorKind::WouldBlock,
379                    "this Store already holds a writer lease",
380                ),
381            ));
382        }
383        let file = self.open_maintenance_lock()?;
384        file.lock()
385            .map_err(|e| Error::Io(self.maintenance_lock_path(), e))?;
386        Ok(StoreMaintenanceGuard(file))
387    }
388
389    /// Apply the legacy-index migration while an exclusive maintenance lease
390    /// is held. Used by real prune after its candidate plan is complete.
391    pub fn migrate_legacy_index_for_maintenance(&self, _guard: &StoreMaintenanceGuard) {
392        self.migrate_legacy_index_dir();
393    }
394
395    /// Directory for the global virtual store (materialized packages).
396    /// `<cacheDir>/virtual-store/` unless `globalVirtualStoreDir`
397    /// moved it, so it follows `cacheDir` by default.
398    pub fn virtual_store_dir(&self) -> PathBuf {
399        self.virtual_store_dir.clone()
400    }
401
402    /// Directory for cached packument metadata (abbreviated/corgi format).
403    /// Versioned so we can bump the schema without breaking old caches —
404    /// old caches at older versions stay around until manually pruned.
405    pub fn packument_cache_dir(&self) -> PathBuf {
406        self.cache_dir.join(PACKUMENT_CACHE_SUBDIR)
407    }
408
409    /// Directory for cached *full* packument JSON (non-corgi) used by
410    /// human-facing commands like `aube view` that need fields the resolver
411    /// doesn't parse (`description`, `repository`, `license`, `keywords`,
412    /// `maintainers`). Separate from `packument_cache_dir` because the
413    /// corgi and full responses have different shapes.
414    pub fn packument_full_cache_dir(&self) -> PathBuf {
415        self.cache_dir.join(PACKUMENT_FULL_CACHE_SUBDIR)
416    }
417
418    /// Check if a file with the given integrity hash exists in the store.
419    pub fn has(&self, integrity: &str) -> bool {
420        self.file_path_from_integrity(integrity)
421            .is_some_and(|p| p.exists())
422    }
423
424    /// Get the path to a file in the store by its integrity hash.
425    pub fn file_path_from_integrity(&self, integrity: &str) -> Option<PathBuf> {
426        let hex_hash = integrity_to_hex(integrity)?;
427        Some(self.file_path_from_hex(&hex_hash))
428    }
429
430    /// Get the path to a file in the store by its hex hash.
431    pub fn file_path_from_hex(&self, hex_hash: &str) -> PathBuf {
432        let (shard, rest) = hex_hash.split_at(2);
433        self.root.join(shard).join(rest)
434    }
435}
436
437#[derive(Debug, thiserror::Error, miette::Diagnostic)]
438#[non_exhaustive]
439pub enum Error {
440    #[error("HOME environment variable not set")]
441    #[diagnostic(code(ERR_AUBE_NO_HOME))]
442    NoHome,
443    #[error("I/O error at {0}: {1}")]
444    Io(PathBuf, std::io::Error),
445    #[error("file error: {0}")]
446    Xx(String),
447    #[error("tarball extraction error: {0}")]
448    #[diagnostic(code(ERR_AUBE_TARBALL_EXTRACT))]
449    Tar(String),
450    #[error("integrity verification failed: {0}")]
451    #[diagnostic(code(ERR_AUBE_TARBALL_INTEGRITY))]
452    Integrity(String),
453    #[error("package.json content mismatch: tarball declares {actual}")]
454    #[diagnostic(code(ERR_AUBE_PKG_CONTENT_MISMATCH))]
455    PkgContentMismatch { actual: String },
456    #[error("git error: {0}")]
457    #[diagnostic(code(ERR_AUBE_GIT_ERROR))]
458    Git(String),
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::git::read_codeload_integrity;
465
466    /// Construct a Store with explicit root + cache_dir, bypassing the
467    /// XDG resolution path. Test-only so the migration test can drive
468    /// `migrate_legacy_index_dir` against a fully isolated layout
469    /// without touching env vars or process-global state.
470    fn store_for_migration_test(root: PathBuf, cache_dir: PathBuf) -> Store {
471        Store {
472            root,
473            virtual_store_dir: cache_dir.join(VIRTUAL_STORE_SUBDIR),
474            cache_dir,
475            maintenance: Arc::new(MaintenanceState::default()),
476            migration_done: Arc::new(OnceLock::new()),
477            fast_path: Arc::new(AtomicBool::new(false)),
478        }
479    }
480
481    #[test]
482    fn migrate_legacy_index_dir_relocates_files_and_subdirs() {
483        let tmp = tempfile::tempdir().unwrap();
484        let root = tmp.path().join("data/aube/store/v1/files");
485        let cache_dir = tmp.path().join("cache/aube");
486        std::fs::create_dir_all(&root).unwrap();
487        let legacy_index = cache_dir.join("index");
488        let legacy_shard = legacy_index.join("0123456789abcdef");
489        std::fs::create_dir_all(&legacy_shard).unwrap();
490        std::fs::write(legacy_index.join("foo@1.0.0.json"), b"{\"index\":\"a\"}").unwrap();
491        std::fs::write(legacy_shard.join("bar@2.0.0.json"), b"{\"index\":\"b\"}").unwrap();
492
493        let store = store_for_migration_test(root.clone(), cache_dir.clone());
494        store.migrate_legacy_index_dir();
495
496        let new_index = store.index_dir();
497        assert!(new_index.exists(), "new index dir must exist");
498        assert_eq!(
499            std::fs::read(new_index.join("foo@1.0.0.json")).unwrap(),
500            b"{\"index\":\"a\"}",
501            "integrity-less entry must migrate"
502        );
503        assert_eq!(
504            std::fs::read(new_index.join("0123456789abcdef/bar@2.0.0.json")).unwrap(),
505            b"{\"index\":\"b\"}",
506            "integrity-keyed shard subdir must migrate"
507        );
508        assert!(
509            !legacy_index.exists(),
510            "legacy index dir must be removed after a successful migration"
511        );
512    }
513
514    #[test]
515    fn migrate_legacy_index_dir_is_a_noop_when_new_dir_exists() {
516        let tmp = tempfile::tempdir().unwrap();
517        let root = tmp.path().join("data/aube/store/v1/files");
518        let cache_dir = tmp.path().join("cache/aube");
519        std::fs::create_dir_all(&root).unwrap();
520        let legacy_index = cache_dir.join("index");
521        std::fs::create_dir_all(&legacy_index).unwrap();
522        std::fs::write(legacy_index.join("foo@1.0.0.json"), b"old").unwrap();
523
524        let store = store_for_migration_test(root.clone(), cache_dir.clone());
525        // Pre-existing new-location entry must not be clobbered.
526        std::fs::create_dir_all(store.index_dir()).unwrap();
527        std::fs::write(store.index_dir().join("keep.json"), b"new").unwrap();
528
529        store.migrate_legacy_index_dir();
530
531        assert!(
532            legacy_index.exists(),
533            "legacy dir must stay untouched when new dir already exists"
534        );
535        assert_eq!(
536            std::fs::read(store.index_dir().join("keep.json")).unwrap(),
537            b"new",
538            "existing new-location content must not be overwritten"
539        );
540        assert!(
541            !store.index_dir().join("foo@1.0.0.json").exists(),
542            "no copy must happen — migration only runs when new dir is absent"
543        );
544    }
545
546    #[test]
547    fn migrate_legacy_index_dir_is_a_noop_when_legacy_absent() {
548        let tmp = tempfile::tempdir().unwrap();
549        let root = tmp.path().join("data/aube/store/v1/files");
550        let cache_dir = tmp.path().join("cache/aube");
551        std::fs::create_dir_all(&root).unwrap();
552
553        let store = store_for_migration_test(root, cache_dir);
554        store.migrate_legacy_index_dir();
555
556        assert!(
557            !store.index_dir().exists(),
558            "migration must not create an empty new dir when there's nothing to migrate"
559        );
560    }
561
562    #[test]
563    fn maintenance_lock_waits_for_writer_lease() {
564        let tmp = tempfile::tempdir().unwrap();
565        let root = tmp.path().join("store/v1/files");
566        let cache = tmp.path().join("cache");
567        let writer = Store::with_dirs(root.clone(), cache.clone());
568        writer.prepare_for_write().unwrap();
569
570        let maintenance = Store::with_dirs(root, cache);
571        let (tx, rx) = std::sync::mpsc::channel();
572        let handle = std::thread::spawn(move || {
573            let guard = maintenance.lock_for_maintenance().unwrap();
574            tx.send(()).unwrap();
575            drop(guard);
576        });
577        assert!(
578            rx.recv_timeout(std::time::Duration::from_millis(50))
579                .is_err(),
580            "maintenance must wait while a writer lease is live"
581        );
582        drop(writer);
583        rx.recv_timeout(std::time::Duration::from_secs(2))
584            .expect("maintenance should proceed after the writer exits");
585        handle.join().unwrap();
586    }
587
588    #[test]
589    fn store_v1_dir_is_parent_of_files() {
590        let tmp = tempfile::tempdir().unwrap();
591        let root = tmp.path().join("data/aube/store/v1/files");
592        let cache_dir = tmp.path().join("cache/aube");
593        let store = store_for_migration_test(root.clone(), cache_dir);
594        assert_eq!(store.store_v1_dir(), root.parent().unwrap());
595        assert_eq!(store.index_dir(), root.parent().unwrap().join("index"));
596    }
597
598    #[test]
599    fn git_commit_matches_abbreviated_sha() {
600        assert!(git_commit_matches(
601            "98e8ff1da1a89f93d1397a24d7413ed15421c139",
602            "98e8ff1"
603        ));
604        assert!(!git_commit_matches(
605            "98e8ff1da1a89f93d1397a24d7413ed15421c139",
606            "98e8ff2"
607        ));
608        assert!(!git_commit_matches(
609            "98e8ff1da1a89f93d1397a24d7413ed15421c139",
610            "main"
611        ));
612    }
613
614    #[test]
615    fn test_integrity_to_hex() {
616        let integrity = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
617        let result = integrity_to_hex(integrity);
618        assert!(result.is_some());
619        let hex = result.unwrap();
620        assert_eq!(hex.len(), 128);
621        assert!(hex.chars().all(|c| c == '0'));
622    }
623
624    #[test]
625    fn test_integrity_to_hex_invalid() {
626        assert!(integrity_to_hex("md5-abc").is_none());
627        assert!(integrity_to_hex("notahash").is_none());
628        assert!(integrity_to_hex("").is_none());
629    }
630
631    #[test]
632    fn test_integrity_to_hex_sha1() {
633        // `co@4.6.0`'s real registry integrity.
634        let hex = integrity_to_hex("sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=").unwrap();
635        assert_eq!(hex.len(), 40);
636        assert_eq!(hex, "6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184");
637    }
638
639    #[test]
640    fn test_integrity_to_hex_sha256() {
641        let hex = integrity_to_hex("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap();
642        assert_eq!(hex.len(), 64);
643    }
644
645    #[test]
646    fn test_file_path_from_hex_sharding() {
647        let dir = tempfile::tempdir().unwrap();
648        let store = Store::at(dir.path().join("files"));
649
650        let path = store.file_path_from_hex("abcdef1234567890");
651        // First 2 chars are the shard directory. Use the platform's
652        // separator so the test works on Windows as well as Unix.
653        let sep = std::path::MAIN_SEPARATOR;
654        assert!(path.to_string_lossy().contains(&format!("{sep}ab{sep}")));
655        assert!(path.to_string_lossy().ends_with("cdef1234567890"));
656    }
657
658    #[test]
659    fn test_import_bytes() {
660        let dir = tempfile::tempdir().unwrap();
661        let store = Store::at(dir.path().join("files"));
662
663        let content = b"hello world";
664        let stored = store.import_bytes(content, false).unwrap();
665
666        assert!(stored.store_path.exists());
667        assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
668        assert!(!stored.executable);
669
670        // Importing same content returns same hash (idempotent)
671        let stored2 = store.import_bytes(content, false).unwrap();
672        assert_eq!(stored.hex_hash, stored2.hex_hash);
673    }
674
675    // ---- store-compression gate (AUBE_COMPRESS_STORE) ----------------
676
677    #[test]
678    fn parse_compress_store_gate_affirmative_is_default_node_glob() {
679        for val in ["1", "true", "on", "yes", ""] {
680            let gate = parse_compress_store_gate(val).expect("affirmative → gate");
681            assert_eq!(gate.glob(), Some("**/*.node"));
682            assert_eq!(gate.size(), None);
683        }
684    }
685
686    #[test]
687    fn parse_compress_store_gate_reads_glob_and_size_directives() {
688        let gate = parse_compress_store_gate("glob:**/*.dylib;size:>= 1MB").unwrap();
689        assert_eq!(gate.glob(), Some("**/*.dylib"));
690        assert!(gate.matches("a/b.dylib", 2_000_000));
691        assert!(!gate.matches("a/b.dylib", 500_000));
692
693        // size: alone keeps the default glob.
694        let gate = parse_compress_store_gate("size:> 100").unwrap();
695        assert_eq!(gate.glob(), Some("**/*.node"));
696        assert!(gate.matches("x.node", 200));
697        assert!(!gate.matches("x.node", 50));
698
699        // An unrecognized directive falls back to the default gate.
700        assert!(parse_compress_store_gate("garbage").is_some());
701    }
702
703    #[test]
704    fn parse_compress_store_gate_fails_closed_on_bad_size() {
705        // A malformed size predicate disables compression rather than
706        // widening the gate.
707        assert!(parse_compress_store_gate("size:< 1MB").is_none());
708        assert!(parse_compress_store_gate("size:nonsense").is_none());
709    }
710
711    #[test]
712    fn import_bytes_gated_off_is_byte_identical_to_import_bytes() {
713        let dir = tempfile::tempdir().unwrap();
714        let store = Store::at(dir.path().join("files"));
715
716        // A fake addon (ELF magic so a backend would attempt to compress
717        // it on a supporting FS).
718        let mut content = vec![0x7f, b'E', b'L', b'F'];
719        content.extend_from_slice(&[7u8; 9000]);
720
721        let plain = store.import_bytes(&content, false).unwrap();
722        // gate=None → exact same CAS key and bytes.
723        let gated = store
724            .import_bytes_with_gate("build/Release/x.node", &content, false, None)
725            .unwrap();
726        assert_eq!(plain.hex_hash, gated.hex_hash);
727        assert_eq!(gated.size, Some(content.len() as u64));
728        assert_eq!(std::fs::read(&gated.store_path).unwrap(), content);
729    }
730
731    #[test]
732    fn import_bytes_gated_stores_node_addon_transparently() {
733        let dir = tempfile::tempdir().unwrap();
734        let store = Store::at(dir.path().join("files"));
735        store.ensure_shards_exist().unwrap();
736
737        let mut content = vec![0x7f, b'E', b'L', b'F'];
738        content.extend_from_slice(&[0x5au8; 12_000]);
739        let gate = decmpfs::Gate::default();
740
741        let stored = store
742            .import_bytes_with_gate("build/Release/addon.node", &content, false, Some(&gate))
743            .unwrap();
744
745        // Whether the FS compressed it or fell back to a plain write, the
746        // file MUST land with the exact bytes and the logical size — the
747        // kernel-transparent contract `cas_file_matches_len` relies on.
748        assert!(stored.store_path.exists(), "addon landed in the CAS");
749        assert_eq!(stored.size, Some(content.len() as u64));
750        assert!(cas_file_matches_len(
751            &stored.store_path,
752            content.len() as u64
753        ));
754        assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
755        // CAS key is the BLAKE3 of the stored (logical) content.
756        assert_eq!(stored.hex_hash, blake3_hex(&content));
757    }
758
759    #[test]
760    fn import_bytes_gated_excludes_non_node_paths() {
761        let dir = tempfile::tempdir().unwrap();
762        let store = Store::at(dir.path().join("files"));
763        store.ensure_shards_exist().unwrap();
764
765        let content = b"module.exports = 1;\n".to_vec();
766        let gate = decmpfs::Gate::default();
767        // A `.js` file does not match `**/*.node` → plain CAS path, but
768        // still lands with the same key as the ungated import.
769        let gated = store
770            .import_bytes_with_gate("index.js", &content, false, Some(&gate))
771            .unwrap();
772        assert_eq!(gated.hex_hash, blake3_hex(&content));
773        assert_eq!(std::fs::read(&gated.store_path).unwrap(), content);
774    }
775
776    #[test]
777    fn import_bytes_gated_unwraps_a_napi_compress_hybrid() {
778        let dir = tempfile::tempdir().unwrap();
779        let store = Store::at(dir.path().join("files"));
780        store.ensure_shards_exist().unwrap();
781
782        // The raw addon the hybrid wraps.
783        let raw = {
784            let mut v = vec![0x7f, b'E', b'L', b'F'];
785            v.extend_from_slice(&[0x66u8; 6000]);
786            v
787        };
788        let hybrid = synth_elf_hybrid(&raw);
789
790        let gate = decmpfs::Gate::default();
791        let stored = store
792            .import_bytes_with_gate("build/Release/native.node", &hybrid, false, Some(&gate))
793            .unwrap();
794
795        // The CAS stores the UNWRAPPED raw addon, not the hybrid wrapper.
796        assert_eq!(stored.size, Some(raw.len() as u64));
797        assert_eq!(stored.hex_hash, blake3_hex(&raw));
798        assert_eq!(std::fs::read(&stored.store_path).unwrap(), raw);
799    }
800
801    /// Build a synthetic napi `--compress` hybrid: a minimal ELF64 with a
802    /// `.PRESSED_DATA` section holding the bin-infra pressed-data blob for
803    /// `raw`. Mirrors decmpfs's own addon round-trip fixture.
804    #[cfg(test)]
805    fn synth_elf_hybrid(raw: &[u8]) -> Vec<u8> {
806        use sha2::{Digest as _, Sha512};
807
808        // Pressed-data blob: magic + sizes + cache key + platform +
809        // SHA-512(payload) + has_config=0 + zstd payload.
810        const MAGIC: &[u8; 32] = b"__SMOL_PRESSED_DATA_MAGIC_MARKER";
811        let payload = zstd::stream::encode_all(raw, 3).unwrap();
812        let mut hasher = Sha512::new();
813        hasher.update(&payload);
814        let hash = hasher.finalize();
815        let mut blob = Vec::new();
816        blob.extend_from_slice(MAGIC);
817        blob.extend_from_slice(&(payload.len() as u64).to_le_bytes());
818        blob.extend_from_slice(&(raw.len() as u64).to_le_bytes());
819        blob.extend_from_slice(&[b'a'; 16]); // cache key
820        blob.extend_from_slice(&[1u8, 1u8, 255u8]); // platform/arch/libc
821        blob.extend_from_slice(&hash);
822        blob.push(0u8); // has_config = 0
823        blob.extend_from_slice(&payload);
824
825        // Minimal ELF64: ehdr + strtab + 2 section headers + blob.
826        let shentsize = 64usize;
827        let mut strtab = vec![0u8];
828        let shstrtab_name = strtab.len() as u32;
829        strtab.extend_from_slice(b".shstrtab\0");
830        let pressed_name = strtab.len() as u32;
831        strtab.extend_from_slice(b".PRESSED_DATA\0");
832
833        let ehdr_len = 64usize;
834        let strtab_off = ehdr_len;
835        let shoff = strtab_off + strtab.len();
836        let blob_off = shoff + 2 * shentsize;
837
838        let mut bin = vec![0u8; blob_off];
839        bin[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
840        bin[4] = 2; // EI_CLASS = 64-bit
841        bin[40..48].copy_from_slice(&(shoff as u64).to_le_bytes());
842        bin[58..60].copy_from_slice(&(shentsize as u16).to_le_bytes());
843        bin[60..62].copy_from_slice(&2u16.to_le_bytes());
844        bin[62..64].copy_from_slice(&0u16.to_le_bytes()); // e_shstrndx = 0
845        bin[strtab_off..strtab_off + strtab.len()].copy_from_slice(&strtab);
846
847        let sh0 = shoff;
848        bin[sh0..sh0 + 4].copy_from_slice(&shstrtab_name.to_le_bytes());
849        bin[sh0 + 24..sh0 + 32].copy_from_slice(&(strtab_off as u64).to_le_bytes());
850        bin[sh0 + 32..sh0 + 40].copy_from_slice(&(strtab.len() as u64).to_le_bytes());
851
852        let sh1 = shoff + shentsize;
853        bin[sh1..sh1 + 4].copy_from_slice(&pressed_name.to_le_bytes());
854        bin[sh1 + 24..sh1 + 32].copy_from_slice(&(blob_off as u64).to_le_bytes());
855        bin[sh1 + 32..sh1 + 40].copy_from_slice(&(blob.len() as u64).to_le_bytes());
856        bin.extend_from_slice(&blob);
857        bin
858    }
859
860    #[test]
861    fn test_import_bytes_repairs_truncated_existing_cas_entry() {
862        let dir = tempfile::tempdir().unwrap();
863        let store = Store::at(dir.path().join("files"));
864        store.ensure_shards_exist().unwrap();
865
866        let content = br#"{"name":"@babel/helper-string-parser","version":"7.27.1"}"#;
867        let hex_hash = blake3_hex(content);
868        let store_path = store.file_path_from_hex(&hex_hash);
869        std::fs::write(&store_path, b"").unwrap();
870
871        let stored = store.import_bytes(content, false).unwrap();
872
873        assert_eq!(stored.hex_hash, hex_hash);
874        assert_eq!(stored.size, Some(content.len() as u64));
875        assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
876    }
877
878    #[test]
879    fn verify_precomputed_sha512_happy_path() {
880        let data = b"hello world";
881        let mut hasher = Sha512::new();
882        hasher.update(data);
883        let mut digest = [0u8; 64];
884        digest.copy_from_slice(&hasher.finalize()[..]);
885        use base64::Engine;
886        let b64 = base64::engine::general_purpose::STANDARD.encode(digest);
887        let integrity = format!("sha512-{b64}");
888        assert!(verify_precomputed_sha512(&digest, &integrity).unwrap());
889    }
890
891    #[test]
892    fn verify_precomputed_sha512_mismatch_errors() {
893        // Build a properly-shaped sha512 SRI for all-FF bytes, then
894        // verify against an all-zero digest — same length, different
895        // content, lands on the byte-compare mismatch arm.
896        use base64::Engine;
897        let other = [0xFFu8; 64];
898        let other_b64 = base64::engine::general_purpose::STANDARD.encode(other);
899        let wrong = format!("sha512-{other_b64}");
900        let digest = [0u8; 64];
901        let err = verify_precomputed_sha512(&digest, &wrong).unwrap_err();
902        assert!(err.to_string().contains("integrity mismatch"));
903    }
904
905    #[test]
906    fn verify_precomputed_sha512_corrupt_b64_errors_distinctly() {
907        // Non-base64 characters: decode fails, user gets "malformed
908        // base64" instead of the misleading "integrity mismatch" they
909        // would see if every failure collapsed into one bucket.
910        let digest = [0u8; 64];
911        let corrupt = "sha512-not_valid_base64_!!!!!";
912        let err = verify_precomputed_sha512(&digest, corrupt).unwrap_err();
913        assert!(err.to_string().contains("malformed base64"));
914    }
915
916    #[test]
917    fn verify_precomputed_sha512_short_b64_errors_distinctly() {
918        // Valid base64 but decodes to too few bytes for sha512.
919        // Reports actual decoded length rather than mismatch.
920        let digest = [0u8; 64];
921        let short = "sha512-AAAA";
922        let err = verify_precomputed_sha512(&digest, short).unwrap_err();
923        assert!(err.to_string().contains("expected 64 for sha512"));
924    }
925
926    #[test]
927    fn verify_precomputed_sha512_non_sha512_returns_false() {
928        // Caller is expected to fall through to the buffered path
929        // with the right algo. Function returns Ok(false) so the
930        // caller can detect this without matching on a sentinel
931        // error variant.
932        let digest = [0u8; 64];
933        for algo in ["sha1-AAAA", "sha256-AAAA", "sha384-AAAA"] {
934            assert!(
935                !verify_precomputed_sha512(&digest, algo).unwrap(),
936                "{algo} should return Ok(false) for fallback"
937            );
938        }
939    }
940
941    #[test]
942    fn verify_precomputed_sha512_malformed_errors() {
943        let digest = [0u8; 64];
944        for bad in ["", "garbage", "not-an-algo-tag", "sha512", "sha512-"] {
945            let result = verify_precomputed_sha512(&digest, bad);
946            assert!(result.is_err(), "{bad:?} should not be Ok");
947        }
948    }
949
950    #[test]
951    fn test_verify_integrity_valid() {
952        let data = b"hello world";
953        // Compute the actual sha512 of "hello world"
954        let mut hasher = Sha512::new();
955        hasher.update(data);
956        let hash = hasher.finalize();
957        use base64::Engine;
958        let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
959        let integrity = format!("sha512-{b64}");
960
961        assert!(verify_integrity(data, &integrity).is_ok());
962    }
963
964    #[test]
965    fn test_verify_integrity_mismatch() {
966        let data = b"hello world";
967        let wrong = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
968        let result = verify_integrity(data, wrong);
969        assert!(result.is_err());
970        assert!(
971            result
972                .unwrap_err()
973                .to_string()
974                .contains("integrity mismatch")
975        );
976    }
977
978    #[test]
979    fn test_verify_integrity_unsupported_format() {
980        let result = verify_integrity(b"test", "md5-abc123");
981        assert!(result.is_err());
982        assert!(result.unwrap_err().to_string().contains("unsupported"));
983    }
984
985    #[test]
986    fn test_verify_integrity_sha1_valid() {
987        // SRI sha1- tarballs exist for legacy packages like co@4.6.0;
988        // aube must still install them, so this is a regression guard.
989        let data = b"hello world";
990        let hash = Sha1::digest(data);
991        use base64::Engine;
992        let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
993        assert!(verify_integrity(data, &format!("sha1-{b64}")).is_ok());
994    }
995
996    #[test]
997    fn test_verify_integrity_sha1_mismatch() {
998        let result = verify_integrity(b"hello world", "sha1-AAAAAAAAAAAAAAAAAAAAAAAAAAA=");
999        let err = result.unwrap_err().to_string();
1000        assert!(err.contains("integrity mismatch"));
1001        assert!(err.contains("sha1-"));
1002    }
1003
1004    #[test]
1005    fn test_verify_integrity_sha256_valid() {
1006        let data = b"hello world";
1007        let hash = Sha256::digest(data);
1008        use base64::Engine;
1009        let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
1010        assert!(verify_integrity(data, &format!("sha256-{b64}")).is_ok());
1011    }
1012
1013    #[test]
1014    fn test_verify_integrity_sha384_valid() {
1015        let data = b"hello world";
1016        let hash = Sha384::digest(data);
1017        use base64::Engine;
1018        let b64 = base64::engine::general_purpose::STANDARD.encode(hash);
1019        assert!(verify_integrity(data, &format!("sha384-{b64}")).is_ok());
1020    }
1021
1022    #[test]
1023    fn test_import_bytes_executable() {
1024        let dir = tempfile::tempdir().unwrap();
1025        let store = Store::at(dir.path().join("files"));
1026
1027        let content = b"#!/bin/sh\necho hello";
1028        let stored = store.import_bytes(content, true).unwrap();
1029        assert!(stored.executable);
1030
1031        // Check exec marker file exists
1032        let exec_marker = PathBuf::from(format!("{}-exec", stored.store_path.display()));
1033        assert!(exec_marker.exists());
1034    }
1035
1036    #[test]
1037    fn test_import_bytes_different_content_different_hash() {
1038        let dir = tempfile::tempdir().unwrap();
1039        let store = Store::at(dir.path().join("files"));
1040
1041        let stored1 = store.import_bytes(b"content a", false).unwrap();
1042        let stored2 = store.import_bytes(b"content b", false).unwrap();
1043        assert_ne!(stored1.hex_hash, stored2.hex_hash);
1044    }
1045
1046    /// SHA-512 of an arbitrary test payload, encoded as npm's
1047    /// `sha512-<base64>`. Shared across index-cache tests so every
1048    /// save/load pair uses the same integrity and the filename is
1049    /// deterministic.
1050    const TEST_INTEGRITY: &str = "sha512-7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ6wYX4oOHV+E+IvIh/1ntDcowEzF+prYseb2BRlkqKKw==";
1051    const OTHER_INTEGRITY: &str = "sha512-n4udRxsOEWaTbNrUjcrNvWAd1/aLvZeC/CwfsBIJZj0kHqyh0h10DmZerKIyp+/YqR09J8rBmdqkIy9SE/6rcQ==";
1052
1053    #[test]
1054    fn test_index_cache_roundtrip() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let store = Store::at(dir.path().join("files"));
1057
1058        let content = b"test file";
1059        let stored = store.import_bytes(content, false).unwrap();
1060
1061        let mut index = PackageIndex::default();
1062        index.insert("index.js".to_string(), stored);
1063
1064        store
1065            .save_index("test-pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1066            .unwrap();
1067
1068        let loaded = store.load_index("test-pkg", "1.0.0", Some(TEST_INTEGRITY));
1069        assert!(loaded.is_some());
1070        let loaded = loaded.unwrap();
1071        assert_eq!(loaded.len(), 1);
1072        assert!(loaded.contains_key("index.js"));
1073    }
1074
1075    #[test]
1076    fn test_index_cache_scoped_package() {
1077        let dir = tempfile::tempdir().unwrap();
1078        let store = Store::at(dir.path().join("files"));
1079
1080        let stored = store.import_bytes(b"scoped content", false).unwrap();
1081        let mut index = PackageIndex::default();
1082        index.insert("index.js".to_string(), stored);
1083
1084        // Scoped package name should work (slash replaced with __)
1085        store
1086            .save_index("@scope/pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1087            .unwrap();
1088        let loaded = store.load_index("@scope/pkg", "1.0.0", Some(TEST_INTEGRITY));
1089        assert!(loaded.is_some());
1090    }
1091
1092    #[test]
1093    fn test_index_cache_stale_detection() {
1094        let dir = tempfile::tempdir().unwrap();
1095        let store = Store::at(dir.path().join("files"));
1096
1097        let stored = store.import_bytes(b"content", false).unwrap();
1098        let store_path = stored.store_path.clone();
1099        let mut index = PackageIndex::default();
1100        index.insert("index.js".to_string(), stored);
1101
1102        store
1103            .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1104            .unwrap();
1105
1106        // Delete the actual store file to simulate staleness
1107        std::fs::remove_file(&store_path).unwrap();
1108
1109        // Both variants detect missing store files and return None.
1110        assert!(
1111            store
1112                .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1113                .is_none()
1114        );
1115        // save_index wrote the file and load_index just deleted it
1116        // after detecting the stale store entry, so re-seed before
1117        // exercising the verified variant.
1118        store
1119            .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1120            .unwrap();
1121        assert!(
1122            store
1123                .load_index_verified("pkg", "1.0.0", Some(TEST_INTEGRITY))
1124                .is_none()
1125        );
1126    }
1127
1128    #[test]
1129    fn load_index_passes_partial_corruption_load_index_verified_catches_it() {
1130        // The user's BuildKit failure mode: cached index references
1131        // multiple files; the iterated-first file's CAS shard
1132        // happens to still exist (or never did — `dist.size` is absent
1133        // on legacy indexes so the probe defaults to `exists()`), but a
1134        // later file's shard is gone. The fast `load_index` returns
1135        // Some(stale_index), which then dies inside the linker with
1136        // `ERR_AUBE_MISSING_STORE_FILE`. `load_index_verified` stats
1137        // every file and drops the index so the fetch path re-imports.
1138        let dir = tempfile::tempdir().unwrap();
1139        let store = Store::at(dir.path().join("files"));
1140
1141        // FxMap iteration is hash-based, so pinning "BBB.txt" as the
1142        // later-iterated entry the way the BTreeMap test did doesn't
1143        // hold. Instead, build the index, round-trip it through
1144        // save+load, and read *that* iteration order — `FxMap`'s
1145        // FixedState seed is stable, but the incremental-insert map's
1146        // bucket count can differ from a freshly-deserialized map's,
1147        // and the cheap probe runs on the deserialized path. Corrupt
1148        // a non-first-iterated file so both halves of the invariant —
1149        // cheap probe accepts, verified probe rejects — are
1150        // deterministic.
1151        let mut index = PackageIndex::default();
1152        for i in 0..8 {
1153            let stored = store
1154                .import_bytes(format!("content-{i}").as_bytes(), false)
1155                .unwrap();
1156            index.insert(format!("file-{i:02}.txt"), stored);
1157        }
1158        store
1159            .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1160            .unwrap();
1161        let loaded = store
1162            .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1163            .expect("freshly saved index must load before any corruption");
1164        let first_path = loaded.values().next().unwrap().store_path.clone();
1165        let dropped_path = loaded
1166            .values()
1167            .find(|f| f.store_path != first_path)
1168            .unwrap()
1169            .store_path
1170            .clone();
1171
1172        // Remove a non-first file's CAS shard.
1173        std::fs::remove_file(&dropped_path).unwrap();
1174
1175        // Cheap probe samples only the iterated-first file (still
1176        // healthy) and accepts the index — the bug class that motivated
1177        // the fix.
1178        assert!(
1179            store
1180                .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1181                .is_some(),
1182            "cheap probe must accept partial corruption (precondition for the fix)"
1183        );
1184        // Re-save defensively in case the cheap probe path ever drops
1185        // the index file on a future tuning. The verified-probe
1186        // assertion below is the real invariant.
1187        store
1188            .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1189            .unwrap();
1190
1191        // Verified probe walks every file and rejects the stale index.
1192        assert!(
1193            store
1194                .load_index_verified("pkg", "1.0.0", Some(TEST_INTEGRITY))
1195                .is_none(),
1196            "verified probe must reject an index whose later files are missing"
1197        );
1198
1199        // Side effect: load_index_verified drops the JSON so the next
1200        // fetch re-imports rather than racing on the same dead reference.
1201        let path = store.index_path("pkg", "1.0.0", Some(TEST_INTEGRITY));
1202        assert!(
1203            !path.unwrap().exists(),
1204            "verified probe must drop the stale cached index"
1205        );
1206    }
1207
1208    #[test]
1209    fn test_invalidate_cached_index_removes_entry() {
1210        let dir = tempfile::tempdir().unwrap();
1211        let store = Store::at(dir.path().join("files"));
1212
1213        let stored = store.import_bytes(b"content", false).unwrap();
1214        let mut index = PackageIndex::default();
1215        index.insert("index.js".to_string(), stored);
1216
1217        store
1218            .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1219            .unwrap();
1220        // First call removes the entry; second call sees it gone.
1221        assert!(
1222            store
1223                .invalidate_cached_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1224                .unwrap()
1225        );
1226        assert!(
1227            !store
1228                .invalidate_cached_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1229                .unwrap()
1230        );
1231        // load_index now misses, forcing a re-import on the next install.
1232        assert!(
1233            store
1234                .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1235                .is_none()
1236        );
1237    }
1238
1239    #[test]
1240    fn test_invalidate_cached_index_returns_false_for_invalid_coordinate() {
1241        let dir = tempfile::tempdir().unwrap();
1242        let store = Store::at(dir.path().join("files"));
1243        // Empty name doesn't yield a valid index path; must not error.
1244        assert!(
1245            !store
1246                .invalidate_cached_index("", "1.0.0", Some(TEST_INTEGRITY))
1247                .unwrap()
1248        );
1249    }
1250
1251    #[test]
1252    fn test_index_cache_rejects_size_mismatch() {
1253        let dir = tempfile::tempdir().unwrap();
1254        let store = Store::at(dir.path().join("files"));
1255
1256        let stored = store.import_bytes(b"content", false).unwrap();
1257        let store_path = stored.store_path.clone();
1258        let mut index = PackageIndex::default();
1259        index.insert("index.js".to_string(), stored);
1260
1261        store
1262            .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &index)
1263            .unwrap();
1264        std::fs::write(&store_path, b"").unwrap();
1265
1266        assert!(
1267            store
1268                .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1269                .is_none()
1270        );
1271    }
1272
1273    #[cfg(unix)]
1274    #[test]
1275    fn test_import_bytes_uses_world_readable_permissions() {
1276        use std::os::unix::fs::PermissionsExt;
1277
1278        let dir = tempfile::tempdir().unwrap();
1279        let store = Store::at(dir.path().join("files"));
1280
1281        let stored = store.import_bytes(b"content", false).unwrap();
1282        let mode = std::fs::metadata(&stored.store_path)
1283            .unwrap()
1284            .permissions()
1285            .mode()
1286            & 0o777;
1287
1288        assert_eq!(mode, 0o644);
1289    }
1290
1291    #[test]
1292    fn test_index_cache_miss() {
1293        let dir = tempfile::tempdir().unwrap();
1294        let store = Store::at(dir.path().join("files"));
1295
1296        assert!(
1297            store
1298                .load_index("nonexistent", "1.0.0", Some(TEST_INTEGRITY))
1299                .is_none()
1300        );
1301    }
1302
1303    #[test]
1304    fn test_index_cache_integrity_discriminates_sources() {
1305        // Regression: before this, two tarballs served under the same
1306        // `(name, version)` from different sources — e.g. a github
1307        // codeload archive and the npm-published bytes — would share
1308        // the `<name>@<version>.json` cache file and return each
1309        // other's file list to the linker.
1310        let dir = tempfile::tempdir().unwrap();
1311        let store = Store::at(dir.path().join("files"));
1312
1313        let registry_bytes = store.import_bytes(b"registry tarball", false).unwrap();
1314        let mut registry_index = PackageIndex::default();
1315        registry_index.insert("package.json".to_string(), registry_bytes);
1316
1317        let github_bytes = store.import_bytes(b"github tarball", false).unwrap();
1318        let mut github_index = PackageIndex::default();
1319        github_index.insert("package.json".to_string(), github_bytes);
1320        github_index.insert("extra-github-only.js".to_string(), {
1321            store.import_bytes(b"extra", false).unwrap()
1322        });
1323
1324        store
1325            .save_index("node-expat", "2.4.1", Some(TEST_INTEGRITY), &registry_index)
1326            .unwrap();
1327        store
1328            .save_index("node-expat", "2.4.1", Some(OTHER_INTEGRITY), &github_index)
1329            .unwrap();
1330
1331        // Each integrity returns its own distinct index.
1332        let registry = store
1333            .load_index("node-expat", "2.4.1", Some(TEST_INTEGRITY))
1334            .unwrap();
1335        let github = store
1336            .load_index("node-expat", "2.4.1", Some(OTHER_INTEGRITY))
1337            .unwrap();
1338        assert_eq!(registry.len(), 1);
1339        assert_eq!(github.len(), 2);
1340        assert!(github.contains_key("extra-github-only.js"));
1341    }
1342
1343    #[test]
1344    fn test_index_cache_rejects_malformed_integrity() {
1345        let dir = tempfile::tempdir().unwrap();
1346        let store = Store::at(dir.path().join("files"));
1347
1348        let stored = store.import_bytes(b"content", false).unwrap();
1349        let mut index = PackageIndex::default();
1350        index.insert("index.js".to_string(), stored);
1351
1352        // Not a `sha512-<base64>` string — save returns an error and
1353        // load returns None rather than falling back to a weaker key.
1354        assert!(
1355            store
1356                .save_index("pkg", "1.0.0", Some("not-an-integrity"), &index)
1357                .is_err()
1358        );
1359        assert!(
1360            store
1361                .load_index("pkg", "1.0.0", Some("not-an-integrity"))
1362                .is_none()
1363        );
1364    }
1365
1366    #[test]
1367    fn test_index_cache_integrity_none_roundtrip() {
1368        // Registry proxies that strip `dist.integrity` still need to
1369        // warm-install. With `integrity = None` the cache falls back
1370        // to `<name>@<version>.json` (no suffix), matching the
1371        // pre-integrity-keyed behavior for exactly that narrow case.
1372        let dir = tempfile::tempdir().unwrap();
1373        let store = Store::at(dir.path().join("files"));
1374
1375        let stored = store.import_bytes(b"no-integrity content", false).unwrap();
1376        let mut index = PackageIndex::default();
1377        index.insert("index.js".to_string(), stored);
1378
1379        store.save_index("pkg", "1.0.0", None, &index).unwrap();
1380        let loaded = store.load_index("pkg", "1.0.0", None);
1381        assert!(loaded.is_some());
1382        assert!(loaded.unwrap().contains_key("index.js"));
1383
1384        // The integrity-keyed key does *not* see the integrity-less
1385        // entry — different directory on disk.
1386        assert!(
1387            store
1388                .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1389                .is_none()
1390        );
1391    }
1392
1393    #[test]
1394    fn test_index_cache_build_metadata_does_not_collide_with_integrity() {
1395        // Regression: an earlier flat-filename scheme
1396        // (`<name>@<version>+<16 hex>.json`) could in theory collide
1397        // with an integrity-less entry for a version whose semver
1398        // build metadata was exactly 16 lowercase hex chars
1399        // (`1.0.0+a1b2c3d4e5f6a7b8`). The subdir layout forecloses
1400        // that: integrity lives in a directory, not in the filename.
1401        let dir = tempfile::tempdir().unwrap();
1402        let store = Store::at(dir.path().join("files"));
1403
1404        let a = store.import_bytes(b"integrity-keyed bytes", false).unwrap();
1405        let mut integrity_keyed = PackageIndex::default();
1406        integrity_keyed.insert("integrity-keyed.js".to_string(), a);
1407
1408        let b = store.import_bytes(b"build-metadata bytes", false).unwrap();
1409        let mut build_meta = PackageIndex::default();
1410        build_meta.insert("build-meta.js".to_string(), b);
1411
1412        // Integrity whose first 16 hex == the version's build metadata.
1413        // TEST_INTEGRITY hex-decodes to `ee26b0dd4af7e749...`, so the
1414        // directory name for the integrity-keyed entry is
1415        // `ee26b0dd4af7e749`. A version with that exact 16-hex build
1416        // metadata under the plain key must not alias it.
1417        let colliding_version = "1.0.0+ee26b0dd4af7e749";
1418        store
1419            .save_index("pkg", "1.0.0", Some(TEST_INTEGRITY), &integrity_keyed)
1420            .unwrap();
1421        store
1422            .save_index("pkg", colliding_version, None, &build_meta)
1423            .unwrap();
1424
1425        let by_integrity = store
1426            .load_index("pkg", "1.0.0", Some(TEST_INTEGRITY))
1427            .unwrap();
1428        let by_build_meta = store.load_index("pkg", colliding_version, None).unwrap();
1429        assert!(by_integrity.contains_key("integrity-keyed.js"));
1430        assert!(by_build_meta.contains_key("build-meta.js"));
1431        // And neither entry leaks into the other's file list.
1432        assert!(!by_integrity.contains_key("build-meta.js"));
1433        assert!(!by_build_meta.contains_key("integrity-keyed.js"));
1434    }
1435
1436    fn index_with_manifest(store: &Store, name: &str, version: &str) -> PackageIndex {
1437        let manifest =
1438            serde_json::json!({"name": name, "version": version, "main": "index.js"}).to_string();
1439        let stored = store.import_bytes(manifest.as_bytes(), false).unwrap();
1440        let mut index = PackageIndex::default();
1441        index.insert("package.json".to_string(), stored);
1442        index
1443    }
1444
1445    #[test]
1446    fn test_validate_pkg_content_match() {
1447        let dir = tempfile::tempdir().unwrap();
1448        let store = Store::at(dir.path().join("files"));
1449        let index = index_with_manifest(&store, "lodash", "4.17.21");
1450        assert!(validate_pkg_content(&index, "lodash", "4.17.21").is_ok());
1451    }
1452
1453    #[test]
1454    fn test_validate_pkg_content_name_mismatch() {
1455        let dir = tempfile::tempdir().unwrap();
1456        let store = Store::at(dir.path().join("files"));
1457        let index = index_with_manifest(&store, "evil-pkg", "1.0.0");
1458        let err = validate_pkg_content(&index, "lodash", "1.0.0").unwrap_err();
1459        let msg = err.to_string();
1460        // The variant only carries the *actual* coordinate; the
1461        // caller's `{name}@{version}: ` prefix supplies the expected
1462        // half. See the comment on `Error::PkgContentMismatch`.
1463        assert!(msg.contains("content mismatch"), "{msg}");
1464        assert!(msg.contains("declares evil-pkg@1.0.0"), "{msg}");
1465    }
1466
1467    #[test]
1468    fn test_validate_pkg_content_version_mismatch() {
1469        let dir = tempfile::tempdir().unwrap();
1470        let store = Store::at(dir.path().join("files"));
1471        let index = index_with_manifest(&store, "lodash", "9.9.9");
1472        let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
1473        let msg = err.to_string();
1474        assert!(msg.contains("content mismatch"), "{msg}");
1475        assert!(msg.contains("declares lodash@9.9.9"), "{msg}");
1476    }
1477
1478    #[test]
1479    fn test_validate_pkg_content_tolerates_leading_v() {
1480        let dir = tempfile::tempdir().unwrap();
1481        let store = Store::at(dir.path().join("files"));
1482        let index = index_with_manifest(&store, "@upstash/ratelimit", "v2.0.8");
1483        assert!(validate_pkg_content(&index, "@upstash/ratelimit", "2.0.8").is_ok());
1484    }
1485
1486    #[test]
1487    fn test_validate_pkg_content_tolerates_tarball_build_metadata() {
1488        let dir = tempfile::tempdir().unwrap();
1489        let store = Store::at(dir.path().join("files"));
1490        let index = index_with_manifest(&store, "@trpc/react-query", "11.0.0-rc.747+64714681c");
1491        assert!(validate_pkg_content(&index, "@trpc/react-query", "11.0.0-rc.747").is_ok());
1492    }
1493
1494    #[test]
1495    fn test_validate_pkg_content_build_metadata_keeps_base_version_strict() {
1496        let dir = tempfile::tempdir().unwrap();
1497        let store = Store::at(dir.path().join("files"));
1498        let index = index_with_manifest(&store, "@trpc/react-query", "11.0.0-rc.748+64714681c");
1499        let err = validate_pkg_content(&index, "@trpc/react-query", "11.0.0-rc.747").unwrap_err();
1500        assert!(err.to_string().contains("content mismatch"), "{err}");
1501    }
1502
1503    #[test]
1504    fn test_validate_pkg_content_skips_version_for_url_shaped_expected() {
1505        // pnpm v9 lockfiles key github-hosted deps by the codeload
1506        // tarball URL in the version slot; the tarball's real semver
1507        // will never match it. Skip the version comparison for
1508        // non-semver expected values, but still enforce the name.
1509        let dir = tempfile::tempdir().unwrap();
1510        let store = Store::at(dir.path().join("files"));
1511        let index = index_with_manifest(&store, "datejs", "1.0.0-rc3");
1512        let url = "https://codeload.github.com/abritinthebay/datejs/tar.gz/3675d46ed96d57e30aeddf9b1d1026ac81d37ae3";
1513        assert!(validate_pkg_content(&index, "datejs", url).is_ok());
1514        // Name mismatch still rejects.
1515        let err = validate_pkg_content(&index, "evil", url).unwrap_err();
1516        assert!(err.to_string().contains("content mismatch"), "{err}");
1517    }
1518
1519    #[test]
1520    fn test_validate_pkg_content_missing_manifest() {
1521        let dir = tempfile::tempdir().unwrap();
1522        let store = Store::at(dir.path().join("files"));
1523        let stored = store.import_bytes(b"module.exports = 1;", false).unwrap();
1524        let mut index = PackageIndex::default();
1525        index.insert("index.js".to_string(), stored);
1526        let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
1527        assert!(err.to_string().contains("package.json missing"), "{err}",);
1528    }
1529
1530    #[test]
1531    fn test_validate_pkg_content_unparseable_manifest() {
1532        let dir = tempfile::tempdir().unwrap();
1533        let store = Store::at(dir.path().join("files"));
1534        let stored = store.import_bytes(b"{not json", false).unwrap();
1535        let mut index = PackageIndex::default();
1536        index.insert("package.json".to_string(), stored);
1537        let err = validate_pkg_content(&index, "lodash", "4.17.21").unwrap_err();
1538        assert!(err.to_string().contains("invalid package.json"), "{err}");
1539    }
1540
1541    #[test]
1542    fn test_import_tarball() {
1543        // Create a minimal .tar.gz in memory
1544        let mut builder = tar::Builder::new(Vec::new());
1545
1546        let content = b"module.exports = 42;\n";
1547        let mut header = tar::Header::new_gnu();
1548        header.set_size(content.len() as u64);
1549        header.set_mode(0o644);
1550        header.set_cksum();
1551        builder
1552            .append_data(&mut header, "package/index.js", &content[..])
1553            .unwrap();
1554
1555        let bin_content = b"#!/usr/bin/env node\nconsole.log('hi');\n";
1556        let mut bin_header = tar::Header::new_gnu();
1557        bin_header.set_size(bin_content.len() as u64);
1558        bin_header.set_mode(0o755);
1559        bin_header.set_cksum();
1560        builder
1561            .append_data(&mut bin_header, "package/bin/cli.js", &bin_content[..])
1562            .unwrap();
1563
1564        let tar_bytes = builder.into_inner().unwrap();
1565
1566        // Gzip it
1567        use flate2::write::GzEncoder;
1568        use std::io::Write;
1569        let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::fast());
1570        encoder.write_all(&tar_bytes).unwrap();
1571        let tgz_bytes = encoder.finish().unwrap();
1572
1573        let dir = tempfile::tempdir().unwrap();
1574        let store = Store::at(dir.path().join("files"));
1575
1576        let index = store.import_tarball(&tgz_bytes).unwrap();
1577        assert_eq!(index.len(), 2);
1578        assert!(index.contains_key("index.js"));
1579        assert!(index.contains_key("bin/cli.js"));
1580
1581        // Verify file contents
1582        let idx_stored = &index["index.js"];
1583        assert!(!idx_stored.executable);
1584        assert_eq!(std::fs::read(&idx_stored.store_path).unwrap(), content);
1585
1586        let bin_stored = &index["bin/cli.js"];
1587        assert!(bin_stored.executable);
1588        assert_eq!(std::fs::read(&bin_stored.store_path).unwrap(), bin_content);
1589    }
1590
1591    #[test]
1592    fn test_git_url_host_https() {
1593        assert_eq!(
1594            git_url_host("https://github.com/user/repo.git"),
1595            Some("github.com")
1596        );
1597        assert_eq!(
1598            git_url_host("git+https://github.com/user/repo.git#main"),
1599            Some("github.com")
1600        );
1601        assert_eq!(
1602            git_url_host("git://git.example.com/repo.git"),
1603            Some("git.example.com")
1604        );
1605    }
1606
1607    #[test]
1608    fn test_git_url_host_ssh() {
1609        assert_eq!(
1610            git_url_host("git+ssh://git@github.com/user/repo.git"),
1611            Some("github.com")
1612        );
1613        assert_eq!(
1614            git_url_host("ssh://git@gitlab.com:2222/user/repo.git"),
1615            Some("gitlab.com")
1616        );
1617        // scp-style URL (no scheme): git@host:path
1618        assert_eq!(
1619            git_url_host("git@github.com:user/repo.git"),
1620            Some("github.com")
1621        );
1622    }
1623
1624    #[test]
1625    fn test_git_url_host_ipv6() {
1626        // IPv6 literals must keep their colons — the port-strip pass
1627        // has to unwrap the brackets before it even considers `:`.
1628        assert_eq!(git_url_host("https://[::1]/repo.git"), Some("::1"));
1629        assert_eq!(git_url_host("https://[::1]:8443/repo.git"), Some("::1"));
1630        assert_eq!(
1631            git_url_host("ssh://git@[2001:db8::1]:2222/user/repo.git"),
1632            Some("2001:db8::1")
1633        );
1634    }
1635
1636    #[test]
1637    fn test_git_url_host_rejects_garbage() {
1638        assert_eq!(git_url_host(""), None);
1639        assert_eq!(git_url_host("not a url"), None);
1640        assert_eq!(git_url_host("/just/a/path"), None);
1641    }
1642
1643    #[test]
1644    fn test_git_host_in_list_exact_match() {
1645        let hosts = vec![
1646            "github.com".to_string(),
1647            "gitlab.com".to_string(),
1648            "bitbucket.org".to_string(),
1649        ];
1650        assert!(git_host_in_list("https://github.com/user/repo.git", &hosts));
1651        assert!(git_host_in_list(
1652            "git+ssh://git@gitlab.com/user/repo.git",
1653            &hosts
1654        ));
1655        // Exact match — no subdomain folding, matching pnpm semantics.
1656        assert!(!git_host_in_list(
1657            "https://api.github.com/user/repo.git",
1658            &hosts
1659        ));
1660        assert!(!git_host_in_list(
1661            "https://self-hosted.example/user/repo.git",
1662            &hosts
1663        ));
1664    }
1665
1666    #[test]
1667    fn test_git_host_in_list_empty_list() {
1668        let hosts: Vec<String> = vec![];
1669        assert!(!git_host_in_list(
1670            "https://github.com/user/repo.git",
1671            &hosts
1672        ));
1673    }
1674
1675    #[test]
1676    fn test_validate_git_positional_accepts_normal_values() {
1677        validate_git_positional("https://github.com/u/r.git", "git url").unwrap();
1678        validate_git_positional("git@github.com:u/r.git", "git url").unwrap();
1679        validate_git_positional("main", "git commit").unwrap();
1680        validate_git_positional("0123456789abcdef0123456789abcdef01234567", "git commit").unwrap();
1681    }
1682
1683    #[test]
1684    fn test_validate_git_positional_rejects_dash_prefix() {
1685        // CVE-2017-1000117 class: git treats a leading `-` as an
1686        // option. `--upload-pack=...` is the classic payload.
1687        let err = validate_git_positional("--upload-pack=/tmp/evil", "git url").unwrap_err();
1688        assert!(matches!(err, Error::Git(_)));
1689        let err = validate_git_positional("-oX", "git commit").unwrap_err();
1690        assert!(matches!(err, Error::Git(_)));
1691    }
1692
1693    #[test]
1694    fn test_validate_git_positional_rejects_nul() {
1695        let err = validate_git_positional("normal\0tail", "git url").unwrap_err();
1696        assert!(matches!(err, Error::Git(_)));
1697    }
1698
1699    #[test]
1700    fn test_git_resolve_ref_rejects_dash_prefixed_url() {
1701        // Must refuse before ever spawning `git ls-remote`. Confirms
1702        // the validation runs at the public entry point.
1703        let err = git_resolve_ref("--upload-pack=/tmp/evil", None).unwrap_err();
1704        assert!(matches!(err, Error::Git(_)));
1705    }
1706
1707    #[test]
1708    fn test_git_commands_disable_terminal_prompts() {
1709        let command = git_command();
1710        let prompt = command
1711            .get_envs()
1712            .find(|(name, _)| *name == "GIT_TERMINAL_PROMPT")
1713            .and_then(|(_, value)| value);
1714        assert_eq!(prompt, Some(std::ffi::OsStr::new("0")));
1715    }
1716
1717    #[test]
1718    fn test_git_resolve_ref_full_sha_is_offline() {
1719        // 40-char hex committish short-circuits `ls-remote`. Confirm
1720        // by handing a non-existent URL — if the fast path regressed
1721        // into a network call, the test would fail to spawn git.
1722        let sha = "0123456789ABCDEF0123456789abcdef01234567";
1723        let resolved = git_resolve_ref("https://example.invalid/missing.git", Some(sha)).unwrap();
1724        assert_eq!(resolved, "0123456789abcdef0123456789abcdef01234567");
1725    }
1726
1727    #[test]
1728    fn test_git_commit_matches_prefix() {
1729        let full = "0b6ea539609031977983f0b2393ebe81ee28c8ec";
1730        assert!(git_commit_matches(full, full));
1731        assert!(git_commit_matches(full, "0b6ea53"));
1732        assert!(!git_commit_matches(full, "0b6ea5"));
1733        assert!(!git_commit_matches(full, "abc1234"));
1734        assert!(!git_commit_matches(full, "main"));
1735    }
1736
1737    /// Build a minimal codeload-style `.tar.gz`: a wrapper directory
1738    /// `<wrapper>/` followed by a few file entries inside it. Mirrors
1739    /// the layout `https://codeload.github.com/<owner>/<repo>/tar.gz/<sha>`
1740    /// produces in the wild.
1741    fn build_codeload_tarball(wrapper: &str, files: &[(&str, &[u8])]) -> Vec<u8> {
1742        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1743        let mut ar = tar::Builder::new(gz);
1744        let mut dh = tar::Header::new_gnu();
1745        dh.set_path(format!("{wrapper}/")).unwrap();
1746        dh.set_size(0);
1747        dh.set_mode(0o755);
1748        dh.set_entry_type(tar::EntryType::Directory);
1749        dh.set_cksum();
1750        ar.append(&dh, std::io::empty()).unwrap();
1751        for (path, content) in files {
1752            let mut h = tar::Header::new_gnu();
1753            h.set_path(format!("{wrapper}/{path}")).unwrap();
1754            h.set_size(content.len() as u64);
1755            h.set_mode(0o644);
1756            h.set_cksum();
1757            ar.append(&h, *content).unwrap();
1758        }
1759        let gz = ar.into_inner().unwrap();
1760        gz.finish().unwrap()
1761    }
1762
1763    #[test]
1764    fn extract_codeload_tarball_strips_wrapper_and_caches() {
1765        // Each test gets its own private cache root via `tempfile`,
1766        // so the three new codeload tests don't race on a process-wide
1767        // `XDG_CACHE_HOME` mutation under `cargo test`'s default
1768        // parallel scheduling. Windows surfaces the race as a
1769        // PermissionDenied on a sibling test's already-dropped temp
1770        // dir; Linux happens to schedule us out of it.
1771        let tmp = tempfile::tempdir().unwrap();
1772        let sha = "abcdef0123456789abcdef0123456789abcdef01";
1773        let wrapper = format!("owner-repo-{}", &sha[..7]);
1774        let bytes = build_codeload_tarball(
1775            &wrapper,
1776            &[
1777                ("package.json", br#"{"name":"x","version":"0.0.1"}"#),
1778                ("src/index.js", b"module.exports = 1;\n"),
1779            ],
1780        );
1781        let url = "https://github.com/owner/repo.git";
1782        let (target, head) =
1783            extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1784        assert_eq!(head, sha);
1785        // Wrapper component is stripped — `package.json` lives at the
1786        // target root, not under `target/<wrapper>/package.json`.
1787        assert!(target.join("package.json").is_file());
1788        assert!(target.join("src/index.js").is_file());
1789        assert!(!target.join(&wrapper).exists());
1790
1791        // Second call with the same (url, commit) reuses the cached
1792        // directory rather than re-extracting.
1793        let (target2, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1794        assert_eq!(target, target2);
1795        assert!(super::git::codeload_integrity_path(&target).is_file());
1796        assert!(
1797            super::git::read_codeload_integrity(&target)
1798                .as_deref()
1799                .is_some_and(|s| s.starts_with("sha512-"))
1800        );
1801    }
1802
1803    #[test]
1804    fn codeload_cache_lookup_returns_target_only_after_extract() {
1805        // Lookup must only report `Some` once the cache directory
1806        // exists, so callers can use it to skip the HTTPS round-trip
1807        // on resolver→installer reuse without falsely short-circuiting
1808        // before the bytes have ever been fetched.
1809        let tmp = tempfile::tempdir().unwrap();
1810        let sha = "fedcba9876543210fedcba9876543210fedcba98";
1811        let wrapper = format!("owner-repo-{}", &sha[..7]);
1812        let url = "https://github.com/owner/repo.git";
1813        let bytes = build_codeload_tarball(
1814            &wrapper,
1815            &[("package.json", br#"{"name":"x","version":"0.0.1"}"#)],
1816        );
1817
1818        // Pre-extract miss.
1819        let (expected_target, expected_sha) =
1820            codeload_cache_paths(tmp.path(), url, sha, None).unwrap();
1821        assert!(!expected_target.exists());
1822        // The public lookup uses the real `dirs::cache_dir()` so the
1823        // test path can't drive it directly. Instead, drive the inner
1824        // helper through the cache_paths function and verify the
1825        // exists check parallels the `is_dir` filter `codeload_cache_lookup`
1826        // applies. After extract, the lookup-equivalent must succeed.
1827        let (target, head) =
1828            extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1829        assert_eq!(target, expected_target);
1830        assert_eq!(head, expected_sha);
1831        assert!(target.is_dir(), "extract must populate the cache target");
1832        // A second `extract_codeload_tarball_at` (the equivalent of a
1833        // post-resolver install-time call) reuses the same dir without
1834        // re-extracting — same cache path comes back.
1835        let (target2, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1836        assert_eq!(target, target2);
1837    }
1838
1839    #[test]
1840    fn codeload_integrity_sidecar_uses_integrity_keyed_cache_path() {
1841        let tmp = tempfile::tempdir().unwrap();
1842        let sha = "1234567890abcdef1234567890abcdef12345678";
1843        let wrapper = format!("owner-repo-{}", &sha[..7]);
1844        let bytes = build_codeload_tarball(
1845            &wrapper,
1846            &[("package.json", br#"{"name":"x","version":"0.0.1"}"#)],
1847        );
1848        let url = "https://github.com/owner/repo.git";
1849        let expected_integrity = "sha512-expected";
1850
1851        let (target, _) =
1852            extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, Some(expected_integrity))
1853                .unwrap();
1854        let (keyed_target, _) =
1855            codeload_cache_paths(tmp.path(), url, sha, Some(expected_integrity)).unwrap();
1856        let (unkeyed_target, _) = codeload_cache_paths(tmp.path(), url, sha, None).unwrap();
1857
1858        assert_eq!(target, keyed_target);
1859        assert_ne!(target, unkeyed_target);
1860        assert!(read_codeload_integrity(&target).is_some());
1861        assert!(read_codeload_integrity(&unkeyed_target).is_none());
1862    }
1863
1864    #[test]
1865    fn extract_codeload_tarball_backfills_missing_integrity_sidecar() {
1866        let tmp = tempfile::tempdir().unwrap();
1867        let sha = "0123456789abcdef0123456789abcdef01234567";
1868        let wrapper = format!("owner-repo-{}", &sha[..7]);
1869        let bytes = build_codeload_tarball(
1870            &wrapper,
1871            &[("package.json", br#"{"name":"x","version":"0.0.1"}"#)],
1872        );
1873        let url = "https://github.com/owner/repo.git";
1874        let (target, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1875        let sidecar = super::git::codeload_integrity_path(&target);
1876        std::fs::remove_file(&sidecar).unwrap();
1877
1878        let (target2, _) = extract_codeload_tarball_at(tmp.path(), &bytes, url, sha, None).unwrap();
1879        assert_eq!(target, target2);
1880        assert!(
1881            super::git::read_codeload_integrity(&target2)
1882                .as_deref()
1883                .is_some_and(|s| s.starts_with("sha512-"))
1884        );
1885    }
1886
1887    #[test]
1888    fn codeload_cache_paths_rejects_invalid_inputs() {
1889        let tmp = tempfile::tempdir().unwrap();
1890        // Abbreviated SHA — codeload extracts can't be verified for
1891        // non-full-SHA committishes, so the cache key would be ambiguous.
1892        assert!(
1893            codeload_cache_paths(tmp.path(), "https://example.com/r.git", "abc1234", None)
1894                .is_none()
1895        );
1896        // Dash-prefixed URL — `validate_git_positional` rejects.
1897        assert!(
1898            codeload_cache_paths(
1899                tmp.path(),
1900                "--upload-pack=/tmp/evil",
1901                "abcdef0123456789abcdef0123456789abcdef01",
1902                None,
1903            )
1904            .is_none()
1905        );
1906        // Branch name — not a SHA.
1907        assert!(
1908            codeload_cache_paths(tmp.path(), "https://example.com/r.git", "main", None).is_none()
1909        );
1910    }
1911
1912    #[test]
1913    fn codeload_cache_paths_include_integrity_when_present() {
1914        let tmp = tempfile::tempdir().unwrap();
1915        let sha = "abcdef0123456789abcdef0123456789abcdef01";
1916        let url = "https://example.com/r.git";
1917        let no_integrity = codeload_cache_paths(tmp.path(), url, sha, None).unwrap();
1918        let with_integrity = codeload_cache_paths(tmp.path(), url, sha, Some("sha512-a")).unwrap();
1919        let with_other_integrity =
1920            codeload_cache_paths(tmp.path(), url, sha, Some("sha512-b")).unwrap();
1921
1922        assert_ne!(no_integrity.0, with_integrity.0);
1923        assert_ne!(with_integrity.0, with_other_integrity.0);
1924        assert_eq!(with_integrity.1, sha);
1925    }
1926
1927    #[test]
1928    fn extract_codeload_tarball_rejects_unsafe_paths() {
1929        let tmp = tempfile::tempdir().unwrap();
1930        let sha = "1111111111111111111111111111111111111111";
1931        // The tar crate's safe `set_path` rejects `..` paths up front,
1932        // so a crafted archive must be assembled with the header name
1933        // field written directly. This mirrors what a hostile
1934        // codeload mirror could serve, and verifies our component
1935        // check catches it before any byte lands on disk.
1936        let body = b"pwn";
1937        let mut h = tar::Header::new_gnu();
1938        h.set_size(body.len() as u64);
1939        h.set_mode(0o644);
1940        h.set_entry_type(tar::EntryType::Regular);
1941        // GNU header `name[100]` — write directly to skip set_path's
1942        // safety filter.
1943        let raw = b"wrapper/../escape.txt";
1944        let name = &mut h.as_gnu_mut().unwrap().name;
1945        name[..raw.len()].copy_from_slice(raw);
1946        h.set_cksum();
1947        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1948        let mut ar = tar::Builder::new(gz);
1949        ar.append(&h, &body[..]).unwrap();
1950        let bytes = ar.into_inner().unwrap().finish().unwrap();
1951        let err =
1952            extract_codeload_tarball_at(tmp.path(), &bytes, "https://example.com/r.git", sha, None)
1953                .unwrap_err();
1954        assert!(
1955            matches!(err, Error::Tar(ref m) if m.contains("unsafe")),
1956            "expected Error::Tar with unsafe-path message, got {err:?}",
1957        );
1958    }
1959
1960    #[test]
1961    fn extract_codeload_tarball_rejects_short_commit() {
1962        // The cache layout assumes `commit` is the canonical 40-hex
1963        // SHA, both for the cache key and as the returned head_sha.
1964        // Branch / tag / abbreviated values must be pinned by an
1965        // upstream `git ls-remote` before reaching here.
1966        let tmp = tempfile::tempdir().unwrap();
1967        let bytes = build_codeload_tarball("wrapper", &[("ok", b"ok")]);
1968        let err = extract_codeload_tarball_at(
1969            tmp.path(),
1970            &bytes,
1971            "https://example.com/r.git",
1972            "abc1234",
1973            None,
1974        )
1975        .unwrap_err();
1976        assert!(matches!(err, Error::Git(ref m) if m.contains("40-char")));
1977    }
1978
1979    #[test]
1980    fn test_git_shallow_clone_rejects_dash_prefixed_url() {
1981        let err = git_shallow_clone("--upload-pack=/tmp/evil", "main", false).unwrap_err();
1982        assert!(matches!(err, Error::Git(_)));
1983    }
1984
1985    #[test]
1986    fn test_git_shallow_clone_rejects_dash_prefixed_commit() {
1987        // `git checkout -- <commit>` treats <commit> as a pathspec,
1988        // so the `--` separator is unavailable at that call site.
1989        // The entry-point check is the only defense.
1990        let err = git_shallow_clone("https://github.com/u/r.git", "-X-evil", false).unwrap_err();
1991        assert!(matches!(err, Error::Git(_)));
1992    }
1993
1994    /// Build a minimal `.tgz` containing a single entry with the
1995    /// given path / size / content. `set_path` goes through the
1996    /// public API so the tar crate's own safety checks run.
1997    fn build_tarball(path: &str, content: &[u8]) -> Vec<u8> {
1998        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1999        let mut ar = tar::Builder::new(gz);
2000        let mut h = tar::Header::new_gnu();
2001        h.set_path(path).unwrap();
2002        h.set_size(content.len() as u64);
2003        h.set_mode(0o644);
2004        h.set_cksum();
2005        ar.append(&h, content).unwrap();
2006        ar.into_inner().unwrap().finish().unwrap()
2007    }
2008
2009    #[test]
2010    fn test_import_tarball_accepts_normal_sized_entry() {
2011        let dir = tempfile::tempdir().unwrap();
2012        let store = Store::at(dir.path().join("files"));
2013        store.ensure_shards_exist().unwrap();
2014        let tarball = build_tarball("package/index.js", b"console.log('hi');");
2015        let index = store.import_tarball(&tarball).unwrap();
2016        assert_eq!(index.len(), 1);
2017        assert!(index.contains_key("index.js"));
2018    }
2019
2020    #[test]
2021    fn test_import_tarball_streams_large_entry_into_cas() {
2022        let dir = tempfile::tempdir().unwrap();
2023        let store = Store::at(dir.path().join("files"));
2024        store.ensure_shards_exist().unwrap();
2025        let content: Vec<u8> = (0..(256 << 10)).map(|i| (i % 251) as u8).collect();
2026        let tarball = build_tarball("package/bin/native", &content);
2027
2028        let index = store.import_tarball(&tarball).unwrap();
2029        let stored = &index["bin/native"];
2030
2031        assert_eq!(stored.hex_hash, blake3_hex(&content));
2032        assert_eq!(stored.size, Some(content.len() as u64));
2033        assert_eq!(std::fs::read(&stored.store_path).unwrap(), content);
2034        assert!(
2035            std::fs::read_dir(dir.path().join("files"))
2036                .unwrap()
2037                .all(|entry| !entry
2038                    .unwrap()
2039                    .file_name()
2040                    .to_string_lossy()
2041                    .starts_with(".aube-stream-"))
2042        );
2043    }
2044
2045    #[cfg(not(windows))]
2046    #[test]
2047    fn test_import_tarball_accepts_posix_colon_filename() {
2048        let dir = tempfile::tempdir().unwrap();
2049        let store = Store::at(dir.path().join("files"));
2050        store.ensure_shards_exist().unwrap();
2051        let tarball = build_tarball(
2052            "package/dist/__mocks__/package-json:version.d.ts",
2053            b"export {};",
2054        );
2055        let index = store.import_tarball(&tarball).unwrap();
2056        assert!(index.contains_key("dist/__mocks__/package-json:version.d.ts"));
2057    }
2058
2059    #[test]
2060    fn test_import_tarball_rejects_per_entry_cap_exceeded() {
2061        // A single entry with a declared size past the per-entry cap
2062        // must be rejected before we allocate or read its contents.
2063        let dir = tempfile::tempdir().unwrap();
2064        let store = Store::at(dir.path().join("files"));
2065        let oversize = (MAX_TARBALL_ENTRY_BYTES + 1) as usize;
2066        // Small actual content. The declared size in the header is
2067        // what matters for the fast-path rejection. We craft the
2068        // header manually to avoid writing a real 512 MiB payload.
2069        let mut h = tar::Header::new_gnu();
2070        h.set_path("package/huge.bin").unwrap();
2071        h.set_size(oversize as u64);
2072        h.set_mode(0o644);
2073        h.set_cksum();
2074        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2075        let mut ar = tar::Builder::new(gz);
2076        // `append` refuses a size/content mismatch, so we manually
2077        // emit the header + pad to a 512-byte block without actual
2078        // content. The per-entry-cap check runs on `header.size()`
2079        // before any read, so the stream shape past the header does
2080        // not matter for this test.
2081        ar.append(&h, &[][..]).ok();
2082        let tarball = ar.into_inner().unwrap().finish().unwrap();
2083        let err = store.import_tarball(&tarball).unwrap_err();
2084        let msg = match err {
2085            Error::Tar(m) => m,
2086            other => panic!("expected Error::Tar, got {other:?}"),
2087        };
2088        assert!(msg.contains("per-entry cap"), "unexpected error: {msg}");
2089    }
2090
2091    #[test]
2092    fn test_import_tarball_rejects_archive_decompression_cap() {
2093        // Two entries whose combined decompressed size exceeds the
2094        // archive cap while each stays under the per-entry cap. The
2095        // cap is enforced by wrapping the gzip decoder in
2096        // `Read::take(cap)`, so the wrapped reader hits EOF mid-way
2097        // through the second entry and the archive iteration errors.
2098        //
2099        // `MAX_TARBALL_DECOMPRESSED_BYTES` and `MAX_TARBALL_ENTRY_BYTES`
2100        // are both reduced under `cfg(test)` so this test builds
2101        // only a couple of MiB of payload and stays CI-fast.
2102        let dir = tempfile::tempdir().unwrap();
2103        let store = Store::at(dir.path().join("files"));
2104        store.ensure_shards_exist().unwrap();
2105
2106        let half = ((MAX_TARBALL_DECOMPRESSED_BYTES / 2) + 1024) as usize;
2107        let chunk = vec![0u8; half];
2108        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2109        let mut ar = tar::Builder::new(gz);
2110        for i in 0..2 {
2111            let mut h = tar::Header::new_gnu();
2112            h.set_path(format!("package/chunk{i}.bin")).unwrap();
2113            h.set_size(chunk.len() as u64);
2114            h.set_mode(0o644);
2115            h.set_cksum();
2116            ar.append(&h, &chunk[..]).unwrap();
2117        }
2118        let tarball = ar.into_inner().unwrap().finish().unwrap();
2119
2120        let err = store.import_tarball(&tarball).unwrap_err();
2121        assert!(matches!(err, Error::Tar(_)));
2122    }
2123
2124    #[test]
2125    fn test_import_tarball_rejects_entry_count_cap() {
2126        // `MAX_TARBALL_ENTRIES` is reduced under `cfg(test)` so this
2127        // test only appends a few dozen empty entries.
2128        let dir = tempfile::tempdir().unwrap();
2129        let store = Store::at(dir.path().join("files"));
2130        store.ensure_shards_exist().unwrap();
2131
2132        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2133        let mut ar = tar::Builder::new(gz);
2134        for i in 0..=MAX_TARBALL_ENTRIES {
2135            let mut h = tar::Header::new_gnu();
2136            h.set_path(format!("package/f{i}.txt")).unwrap();
2137            h.set_size(0);
2138            h.set_mode(0o644);
2139            h.set_cksum();
2140            ar.append(&h, &[][..]).unwrap();
2141        }
2142        let tarball = ar.into_inner().unwrap().finish().unwrap();
2143
2144        let err = store.import_tarball(&tarball).unwrap_err();
2145        let msg = match err {
2146            Error::Tar(m) => m,
2147            other => panic!("expected Error::Tar, got {other:?}"),
2148        };
2149        assert!(msg.contains("entry cap"), "unexpected error: {msg}");
2150    }
2151
2152    // ---------------------------------------------------------------
2153    // Path traversal / zip-slip defences.
2154    //
2155    // A malicious tarball can try to write files outside the package
2156    // directory at install time by crafting entry paths with `..`,
2157    // absolute roots, Windows drive prefixes, or smuggled separators
2158    // inside a single component. `normalize_tar_entry_path` must
2159    // refuse every such shape before the key enters the
2160    // `PackageIndex`, and `import_tarball` must refuse symlink /
2161    // hardlink / device / fifo entries regardless of their path.
2162    // ---------------------------------------------------------------
2163
2164    fn build_raw_named_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
2165        // The `tar` crate's `Builder::append` refuses to write `..`
2166        // paths and other malformed shapes, which is precisely what
2167        // this test suite needs to construct. Write the header
2168        // `name` field raw to bypass the safety check — a real
2169        // attacker uploading a `.tgz` to a registry has no such
2170        // guard.
2171        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2172        let mut ar = tar::Builder::new(gz);
2173        for (path, data) in entries {
2174            let mut h = tar::Header::new_gnu();
2175            h.set_path("placeholder").unwrap();
2176            let name = &mut h.as_old_mut().name;
2177            name.fill(0);
2178            let bytes = path.as_bytes();
2179            assert!(bytes.len() < 100, "path too long for ustar name field");
2180            name[..bytes.len()].copy_from_slice(bytes);
2181            h.set_size(data.len() as u64);
2182            h.set_mode(0o644);
2183            h.set_cksum();
2184            ar.append(&h, *data).unwrap();
2185        }
2186        ar.into_inner().unwrap().finish().unwrap()
2187    }
2188
2189    #[test]
2190    fn normalize_tar_entry_path_accepts_plain_keys() {
2191        assert_eq!(
2192            normalize_tar_entry_path(Path::new("package/index.js")).unwrap(),
2193            Some("index.js".to_string())
2194        );
2195        assert_eq!(
2196            normalize_tar_entry_path(Path::new("package/lib/util/a.js")).unwrap(),
2197            Some("lib/util/a.js".to_string())
2198        );
2199    }
2200
2201    #[test]
2202    fn normalize_tar_entry_path_skips_wrapper_only_entry() {
2203        assert_eq!(
2204            normalize_tar_entry_path(Path::new("package")).unwrap(),
2205            None
2206        );
2207        assert_eq!(
2208            normalize_tar_entry_path(Path::new("package/")).unwrap(),
2209            None
2210        );
2211    }
2212
2213    #[test]
2214    fn normalize_tar_entry_path_collapses_cur_dir() {
2215        assert_eq!(
2216            normalize_tar_entry_path(Path::new("package/./foo.js")).unwrap(),
2217            Some("foo.js".to_string())
2218        );
2219    }
2220
2221    #[test]
2222    fn normalize_tar_entry_path_rejects_parent_dir() {
2223        let err = normalize_tar_entry_path(Path::new("package/../etc/passwd")).unwrap_err();
2224        assert!(matches!(err, Error::Tar(_)));
2225    }
2226
2227    #[test]
2228    fn normalize_tar_entry_path_rejects_parent_dir_after_leading_cur_dir() {
2229        // `./../file` must be rejected. An earlier version of the
2230        // validator ran the ParentDir check against the raw first
2231        // component, so the `.` passed it and the `..` was then
2232        // silently consumed as the wrapper directory.
2233        let err = normalize_tar_entry_path(Path::new("./../file")).unwrap_err();
2234        assert!(matches!(err, Error::Tar(_)));
2235        let err = normalize_tar_entry_path(Path::new("././../etc/passwd")).unwrap_err();
2236        assert!(matches!(err, Error::Tar(_)));
2237    }
2238
2239    #[test]
2240    fn normalize_tar_entry_path_rejects_absolute_path() {
2241        let err = normalize_tar_entry_path(Path::new("/etc/passwd")).unwrap_err();
2242        assert!(matches!(err, Error::Tar(_)));
2243    }
2244
2245    #[test]
2246    fn normalize_tar_entry_path_rejects_smuggled_backslash() {
2247        // On unix `Path::components` leaves `a\b` as one Normal
2248        // component with a literal backslash inside. Reject.
2249        let err = normalize_tar_entry_path(Path::new("package/a\\..\\etc")).unwrap_err();
2250        assert!(matches!(err, Error::Tar(_)));
2251    }
2252
2253    #[cfg(windows)]
2254    #[test]
2255    fn normalize_tar_entry_path_rejects_colon_on_windows() {
2256        let err = normalize_tar_entry_path(Path::new("package/C:evil")).unwrap_err();
2257        assert!(matches!(err, Error::Tar(_)));
2258    }
2259
2260    #[test]
2261    fn normalize_tar_entry_path_rejects_nul() {
2262        let err = normalize_tar_entry_path(Path::new("package/a\0b")).unwrap_err();
2263        assert!(matches!(err, Error::Tar(_)));
2264    }
2265
2266    #[test]
2267    fn test_import_tarball_rejects_parent_dir_escape() {
2268        // End-to-end: the crafted tarball that the prior zip-slip
2269        // reproducer used. `import_tarball` must refuse it and
2270        // produce no `PackageIndex` entries.
2271        let dir = tempfile::tempdir().unwrap();
2272        let store = Store::at(dir.path().join("files"));
2273        store.ensure_shards_exist().unwrap();
2274        let tarball = build_raw_named_tarball(&[
2275            ("package/package.json", b"{}"),
2276            ("package/../../../etc/cron.d/evil", b"* * * * * root id\n"),
2277        ]);
2278        let err = store.import_tarball(&tarball).unwrap_err();
2279        assert!(matches!(err, Error::Tar(_)));
2280    }
2281
2282    #[test]
2283    fn test_import_tarball_rejects_absolute_entry() {
2284        let dir = tempfile::tempdir().unwrap();
2285        let store = Store::at(dir.path().join("files"));
2286        store.ensure_shards_exist().unwrap();
2287        let tarball = build_raw_named_tarball(&[
2288            ("package/package.json", b"{}"),
2289            ("/etc/passwd", b"root:x:0:0\n"),
2290        ]);
2291        let err = store.import_tarball(&tarball).unwrap_err();
2292        assert!(matches!(err, Error::Tar(_)));
2293    }
2294
2295    #[test]
2296    fn test_import_tarball_rejects_symlink_entry() {
2297        // Symlink entries let a malicious package place the eventual
2298        // `pkg_dir.join(key)` file through a symlink that points
2299        // outside the package root. Refuse the entire class.
2300        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2301        let mut ar = tar::Builder::new(gz);
2302        let mut h = tar::Header::new_gnu();
2303        h.set_path("package/sneaky").unwrap();
2304        h.set_size(0);
2305        h.set_mode(0o644);
2306        h.set_entry_type(tar::EntryType::Symlink);
2307        h.set_link_name("/etc/passwd").unwrap();
2308        h.set_cksum();
2309        ar.append(&h, &[][..]).unwrap();
2310        let tarball = ar.into_inner().unwrap().finish().unwrap();
2311
2312        let dir = tempfile::tempdir().unwrap();
2313        let store = Store::at(dir.path().join("files"));
2314        store.ensure_shards_exist().unwrap();
2315        let err = store.import_tarball(&tarball).unwrap_err();
2316        assert!(matches!(err, Error::Tar(_)));
2317    }
2318
2319    #[test]
2320    fn test_import_tarball_rejects_hardlink_entry() {
2321        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2322        let mut ar = tar::Builder::new(gz);
2323        let mut h = tar::Header::new_gnu();
2324        h.set_path("package/clobber").unwrap();
2325        h.set_size(0);
2326        h.set_mode(0o644);
2327        h.set_entry_type(tar::EntryType::Link);
2328        h.set_link_name("../../../../home/victim/.ssh/authorized_keys")
2329            .unwrap();
2330        h.set_cksum();
2331        ar.append(&h, &[][..]).unwrap();
2332        let tarball = ar.into_inner().unwrap().finish().unwrap();
2333
2334        let dir = tempfile::tempdir().unwrap();
2335        let store = Store::at(dir.path().join("files"));
2336        store.ensure_shards_exist().unwrap();
2337        let err = store.import_tarball(&tarball).unwrap_err();
2338        assert!(matches!(err, Error::Tar(_)));
2339    }
2340
2341    #[test]
2342    fn test_import_tarball_skips_pax_global_header() {
2343        // GitHub-generated tarballs (e.g. `imap@0.8.19`) start with a
2344        // PAX global header carrying the source git blob SHA in a
2345        // `comment=...` record. The entry is metadata-only and has no
2346        // file content; npm/pnpm/bun skip it silently. The extractor
2347        // must not reject the tarball on sight.
2348        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
2349        let mut ar = tar::Builder::new(gz);
2350
2351        let pax_body = b"52 comment=867aa88a335a266b904e0b5d1a3b0b5d1a3b0b5d1\n";
2352        let mut gh = tar::Header::new_ustar();
2353        gh.set_path("pax_global_header").unwrap();
2354        gh.set_size(pax_body.len() as u64);
2355        gh.set_mode(0o644);
2356        gh.set_entry_type(tar::EntryType::XGlobalHeader);
2357        gh.set_cksum();
2358        ar.append(&gh, &pax_body[..]).unwrap();
2359
2360        let body = b"// ok";
2361        let mut fh = tar::Header::new_gnu();
2362        fh.set_path("package/index.js").unwrap();
2363        fh.set_size(body.len() as u64);
2364        fh.set_mode(0o644);
2365        fh.set_cksum();
2366        ar.append(&fh, &body[..]).unwrap();
2367
2368        let tarball = ar.into_inner().unwrap().finish().unwrap();
2369
2370        let dir = tempfile::tempdir().unwrap();
2371        let store = Store::at(dir.path().join("files"));
2372        store.ensure_shards_exist().unwrap();
2373        let index = store.import_tarball(&tarball).unwrap();
2374        assert!(index.contains_key("index.js"));
2375        assert!(!index.contains_key("pax_global_header"));
2376    }
2377
2378    #[test]
2379    fn test_import_tarball_still_accepts_normal_nested_paths() {
2380        // Regression guard: the validator must not refuse legitimate
2381        // deep paths that top-1000 packages actually ship.
2382        let dir = tempfile::tempdir().unwrap();
2383        let store = Store::at(dir.path().join("files"));
2384        store.ensure_shards_exist().unwrap();
2385        let tarball = build_tarball("package/lib/sub/a.js", b"// hi");
2386        let index = store.import_tarball(&tarball).unwrap();
2387        assert!(index.contains_key("lib/sub/a.js"));
2388    }
2389
2390    #[test]
2391    fn test_capped_reader_surfaces_exhaustion_as_error() {
2392        // Regression: `Read::take(cap)` returns a clean EOF when the
2393        // limit is reached, which in the tar case can land on a
2394        // block boundary and let an archive silently truncate into
2395        // a partial index. `CappedReader` must produce an error
2396        // instead so `tar::Archive` surfaces it to the caller.
2397        use std::io::Read;
2398        let mut r = CappedReader::new(&b"hello world"[..], 5);
2399        let mut first = [0u8; 5];
2400        r.read_exact(&mut first).unwrap();
2401        assert_eq!(&first, b"hello");
2402        let mut rest = Vec::new();
2403        let err = r.read_to_end(&mut rest).unwrap_err();
2404        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2405    }
2406
2407    #[test]
2408    fn test_capped_reader_does_not_error_below_cap() {
2409        // Normal reads under the cap behave identically to the
2410        // inner reader. Only hitting `remaining == 0` errors.
2411        use std::io::Read;
2412        let mut r = CappedReader::new(&b"hi"[..], 10);
2413        let mut buf = Vec::new();
2414        r.read_to_end(&mut buf).unwrap();
2415        assert_eq!(&buf, b"hi");
2416    }
2417
2418    #[test]
2419    fn test_capped_reader_empty_buf_is_ok_past_cap() {
2420        // `Read::read(&mut [])` is a no-op per contract. Even with
2421        // the cap exhausted, it must return Ok(0) and not error.
2422        use std::io::Read;
2423        let mut r = CappedReader::new(&b"abcd"[..], 4);
2424        let mut buf = [0u8; 4];
2425        r.read_exact(&mut buf).unwrap();
2426        assert_eq!(r.read(&mut []).unwrap(), 0);
2427    }
2428
2429    #[test]
2430    fn test_capped_reader_at_exact_boundary_still_errors() {
2431        // A read that drains exactly to the cap leaves `remaining`
2432        // at 0. The next read must error, which is the scenario
2433        // that motivated dropping `Read::take`. A tar block ending
2434        // on the cap would otherwise EOF silently.
2435        use std::io::Read;
2436        let mut r = CappedReader::new(&b"abcd"[..], 4);
2437        let mut buf = [0u8; 4];
2438        r.read_exact(&mut buf).unwrap();
2439        assert_eq!(&buf, b"abcd");
2440        let mut rest = Vec::new();
2441        let err = r.read_to_end(&mut rest).unwrap_err();
2442        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2443    }
2444
2445    #[test]
2446    fn test_import_tarball_declared_size_does_not_overallocate() {
2447        // A malicious header can declare a size near the per-entry
2448        // cap while shipping almost no actual content. The per-entry
2449        // `Vec::with_capacity` is clamped to `VEC_PREALLOC_CEILING`
2450        // so a lying header cannot force a 512 MiB reservation
2451        // before any byte has been read.
2452        let dir = tempfile::tempdir().unwrap();
2453        let store = Store::at(dir.path().join("files"));
2454        store.ensure_shards_exist().unwrap();
2455
2456        // Under `cfg(test)` `MAX_TARBALL_ENTRY_BYTES` is 1 MiB, so
2457        // we declare an entry right at that cap but with only a few
2458        // content bytes. Import should succeed with no OOM, and the
2459        // stored content length should match the actual bytes.
2460        let declared_near_cap = MAX_TARBALL_ENTRY_BYTES;
2461        let actual_content = b"tiny";
2462        let mut h = tar::Header::new_gnu();
2463        h.set_path("package/lying.bin").unwrap();
2464        h.set_size(declared_near_cap);
2465        h.set_mode(0o644);
2466        h.set_cksum();
2467        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
2468        let mut ar = tar::Builder::new(gz);
2469        // Size mismatch is intentional. `append` will not refuse
2470        // when we stamp the header manually via `append`.
2471        ar.append(&h, &actual_content[..]).ok();
2472        let tarball = ar.into_inner().unwrap().finish().unwrap();
2473
2474        // The per-entry cap check rejects `declared == cap` values
2475        // that exceed it; values at exactly the cap pass. Whichever
2476        // branch fires, the process must not OOM.
2477        let _ = store.import_tarball(&tarball);
2478    }
2479}