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}