Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/tar-xz/src/node/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/+$/, '');
Expand Down Expand Up @@ -79,10 +83,14 @@ async function hasSymlinkAncestor(filePath: string, root: string): Promise<boole
// ENOENT is fine — this intermediate dir doesn't exist yet, keep walking up.
// A higher ancestor may still be a symlink.
const code = (err as NodeJS.ErrnoException).code;
/* v8 ignore start: race-window — ancestor dir deleted between lstat and dirname call; non-ENOENT errors (EACCES/EIO) are system-level and cannot be triggered in tests */
if (code !== 'ENOENT') throw err;
/* v8 ignore stop */
}
const parent = dirname(dir);
/* v8 ignore start: filesystem-root invariant — dirname('/') === '/' on POSIX and dirname('C:\\') === 'C:\\' on Win32; loop cannot reach root in practice because tar entries are relative paths under cwd */
if (parent === dir) break; // filesystem root reached
/* v8 ignore stop */
dir = parent;
}
return false;
Expand Down Expand Up @@ -131,7 +139,9 @@ async function ensureSafeTarget(
throw new Error(`Refusing '${entryName}': target path is an existing symlink`);
}
} catch (err) {
/* v8 ignore start: race-window — target path disappears between the symlink check and lstat; non-ENOENT errors (EACCES/EIO) are system-level and cannot be triggered in tests */
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
/* v8 ignore stop */
}
}
}
Expand Down Expand Up @@ -175,7 +185,9 @@ async function extractSymlinkEntry(
try {
await unlink(target);
} catch (err) {
/* v8 ignore start: race-window — existing symlink deleted between ensureSafeTarget check and unlink; non-ENOENT errors (EACCES/EIO) are system-level and cannot be triggered in tests */
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
/* v8 ignore stop */
}
await symlink(strippedLinkname, target);
}
Expand Down Expand Up @@ -220,7 +232,9 @@ async function extractHardlinkEntry(
try {
linkSrcStat = await lstat(linkSource);
} catch (err) {
/* v8 ignore start: race-window — link source deleted between existence check and lstat; non-ENOENT errors (EACCES/EIO) are system-level and cannot be triggered in tests */
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
/* v8 ignore stop */
}
if (linkSrcStat?.isSymbolicLink()) {
throw new Error(
Expand Down Expand Up @@ -316,7 +330,9 @@ async function openFileExclusive(
try {
return await open(target, 'wx', fileMode);
} catch (firstErr) {
/* v8 ignore start: race-window — non-EEXIST errors from first open() (e.g., EACCES, ENOSPC) are system-level and cannot be triggered in tests; PR #114 Win32 TOCTOU hardening */
if ((firstErr as NodeJS.ErrnoException).code !== 'EEXIST') throw firstErr;
/* v8 ignore stop */
// Target exists — legitimate overwrite: unlink then retry.
// If the target disappears between the failed open() and unlink(), ignore
// ENOENT and still retry the atomic exclusive create.
Expand Down
101 changes: 101 additions & 0 deletions packages/tar-xz/test/coverage-trivial-branches.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function buildSingleEntryTar(options: {
type?: string;
mtime?: number;
mode?: number;
linkname?: string;
}): Buffer {
const type = options.type ?? TarEntryType.FILE;
const isLink =
Expand All @@ -40,6 +41,7 @@ function buildSingleEntryTar(options: {
type: type as '0',
mtime: options.mtime,
mode: options.mode,
linkname: options.linkname,
});

const blocks: Buffer[] = [Buffer.from(header)];
Expand Down Expand Up @@ -165,3 +167,102 @@ describe('extractFile() — mode=0 in TAR header (mode code path, POSIX only)',
}
);
});

// ---------------------------------------------------------------------------
// Test B — file.ts:170 SYMLINK with linkname fully stripped → early return
//
// When strip=N removes all components from the SYMLINK linkname, the early-
// return `if (!strippedLinkname) return;` fires and the entry is silently
// skipped (no symlink created on disk, no error).
// ---------------------------------------------------------------------------

describe('extractFile() — SYMLINK entry skipped when strip removes entire linkname', () => {
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);
});
});
Loading