diff --git a/packages/tar-xz/src/node/file.ts b/packages/tar-xz/src/node/file.ts index 123ec90..f1387f6 100644 --- a/packages/tar-xz/src/node/file.ts +++ b/packages/tar-xz/src/node/file.ts @@ -35,9 +35,13 @@ const SAFE_MODE_MASK = 0o0777; * - Dot-segment-only names ('.', '..') that resolve to cwd or its parent */ function ensureSafeName(s: string | undefined, label: string): void { + /* v8 ignore start: TS-defensive — callers only pass string (TarEntry.name/linkname are typed non-optional); undefined arm exists for future extensibility */ if (s === undefined) return; + /* v8 ignore stop */ if (s.length === 0) throw new Error(`Refusing entry: empty ${label}`); + /* v8 ignore start: unreachable via public API — TAR parser parseString() stops at the first NUL byte so no parsed entry name or linkname can ever contain U+0000 */ if (s.includes('\x00')) throw new Error(`Refusing entry: ${label} contains NUL byte`); + /* v8 ignore stop */ // R7-1: reject names that are dot-segment-only after normalising separators. // './' → '.', '../' → '..', '.\' → '.' etc. const normalized = s.replace(/\\/g, '/').replace(/\/+$/, ''); @@ -79,10 +83,14 @@ async function hasSymlinkAncestor(filePath: string, root: string): Promise { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-xz-zeta-sym-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('silently skips a SYMLINK entry when strip=1 removes its 1-component linkname', async () => { + // entry name: "prefix/link-target.txt" → strip=1 strips "prefix/" → yielded as "link-target.txt" + // linkname: "only-one-part" → extractSymlinkEntry strips 1 component → + // parts = ['only-one-part'], parts.slice(1) = [] → strippedLinkname = '' → early return + const rawTar = buildSingleEntryTar({ + name: 'prefix/link-target.txt', + content: Buffer.alloc(0), + type: TarEntryType.SYMLINK, + linkname: 'only-one-part', + }); + + const archivePath = path.join(tempDir, 'sym-strip.tar.xz'); + await fs.writeFile(archivePath, xzSync(rawTar)); + + const dest = path.join(tempDir, 'out'); + await fs.mkdir(dest); + + // strip=1 strips "prefix/" from the entry name (yielded as "link-target.txt") + // and also strips "only-one-part" → strippedLinkname = '' → early return in extractSymlinkEntry + await extractFile(archivePath, { cwd: dest, strip: 1 }); + + // The symlink must NOT have been created (silently skipped) + const symlinkPath = path.join(dest, 'link-target.txt'); + const exists = await fs + .lstat(symlinkPath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Test C — file.ts:208 HARDLINK with linkname fully stripped → early return +// +// When strip=N removes all components from the HARDLINK linkname, the early- +// return `if (!strippedLinkname) return;` fires and the entry is silently +// skipped (no hardlink created on disk, no error). +// --------------------------------------------------------------------------- + +describe('extractFile() — HARDLINK entry skipped when strip removes entire linkname', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-xz-zeta-hard-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + }); + + it('silently skips a HARDLINK entry when strip=1 removes its 1-component linkname', async () => { + // HARDLINK linkname has exactly 1 component: "only-one-part" + // After strip=1 → parts.slice(1) = [] → strippedLinkname = '' → early return + const rawTar = buildSingleEntryTar({ + name: 'prefix/hardlink-target.txt', + content: Buffer.alloc(0), + type: TarEntryType.HARDLINK, + linkname: 'only-one-part', + }); + + const archivePath = path.join(tempDir, 'hard-strip.tar.xz'); + await fs.writeFile(archivePath, xzSync(rawTar)); + + const dest = path.join(tempDir, 'out'); + await fs.mkdir(dest); + + // strip=1 strips "prefix/" from the entry name and strips + // "only-one-part" → ['only-one-part'].slice(1) = [] → strippedLinkname = '' + await extractFile(archivePath, { cwd: dest, strip: 1 }); + + // The hardlink must NOT have been created (silently skipped) + const hardlinkPath = path.join(dest, 'hardlink-target.txt'); + const exists = await fs + .lstat(hardlinkPath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + }); +});