diff --git a/.changeset/dot-prefixed-tarball-entries.md b/.changeset/dot-prefixed-tarball-entries.md new file mode 100644 index 0000000000..35db08ed2b --- /dev/null +++ b/.changeset/dot-prefixed-tarball-entries.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +Pacquet now strips exactly one leading path component from `./`-prefixed tarball entries, matching pnpm and npm's tar extraction semantics and keeping shared store keys consistent. diff --git a/pnpm/crates/tarball/src/extract.rs b/pnpm/crates/tarball/src/extract.rs index edd23f1bc2..746e2bd26e 100644 --- a/pnpm/crates/tarball/src/extract.rs +++ b/pnpm/crates/tarball/src/extract.rs @@ -959,6 +959,9 @@ pub(crate) fn tar_entry_payload<'a, Reader: std::io::Read>( /// both implementations share to a reader that *does* treat them as /// separators. /// +/// A leading `.` is preserved because npm's `tar` counts it as the +/// component removed by `strip: 1`. Other `.` components are ignored. +/// /// `None` for an absolute path or one climbing past the root. pub(crate) fn archive_entry_segments(raw: &str) -> Option> { let normalized = raw.replace('\\', "/"); @@ -966,9 +969,11 @@ pub(crate) fn archive_entry_segments(raw: &str) -> Option> { return None; } let mut segments = Vec::new(); - for segment in normalized.split('/') { + for (index, segment) in normalized.split('/').enumerate() { match segment { - "" | "." => {} + "" => {} + "." if index == 0 => segments.push(segment.to_string()), + "." => {} ".." => return None, other => segments.push(other.to_string()), } diff --git a/pnpm/crates/tarball/src/tests.rs b/pnpm/crates/tarball/src/tests.rs index f6cfac23b5..b0e4577cf0 100644 --- a/pnpm/crates/tarball/src/tests.rs +++ b/pnpm/crates/tarball/src/tests.rs @@ -5312,6 +5312,21 @@ fn tar_with_raw_entry_name(name: &[u8]) -> Vec { tar_bytes } +#[test] +fn extract_strips_only_one_component_from_a_dot_prefixed_entry_path() { + let (tempdir, store_path) = tempdir_with_leaked_path(); + + let tar_bytes = tar_with_raw_entry_name(b"./package/package.json"); + let (cas_paths, pkg_files_idx) = + extract_tarball_entries(&tar_bytes, store_path, None).expect("extract the tarball"); + + assert_eq!(cas_paths.keys().collect::>(), vec!["package/package.json"]); + assert_eq!(pkg_files_idx.files.keys().collect::>(), vec!["package/package.json"]); + assert!(pkg_files_idx.manifest.is_none()); + + drop(tempdir); +} + /// A backslash is an ordinary filename character on Unix but a /// separator on Windows, and these keys travel between the two through /// the shared `index.db`. pnpm folds `\` to `/` before validating diff --git a/pnpm11/store/cafs/test/index.ts b/pnpm11/store/cafs/test/index.ts index d62da861f5..a334d4e797 100644 --- a/pnpm11/store/cafs/test/index.ts +++ b/pnpm11/store/cafs/test/index.ts @@ -251,7 +251,7 @@ test('unpack a tarball that contains hard links', () => { // A malicious tarball entry like "foo\..\..\..\.npmrc" should have its path normalized test('path traversal with backslashes is blocked (Windows security fix)', () => { // Create a minimal valid tarball with a malicious filename - const tarBuffer = createTarballWithEntry('foo\\..\\..\\..\\malicious.txt', 'evil content') + const tarBuffer = createTarballWithEntry('package/foo\\..\\..\\..\\malicious.txt', 'evil content') const result = parseTarball(tarBuffer) const fileNames = Array.from(result.files.keys()) @@ -263,16 +263,21 @@ test('path traversal with backslashes is blocked (Windows security fix)', () => } }) +test('only one segment is stripped from a dot-prefixed tarball entry', () => { + const result = parseTarball(createTarballWithEntry('./package/package.json', '{}')) + + expect(Array.from(result.files.keys())).toStrictEqual(['package/package.json']) +}) + // Helper to create a minimal tarball buffer with a single entry -function createTarballWithEntry (fileName: string, content: string): Buffer { +function createTarballWithEntry (entryPath: string, content: string): Buffer { const contentBytes = Buffer.from(content, 'utf8') // Create a 512-byte header const header = Buffer.alloc(512, 0) // File name at offset 0 (max 100 chars) - const nameToWrite = `package/${fileName}` - header.write(nameToWrite, 0, Math.min(nameToWrite.length, 100), 'utf8') + header.write(entryPath, 0, Math.min(entryPath.length, 100), 'utf8') // File mode at offset 100 (octal, 8 bytes) - 0644 header.write('0000644\0', 100, 8, 'utf8')