Skip to main content

aube_store/
tarball.rs

1use crate::{Error, PackageIndex, Store, StoredFile};
2use std::path::Path;
3
4/// Deterministic fingerprint of a directory imported as a `file:` package.
5///
6/// This deliberately mirrors [`Store::import_directory`]: `.git` and
7/// `node_modules` directories are skipped, non-files are ignored, and each
8/// relative path, content hash, and executable bit contributes to the result.
9/// The install freshness check uses it without writing the files to the CAS.
10pub fn directory_content_fingerprint(dir: &Path) -> Result<String, Error> {
11    let entries = collect_directory_fingerprints(dir, true)?;
12    Ok(content_fingerprint(&entries))
13}
14
15/// Metadata-only fingerprint for the same tree as
16/// [`directory_content_fingerprint`]. This stats every included file but does
17/// not read its contents, letting warm install checks avoid unbounded file I/O
18/// when the source tree is unchanged.
19pub fn directory_metadata_fingerprint(dir: &Path) -> Result<String, Error> {
20    let entries = collect_directory_fingerprints(dir, false)?;
21    Ok(metadata_fingerprint(&entries))
22}
23
24/// Compute content and metadata fingerprints in one directory walk.
25///
26/// State writes need both values. Combining them avoids a second traversal
27/// after reading the source files for the authoritative content fingerprint.
28pub fn directory_fingerprints(dir: &Path) -> Result<(String, String), Error> {
29    let entries = collect_directory_fingerprints(dir, true)?;
30    Ok((
31        content_fingerprint(&entries),
32        metadata_fingerprint(&entries),
33    ))
34}
35
36#[derive(Debug)]
37struct DirectoryFileFingerprint {
38    path: String,
39    content_hash: Option<String>,
40    executable: bool,
41    size: u64,
42    mtime_secs: i64,
43    mtime_nanos: u32,
44}
45
46fn content_fingerprint(entries: &[DirectoryFileFingerprint]) -> String {
47    let mut entries: Vec<(&str, &str, bool)> = entries
48        .iter()
49        .filter_map(|entry| {
50            Some((
51                entry.path.as_str(),
52                entry.content_hash.as_deref()?,
53                entry.executable,
54            ))
55        })
56        .collect();
57    entries.sort_unstable();
58    let mut hasher = blake3::Hasher::new();
59    for (path, hex_hash, executable) in entries {
60        hasher.update(path.as_bytes());
61        hasher.update(b"\0");
62        hasher.update(hex_hash.as_bytes());
63        hasher.update(if executable { b"\x01" } else { b"\x00" });
64    }
65    hasher.finalize().to_hex().to_string()
66}
67
68fn metadata_fingerprint(entries: &[DirectoryFileFingerprint]) -> String {
69    let mut entries: Vec<&DirectoryFileFingerprint> = entries.iter().collect();
70    entries.sort_unstable_by(|a, b| a.path.cmp(&b.path));
71    let mut hasher = blake3::Hasher::new();
72    for entry in entries {
73        hasher.update(entry.path.as_bytes());
74        hasher.update(b"\0");
75        hasher.update(&entry.size.to_le_bytes());
76        hasher.update(&entry.mtime_secs.to_le_bytes());
77        hasher.update(&entry.mtime_nanos.to_le_bytes());
78        hasher.update(if entry.executable { b"\x01" } else { b"\x00" });
79    }
80    hasher.finalize().to_hex().to_string()
81}
82
83fn collect_directory_fingerprints(
84    dir: &Path,
85    hash_content: bool,
86) -> Result<Vec<DirectoryFileFingerprint>, Error> {
87    let mut entries = Vec::new();
88    collect_directory_fingerprints_recursive(dir, dir, hash_content, &mut entries)?;
89    Ok(entries)
90}
91
92fn collect_directory_fingerprints_recursive(
93    base: &Path,
94    current: &Path,
95    hash_content: bool,
96    entries: &mut Vec<DirectoryFileFingerprint>,
97) -> Result<(), Error> {
98    let dir_entries = std::fs::read_dir(current)
99        .map_err(|e| Error::Tar(format!("read_dir {}: {e}", current.display())))?;
100    for entry in dir_entries {
101        let entry = entry.map_err(|e| Error::Tar(format!("read_dir entry: {e}")))?;
102        let file_type = entry
103            .file_type()
104            .map_err(|e| Error::Tar(format!("file_type: {e}")))?;
105        let name = entry.file_name();
106        let name = name.to_string_lossy();
107        if matches!(name.as_ref(), ".git" | "node_modules") {
108            continue;
109        }
110        let path = entry.path();
111        if file_type.is_dir() {
112            collect_directory_fingerprints_recursive(base, &path, hash_content, entries)?;
113            continue;
114        }
115        if !file_type.is_file() {
116            continue;
117        }
118        let metadata = entry
119            .metadata()
120            .map_err(|e| Error::Tar(format!("metadata {}: {e}", path.display())))?;
121        let content_hash = if hash_content {
122            let content = std::fs::read(&path)
123                .map_err(|e| Error::Tar(format!("read {}: {e}", path.display())))?;
124            Some(blake3::hash(&content).to_hex().to_string())
125        } else {
126            None
127        };
128        #[cfg(unix)]
129        let executable = {
130            use std::os::unix::fs::PermissionsExt;
131            metadata.permissions().mode() & 0o111 != 0
132        };
133        #[cfg(not(unix))]
134        let executable = false;
135        let rel = path
136            .strip_prefix(base)
137            .map_err(|e| Error::Tar(format!("strip_prefix: {e}")))?
138            .to_string_lossy()
139            .replace('\\', "/");
140        let modified = metadata
141            .modified()
142            .ok()
143            .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok());
144        let (mtime_secs, mtime_nanos) = modified
145            .map(|duration| (duration.as_secs() as i64, duration.subsec_nanos()))
146            .unwrap_or((0, 0));
147        entries.push(DirectoryFileFingerprint {
148            path: rel,
149            content_hash,
150            executable,
151            size: metadata.len(),
152            mtime_secs,
153            mtime_nanos,
154        });
155    }
156    Ok(())
157}
158
159impl Store {
160    /// Import every file under a directory into the store, producing a
161    /// `PackageIndex` keyed by paths relative to `dir`. Used by `file:`
162    /// deps pointing at an on-disk package directory. Common noise
163    /// (`.git`, `node_modules`) is skipped so local packages don't drag
164    /// the target's own installed deps into the virtual store.
165    pub fn import_directory(&self, dir: &Path) -> Result<PackageIndex, Error> {
166        let mut index = PackageIndex::default();
167        self.import_directory_recursive(dir, dir, &mut index)?;
168        Ok(index)
169    }
170
171    fn import_directory_recursive(
172        &self,
173        base: &Path,
174        current: &Path,
175        index: &mut PackageIndex,
176    ) -> Result<(), Error> {
177        let entries = std::fs::read_dir(current)
178            .map_err(|e| Error::Tar(format!("read_dir {}: {e}", current.display())))?;
179        for entry in entries {
180            let entry =
181                entry.map_err(|e| Error::Tar(format!("read_dir {}: {e}", current.display())))?;
182            let file_type = entry
183                .file_type()
184                .map_err(|e| Error::Tar(format!("file_type: {e}")))?;
185            let name_os = entry.file_name();
186            let name_str = name_os.to_string_lossy();
187            if matches!(name_str.as_ref(), ".git" | "node_modules") {
188                continue;
189            }
190            let path = entry.path();
191            if file_type.is_dir() {
192                self.import_directory_recursive(base, &path, index)?;
193                continue;
194            }
195            if !file_type.is_file() {
196                continue;
197            }
198            let content = std::fs::read(&path)
199                .map_err(|e| Error::Tar(format!("read {}: {e}", path.display())))?;
200            #[cfg(unix)]
201            let executable = {
202                use std::os::unix::fs::PermissionsExt;
203                let meta = entry
204                    .metadata()
205                    .map_err(|e| Error::Tar(format!("metadata: {e}")))?;
206                meta.permissions().mode() & 0o111 != 0
207            };
208            #[cfg(not(unix))]
209            let executable = false;
210            let rel = path
211                .strip_prefix(base)
212                .map_err(|e| Error::Tar(format!("strip_prefix: {e}")))?
213                .to_string_lossy()
214                .replace('\\', "/");
215            let stored = self.import_bytes_gated(&rel, &content, executable)?;
216            index.insert(rel, stored);
217        }
218        Ok(())
219    }
220
221    /// Import a tarball (.tgz) into the store.
222    /// Returns a PackageIndex mapping relative paths to stored files.
223    ///
224    /// Two-phase: serial tar walk that stages
225    /// `(rel_path, content, executable)` triples (the tar reader is
226    /// inherently sequential), then a CAS-write batch. When the
227    /// staged batch crosses [`PARALLEL_IMPORT_THRESHOLD`] entries,
228    /// the writes fan out via `rayon::par_iter` — the per-file CAS
229    /// path is `O_CREAT|O_EXCL` and uses a shared `&Store`, so
230    /// parallel writers are race-safe by construction (`EEXIST` on
231    /// content collision is a success path because BLAKE3 paths are
232    /// content-addressed).
233    ///
234    /// `AUBE_DISABLE_PARALLEL_IMPORT=1` forces the serial path. Use
235    /// it as a regression killswitch if a future rayon scope inversion
236    /// (linker symlink pass running concurrently) shows contention.
237    /// Below the threshold the small-tarball overhead of rayon
238    /// dispatch outweighs the win, so the cutover is conditional.
239    pub fn import_tarball(&self, tarball_bytes: &[u8]) -> Result<PackageIndex, Error> {
240        // &[u8] impls std::io::Read by advancing the slice.
241        self.import_tarball_reader(tarball_bytes)
242    }
243
244    /// Streaming variant. Accepts any compressed-tarball Read source so
245    /// callers can pipe HTTP body chunks straight through without
246    /// buffering the whole archive into memory first. Caps and CAS
247    /// publish semantics match `import_tarball` exactly.
248    pub fn import_tarball_reader<R: std::io::Read>(
249        &self,
250        compressed_reader: R,
251    ) -> Result<PackageIndex, Error> {
252        use std::io::{Read, Write};
253
254        let _diag =
255            aube_util::diag::Span::new(aube_util::diag::Category::Store, "import_tarball_reader");
256        let _diag_decode = aube_util::diag::inflight(aube_util::diag::Slot::Decode);
257        let extract_t0 = std::time::Instant::now();
258
259        // Caps defend against gzip bombs and lying tar headers. The
260        // values sit well above any real npm package (largest top
261        // 1000 are in the tens of MiB) but low enough to prevent a
262        // malicious registry or mirror from OOMing the installer
263        // with a small high-compression-ratio payload.
264        //
265        // CappedReader instead of Read::take for the archive-level
266        // cap so exhaustion surfaces as an Err. A clean EOF landing on
267        // a tar block boundary would let a crafted archive silently
268        // truncate into a partial index.
269        let gz = flate2::read::GzDecoder::new(compressed_reader);
270        let capped = CappedReader::new(gz, MAX_TARBALL_DECOMPRESSED_BYTES);
271        let buffered = std::io::BufReader::with_capacity(256 * 1024, capped);
272        let mut archive = tar::Archive::new(buffered);
273        /*
274         * Chunked staged pipeline. Read N entries, flush them to CAS
275         * via rayon parallel writes, repeat. Keeps the existing
276         * rayon global pool warm across chunks and partially
277         * overlaps tar parsing with file writes within a single
278         * tarball. No new threads spawned (per-call thread::scope
279         * was tried and live locked at 80 s, see git history if
280         * curious). Chunk size of 64 is roughly the median npm
281         * package's file count, so most tarballs flush at most
282         * once or twice; fat native bindings (next, sharp, swc)
283         * with 1k+ files chunk through 16+ flushes. The legacy
284         * "stage everything then flush" path remains under
285         * `AUBE_DISABLE_PIPELINED_IMPORT=1` for byte-identity
286         * regression debugging.
287         */
288        const PIPELINE_CHUNK_SIZE: usize = 64;
289        let pipelined_disabled = aube_util::env::embedder_env("DISABLE_PIPELINED_IMPORT").is_some();
290        let parallel_disabled = aube_util::env::embedder_env("DISABLE_PARALLEL_IMPORT").is_some();
291        let mut staged: Vec<(String, Vec<u8>, bool)> = Vec::new();
292        let mut entries_seen: usize = 0;
293        let mut total_uncompressed: u64 = 0;
294        let mut max_entry_bytes: u64 = 0;
295        let mut decode_ns: u128 = 0;
296        let mut cas_ns: u128 = 0;
297        let mut index = PackageIndex::default();
298        let mut staged_count: usize = 0;
299
300        let flush_chunk = |chunk: Vec<(String, Vec<u8>, bool)>,
301                           index: &mut PackageIndex,
302                           cas_ns: &mut u128|
303         -> Result<(), Error> {
304            if chunk.is_empty() {
305                return Ok(());
306            }
307            let chunk_t0 = std::time::Instant::now();
308            if parallel_disabled || chunk.len() < PARALLEL_IMPORT_THRESHOLD {
309                for (rel_path, content, executable) in chunk {
310                    let stored = self.import_bytes_gated(&rel_path, &content, executable)?;
311                    index.insert(rel_path, stored);
312                }
313            } else {
314                use rayon::iter::{
315                    IndexedParallelIterator, IntoParallelIterator, ParallelIterator,
316                };
317                // `with_min_len` raises the minimum work unit per
318                // rayon task. samply on a 1230-pkg cold install
319                // pinned `crossbeam_deque::Stealer::steal` at 4.1%
320                // self time; each per-file task is ~50µs of useful
321                // work, below rayon's amortization threshold for
322                // its work-stealing overhead. Grouping 8 files per
323                // task amortizes the dispatch/steal cost without
324                // losing meaningful parallelism — 8 × 50µs = 400µs,
325                // well under a typical OS scheduling slice.
326                const RAYON_TASK_MIN_LEN: usize = 8;
327                let results: Vec<Result<(String, StoredFile), Error>> = chunk
328                    .into_par_iter()
329                    .with_min_len(RAYON_TASK_MIN_LEN)
330                    .map(|(rel_path, content, executable)| {
331                        self.import_bytes_gated(&rel_path, &content, executable)
332                            .map(|stored| (rel_path, stored))
333                    })
334                    .collect();
335                for r in results {
336                    let (rel_path, stored) = r?;
337                    index.insert(rel_path, stored);
338                }
339            }
340            *cas_ns += chunk_t0.elapsed().as_nanos();
341            Ok(())
342        };
343
344        for entry in archive.entries().map_err(|e| Error::Tar(e.to_string()))? {
345            entries_seen += 1;
346            if entries_seen > MAX_TARBALL_ENTRIES {
347                return Err(Error::Tar(format!(
348                    "tarball exceeds entry cap of {MAX_TARBALL_ENTRIES}"
349                )));
350            }
351
352            let mut entry = entry.map_err(|e| Error::Tar(e.to_string()))?;
353
354            // Directories don't carry content, skip them. PAX global
355            // and extension headers (type `g` / `x`) carry metadata
356            // only — GitHub-generated tarballs (e.g. `imap@0.8.19`)
357            // start with one that embeds the source git blob SHA.
358            // npm/pnpm/bun tolerate these; we do too. Every other
359            // non-regular entry type (symlink, hardlink, character
360            // device, block device, fifo) is rejected. Real npm
361            // packages ship files and directories only. Symlink and
362            // hardlink entries are the load-bearing primitive of the
363            // node-tar CVE-2021-37701 class and have no legitimate
364            // use here.
365            let entry_type = entry.header().entry_type();
366            // GNU LongName/LongLink and PAX X-headers carry metadata
367            // for the next real entry. The tar crate folds the long
368            // name into Entry::path() automatically. Just skip the
369            // metadata records themselves.
370            if entry_type.is_dir()
371                || matches!(
372                    entry_type,
373                    tar::EntryType::XGlobalHeader
374                        | tar::EntryType::XHeader
375                        | tar::EntryType::GNULongName
376                        | tar::EntryType::GNULongLink
377                )
378            {
379                continue;
380            }
381            if !matches!(
382                entry_type,
383                tar::EntryType::Regular | tar::EntryType::Continuous
384            ) {
385                return Err(Error::Tar(format!(
386                    "tarball entry type {entry_type:?} is not allowed"
387                )));
388            }
389
390            // Reject oversized entries up front on the declared size
391            // so we never allocate a huge `Vec` just to error after.
392            // `.take()` below is the belt-and-suspenders guard for
393            // the case where the header lies about the stream length.
394            let declared = entry
395                .header()
396                .size()
397                .map_err(|e| Error::Tar(e.to_string()))?;
398            if declared > MAX_TARBALL_ENTRY_BYTES {
399                return Err(Error::Tar(format!(
400                    "tarball entry exceeds per-entry cap: {declared} bytes > {MAX_TARBALL_ENTRY_BYTES}"
401                )));
402            }
403
404            let raw_path = entry
405                .path()
406                .map_err(|e| Error::Tar(e.to_string()))?
407                .to_path_buf();
408            let Some(rel_path) = normalize_tar_entry_path(&raw_path)? else {
409                // Entry was the wrapper directory itself with no
410                // interior path after stripping. Nothing to store.
411                continue;
412            };
413
414            let mode = entry.header().mode().unwrap_or(0o644);
415            let executable = mode & 0o111 != 0;
416
417            // Store compression may unwrap napi hybrids before hashing, which
418            // inherently needs the complete entry. Keep that opt-in path on
419            // the byte-buffered importer.
420            if declared >= LARGE_ENTRY_STREAM_THRESHOLD
421                && crate::cas::store_compression_gate().is_none()
422            {
423                // Keep small files on the staged/rayon path, but never retain
424                // an existing chunk while decoding a large entry. Large files
425                // are written once into a same-filesystem CAS tempfile and
426                // hashed during decompression, then atomically published.
427                if !staged.is_empty() {
428                    let chunk = std::mem::take(&mut staged);
429                    flush_chunk(chunk, &mut index, &mut cas_ns)?;
430                }
431
432                std::fs::create_dir_all(&self.root).map_err(|e| Error::Io(self.root.clone(), e))?;
433                let mut temp = tempfile::Builder::new()
434                    .prefix(".aube-stream-")
435                    .tempfile_in(&self.root)
436                    .map_err(|e| Error::Io(self.root.clone(), e))?;
437                let mut hasher = blake3::Hasher::new();
438                let mut limited = (&mut entry).take(MAX_TARBALL_ENTRY_BYTES);
439                let mut buffer = [0u8; STREAM_COPY_BUFFER_SIZE];
440                let read_t0 = std::time::Instant::now();
441                let mut actual_len = 0u64;
442                loop {
443                    let n = limited
444                        .read(&mut buffer)
445                        .map_err(|e| Error::Tar(e.to_string()))?;
446                    if n == 0 {
447                        break;
448                    }
449                    temp.write_all(&buffer[..n])
450                        .map_err(|e| Error::Io(self.root.clone(), e))?;
451                    hasher.update(&buffer[..n]);
452                    actual_len = actual_len.saturating_add(n as u64);
453                }
454                decode_ns += read_t0.elapsed().as_nanos();
455
456                let hex_hash = hasher.finalize().to_hex().to_string();
457                let cas_t0 = std::time::Instant::now();
458                let stored = self.import_hashed_tempfile(temp, hex_hash, actual_len, executable)?;
459                cas_ns += cas_t0.elapsed().as_nanos();
460                total_uncompressed = total_uncompressed.saturating_add(actual_len);
461                max_entry_bytes = max_entry_bytes.max(actual_len);
462                staged_count += 1;
463                index.insert(rel_path, stored);
464                continue;
465            }
466
467            // Clamp upfront alloc so a lying header can't force a 512
468            // MiB reservation before any byte has been read. read_to_end
469            // grows the Vec for the rare entry that really is huge.
470            let mut content = Vec::with_capacity((declared as usize).min(VEC_PREALLOC_CEILING));
471            let read_t0 = std::time::Instant::now();
472            (&mut entry)
473                .take(MAX_TARBALL_ENTRY_BYTES)
474                .read_to_end(&mut content)
475                .map_err(|e| Error::Tar(e.to_string()))?;
476            decode_ns += read_t0.elapsed().as_nanos();
477
478            // Reject header that declared 0 bytes but produced a
479            // non-empty stream. Synthetic-entry injection: header
480            // claims empty file, real bytes go to disk.
481            if declared == 0 && !content.is_empty() {
482                return Err(Error::Tar(format!(
483                    "tarball entry declared 0 bytes but yielded {} bytes",
484                    content.len()
485                )));
486            }
487
488            total_uncompressed = total_uncompressed.saturating_add(content.len() as u64);
489            max_entry_bytes = max_entry_bytes.max(content.len() as u64);
490            staged.push((rel_path, content, executable));
491            staged_count += 1;
492
493            if !pipelined_disabled && staged.len() >= PIPELINE_CHUNK_SIZE {
494                let chunk = std::mem::take(&mut staged);
495                flush_chunk(chunk, &mut index, &mut cas_ns)?;
496            }
497        }
498
499        aube_util::diag::event_lazy(
500            aube_util::diag::Category::Store,
501            "tar_extract_complete",
502            extract_t0.elapsed(),
503            || {
504                format!(
505                    r#"{{"entries":{staged_count},"bytes_uncompressed":{total_uncompressed},"max_entry_bytes":{max_entry_bytes}}}"#
506                )
507            },
508        );
509        if aube_util::diag::enabled() {
510            aube_util::diag::event_lazy(
511                aube_util::diag::Category::Store,
512                "gzip_decompress",
513                std::time::Duration::from_nanos(decode_ns as u64),
514                || format!(r#"{{"bytes_uncompressed":{total_uncompressed}}}"#),
515            );
516        }
517
518        if !staged.is_empty() {
519            let chunk = std::mem::take(&mut staged);
520            flush_chunk(chunk, &mut index, &mut cas_ns)?;
521        }
522        aube_util::diag::event_lazy(
523            aube_util::diag::Category::Store,
524            "cas_import_complete",
525            // Saturating cast: u128 cas_ns won't realistically
526            // exceed u64::MAX (~584 years in nanoseconds), but a
527            // bug or runaway accumulator should clamp to the diag
528            // ceiling rather than silently truncate the high bits
529            // and emit a misleadingly small duration.
530            std::time::Duration::from_nanos(u64::try_from(cas_ns).unwrap_or(u64::MAX)),
531            || {
532                let pipelined = !pipelined_disabled;
533                let parallel = !parallel_disabled && staged_count >= PARALLEL_IMPORT_THRESHOLD;
534                format!(
535                    r#"{{"files":{staged_count},"parallel":{parallel},"pipelined":{pipelined}}}"#
536                )
537            },
538        );
539        Ok(index)
540    }
541}
542
543// Median npm tarball has 7 files. Old 256 threshold almost never
544// tripped. Rayon dispatch is cheap on tiny batches.
545// AUBE_DISABLE_PARALLEL_IMPORT kills the parallel path entirely.
546const PARALLEL_IMPORT_THRESHOLD: usize = 16;
547
548/// Strip the wrapper directory from `raw` and return a safe POSIX-style
549/// index key, or refuse the entry outright.
550///
551/// Rejects every shape that would let a crafted tarball place the
552/// eventual `pkg_dir.join(key)` file outside the package root:
553/// `..` anywhere in the remaining path, absolute paths, Windows
554/// drive prefixes, backslash separators smuggled inside a single
555/// component, and NUL bytes. Non-UTF-8 paths are also rejected because
556/// the stored index is a JSON map keyed by string.
557///
558/// `Ok(None)` means the entry stripped down to the wrapper itself
559/// and should be skipped by the caller.
560///
561/// Matches the class of defences node-tar added for CVE-2021-32804,
562/// CVE-2021-37713, and what pnpm added for CVE-2024-27298.
563pub(crate) fn normalize_tar_entry_path(raw: &Path) -> Result<Option<String>, Error> {
564    use std::path::Component;
565
566    // Peek past any leading `.` segments (`./package/foo.js` appears
567    // in some tar implementations' wrapper representations) so the
568    // first-component reject and the wrapper-strip both work off the
569    // same "first real" position. Running the reject before the
570    // stripping loop would otherwise let `./../file` silently
571    // consume the `..` as the wrapper.
572    let mut components = raw.components().peekable();
573    while matches!(components.peek(), Some(Component::CurDir)) {
574        components.next();
575    }
576
577    // Reject absolute, drive-prefixed, or `..`-rooted paths before
578    // wrapper-strip runs. A naive "skip the first component" would
579    // otherwise strip the `RootDir` marker and accept `/etc` as
580    // `etc`, or consume a leading `..` as the wrapper.
581    match components.peek() {
582        Some(Component::RootDir) => {
583            return Err(Error::Tar(format!(
584                "tarball entry path is absolute: {raw:?}"
585            )));
586        }
587        Some(Component::Prefix(_)) => {
588            return Err(Error::Tar(format!(
589                "tarball entry path has a Windows drive prefix: {raw:?}"
590            )));
591        }
592        Some(Component::ParentDir) => {
593            return Err(Error::Tar(format!(
594                "tarball entry path escapes package root via `..`: {raw:?}"
595            )));
596        }
597        _ => {}
598    }
599
600    // Drop the first real component as the wrapper directory. npm
601    // convention is `package/`, but some packages ship the package
602    // name or another identifier. Whatever it is, drop it.
603    components.next();
604
605    // Pre-size to the raw path length so growing the output never
606    // reallocates: the normalized form drops the wrapper segment and
607    // converts `\` to `/` but never grows beyond the input size.
608    let mut out = String::with_capacity(raw.as_os_str().len());
609    for comp in components {
610        match comp {
611            Component::Normal(os) => {
612                let s = os.to_str().ok_or_else(|| {
613                    Error::Tar(format!(
614                        "tarball entry path contains non-UTF-8 bytes: {raw:?}"
615                    ))
616                })?;
617                if s.is_empty() || s.contains('\0') || s.contains('\\') || s.contains('/') {
618                    return Err(Error::Tar(format!(
619                        "tarball entry path contains a malformed component: {raw:?}"
620                    )));
621                }
622                // Windows-only filename restrictions. Gated to
623                // cfg(windows) so Unix hosts keep tarballs with
624                // valid-on-Linux names like `CON.js` or `foo.`.
625                // Rejecting those cross-platform would regress real
626                // Linux installs for a hazard that only hits
627                // Windows users. Windows users get the checks they
628                // need, portability of a package to Windows is the
629                // publisher's problem to validate.
630                #[cfg(windows)]
631                {
632                    // `:` is an alternate data stream separator on
633                    // NTFS and is rejected by Windows path creation.
634                    if s.contains(':') {
635                        return Err(Error::Tar(format!(
636                            "tarball entry path contains a malformed component: {raw:?}"
637                        )));
638                    }
639                    // NTFS reserved device names. `CON`, `con.txt`,
640                    // `CON.tar.gz` all resolve to the console
641                    // device. Writing one either fails with
642                    // ERROR_INVALID_NAME or gets silently consumed
643                    // by the device driver and hangs the writer.
644                    if is_windows_reserved_name(s) {
645                        return Err(Error::Tar(format!(
646                            "tarball entry path contains a Windows reserved device name: {raw:?}"
647                        )));
648                    }
649                    // NTFS strips trailing `.` and trailing space
650                    // on create so `foo` and `foo.` alias. Reject
651                    // both rather than sort out aliasing at
652                    // materialize time.
653                    if s.ends_with('.') || s.ends_with(' ') {
654                        return Err(Error::Tar(format!(
655                            "tarball entry path has a trailing dot or space which Windows strips: {raw:?}"
656                        )));
657                    }
658                    // Control chars 0x01..0x1F invalid on NTFS.
659                    // Reject the whole tarball instead of hitting
660                    // per-file create errors mid-extract.
661                    if s.bytes().any(|b| b < 0x20) {
662                        return Err(Error::Tar(format!(
663                            "tarball entry path contains control characters: {raw:?}"
664                        )));
665                    }
666                }
667                if !out.is_empty() {
668                    out.push('/');
669                }
670                out.push_str(s);
671            }
672            Component::ParentDir => {
673                return Err(Error::Tar(format!(
674                    "tarball entry path escapes package root via `..`: {raw:?}"
675                )));
676            }
677            Component::RootDir => {
678                return Err(Error::Tar(format!(
679                    "tarball entry path is absolute: {raw:?}"
680                )));
681            }
682            Component::Prefix(_) => {
683                return Err(Error::Tar(format!(
684                    "tarball entry path has a Windows drive prefix: {raw:?}"
685                )));
686            }
687            // `.` components are harmless and appear in some tar
688            // implementations' wrapper representations.
689            Component::CurDir => {}
690        }
691    }
692
693    if out.is_empty() {
694        Ok(None)
695    } else {
696        Ok(Some(out))
697    }
698}
699
700/// Check if a tarball path component matches a Windows reserved
701/// device name. Compare case-insensitively on the stem only.
702/// `CON`, `Con`, `con`, `con.txt`, `CON.tar.gz` all resolve to the
703/// same DOS device on NTFS. Only base name matters, the extension
704/// is irrelevant to the device lookup.
705#[cfg(windows)]
706fn is_windows_reserved_name(name: &str) -> bool {
707    let stem = name.split_once('.').map(|(a, _)| a).unwrap_or(name);
708    let upper = stem.to_ascii_uppercase();
709    matches!(
710        upper.as_str(),
711        "CON"
712            | "PRN"
713            | "AUX"
714            | "NUL"
715            | "COM1"
716            | "COM2"
717            | "COM3"
718            | "COM4"
719            | "COM5"
720            | "COM6"
721            | "COM7"
722            | "COM8"
723            | "COM9"
724            | "LPT1"
725            | "LPT2"
726            | "LPT3"
727            | "LPT4"
728            | "LPT5"
729            | "LPT6"
730            | "LPT7"
731            | "LPT8"
732            | "LPT9"
733    )
734}
735
736/// Hard ceiling on the per-entry `Vec::with_capacity` hint. 64 KiB
737/// covers the bulk of real npm package files (JS / JSON / TS, all
738/// typically under a few KiB each) without trusting the declared
739/// header size, which an attacker controls. `read_to_end` grows
740/// past this ceiling when a legitimate larger file warrants it.
741const VEC_PREALLOC_CEILING: usize = 64 * 1024;
742
743/// Entries at least this large stream through a CAS tempfile instead of
744/// occupying a full growable `Vec`. Eight MiB keeps the hot path for normal
745/// npm files while bounding concurrent native-binary extraction memory.
746#[cfg(not(test))]
747const LARGE_ENTRY_STREAM_THRESHOLD: u64 = 8 << 20;
748#[cfg(test)]
749const LARGE_ENTRY_STREAM_THRESHOLD: u64 = 128 << 10;
750
751const STREAM_COPY_BUFFER_SIZE: usize = 256 * 1024;
752
753/// A `Read` wrapper that refuses to deliver more than `remaining`
754/// bytes. Unlike `std::io::Read::take`, exhaustion produces an
755/// explicit `io::Error` rather than a clean EOF. When the wrapped
756/// reader is a gzip decoder feeding a tar archive, a clean EOF at
757/// a block boundary would let a crafted archive silently truncate
758/// into a partial index. Surfacing an error keeps the archive
759/// iterator from accepting a half-read stream as complete.
760pub(crate) struct CappedReader<R: std::io::Read> {
761    inner: R,
762    remaining: u64,
763}
764
765impl<R: std::io::Read> CappedReader<R> {
766    pub(crate) fn new(inner: R, cap: u64) -> Self {
767        Self {
768            inner,
769            remaining: cap,
770        }
771    }
772}
773
774impl<R: std::io::Read> std::io::Read for CappedReader<R> {
775    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
776        // A zero-length read is a no-op by the `Read` contract and
777        // must not error even if the cap is already exhausted.
778        if buf.is_empty() {
779            return Ok(0);
780        }
781        if self.remaining == 0 {
782            return Err(std::io::Error::new(
783                std::io::ErrorKind::InvalidData,
784                format!(
785                    "tarball decompression exceeds archive cap of {MAX_TARBALL_DECOMPRESSED_BYTES} bytes"
786                ),
787            ));
788        }
789        let want = buf.len().min(self.remaining as usize);
790        let n = self.inner.read(&mut buf[..want])?;
791        self.remaining -= n as u64;
792        Ok(n)
793    }
794}
795
796/// Maximum total decompressed bytes accepted from a single tarball.
797/// 1 GiB. Reality check against the npm registry on 2026-04-19.
798/// Biggest tarball in the top 1000 by download count is `next` at
799/// 154 MiB unpacked. Second is `@tensorflow/tfjs` at 147 MiB. The
800/// cap sits ~6x above both, leaves room for future growth, and
801/// stays well below the process RSS a gzip bomb would otherwise
802/// force the installer to allocate.
803#[cfg(not(test))]
804pub(crate) const MAX_TARBALL_DECOMPRESSED_BYTES: u64 = 1 << 30;
805#[cfg(test)]
806pub(crate) const MAX_TARBALL_DECOMPRESSED_BYTES: u64 = 1 << 20;
807
808/// Maximum bytes for a single tar entry. 512 MiB. Reality check: the
809/// largest legitimate single file shipped by a top-1000 npm package
810/// sits in the tens of MiB range (bundled WASM blobs in `@swc/wasm`,
811/// `@babel/standalone`, `monaco-editor`). 512 MiB leaves a full
812/// order of magnitude of headroom.
813#[cfg(not(test))]
814pub(crate) const MAX_TARBALL_ENTRY_BYTES: u64 = 512 << 20;
815#[cfg(test)]
816pub(crate) const MAX_TARBALL_ENTRY_BYTES: u64 = 1 << 20;
817
818/// Maximum number of tar entries in a single archive. 200_000.
819/// Reality check: `next` ships 8_065 files and `@fluentui/react`
820/// ships 7_448, the largest counts in the top 1000. 200_000 is
821/// ~25x above that and stops a crafted archive from pinning the
822/// CPU on iteration alone.
823#[cfg(not(test))]
824pub(crate) const MAX_TARBALL_ENTRIES: usize = 200_000;
825#[cfg(test)]
826pub(crate) const MAX_TARBALL_ENTRIES: usize = 64;
827
828#[cfg(test)]
829mod directory_fingerprint_tests {
830    use super::*;
831
832    #[test]
833    fn directory_fingerprint_matches_imported_index() {
834        let temp = tempfile::tempdir().unwrap();
835        let source = temp.path().join("source");
836        std::fs::create_dir_all(source.join("lib")).unwrap();
837        std::fs::create_dir_all(source.join("node_modules/ignored")).unwrap();
838        std::fs::write(source.join("package.json"), br#"{"name":"local"}"#).unwrap();
839        std::fs::write(source.join("lib/index.js"), b"module.exports = 'v1';\n").unwrap();
840        std::fs::write(source.join("node_modules/ignored/index.js"), b"ignored\n").unwrap();
841
842        let store = Store::at(temp.path().join("store"));
843        let index = store.import_directory(&source).unwrap();
844        let (content_hash, metadata_hash) = directory_fingerprints(&source).unwrap();
845        assert_eq!(content_hash, crate::index_content_fingerprint(&index));
846        assert_eq!(
847            metadata_hash,
848            directory_metadata_fingerprint(&source).unwrap()
849        );
850
851        let before_content = content_hash;
852        std::fs::write(source.join("lib/index.js"), b"module.exports = 'v2';\n").unwrap();
853        let after_content = directory_content_fingerprint(&source).unwrap();
854        assert_ne!(before_content, after_content);
855
856        std::fs::write(source.join("lib/added.js"), b"added\n").unwrap();
857        assert_ne!(
858            metadata_hash,
859            directory_metadata_fingerprint(&source).unwrap()
860        );
861    }
862}