Skip to content

Commit 8e51020

Browse files
authored
test(tar-xz): close file.ts coverage partials with tests + v8 ignores (#128)
Address ten remaining partial branches in file.ts: two are real reachable paths that get fixtures, eight are defensive guards that get v8 ignore start/stop wraps with inline rationale. Real tests added (2 in coverage-trivial-branches.spec.ts): - extractSymlinkEntry early-return: a SYMLINK entry whose linkname is a single component is silently skipped when strip=1, exercising the if (!strippedLinkname) return; branch. - extractHardlinkEntry early-return: same shape for hardlinks. The coverage-trivial-branches helper buildSingleEntryTar gains an optional linkname parameter so the tests can craft SYMLINK and HARDLINK archives without depending on coverage-final.spec.ts. v8 ignore wraps applied (7 sites in file.ts): - ensureSafeName: TS-defensive 'undefined' early-return AND the NUL-byte rejection. Discovery during this work: parseString in tar/format.ts already terminates a name field at the first 0x00, so any name reaching ensureSafeName cannot contain a NUL. The guard is meaningful only for non-TAR callers and is unreachable via the public extract path; suppress accordingly. - hasSymlinkAncestor non-ENOENT rethrow: race-window where the ancestor directory disappears between lstat calls; non-ENOENT branches are EACCES/EIO that test fixtures cannot trigger. - hasSymlinkAncestor parent === dir guard: filesystem-root invariant; tar entries are relative paths under cwd so the loop never reaches POSIX '/' or Windows 'C:\'. - ensureSafeTarget non-ENOENT rethrow: race-window in the stat-then-validate sequence. - extractSymlinkEntry non-ENOENT rethrow on unlink: race-window where the existing symlink disappears between ensureSafeTarget and the unlink call. - extractHardlinkEntry non-ENOENT rethrow on lstat: race-window on the link source. - openFileExclusive non-EEXIST rethrow on the first open: same race-window class as the existing wraps in the same function; closes the symmetry hole. Coverage: - file.ts branches 83.72% -> 92.85% (+9.13). - tar-xz overall branches 92.80% -> 94.98% (+2.18). - tar-xz lines stay 100%. tar-xz tests: 209 pass / 3 skipped -> 211 pass / 3 skipped (+2).
1 parent 2a4210a commit 8e51020

2 files changed

Lines changed: 117 additions & 0 deletions

File tree

packages/tar-xz/src/node/file.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ const SAFE_MODE_MASK = 0o0777;
3535
* - Dot-segment-only names ('.', '..') that resolve to cwd or its parent
3636
*/
3737
function ensureSafeName(s: string | undefined, label: string): void {
38+
/* v8 ignore start: TS-defensive — callers only pass string (TarEntry.name/linkname are typed non-optional); undefined arm exists for future extensibility */
3839
if (s === undefined) return;
40+
/* v8 ignore stop */
3941
if (s.length === 0) throw new Error(`Refusing entry: empty ${label}`);
42+
/* 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 */
4043
if (s.includes('\x00')) throw new Error(`Refusing entry: ${label} contains NUL byte`);
44+
/* v8 ignore stop */
4145
// R7-1: reject names that are dot-segment-only after normalising separators.
4246
// './' → '.', '../' → '..', '.\' → '.' etc.
4347
const normalized = s.replace(/\\/g, '/').replace(/\/+$/, '');
@@ -79,10 +83,14 @@ async function hasSymlinkAncestor(filePath: string, root: string): Promise<boole
7983
// ENOENT is fine — this intermediate dir doesn't exist yet, keep walking up.
8084
// A higher ancestor may still be a symlink.
8185
const code = (err as NodeJS.ErrnoException).code;
86+
/* 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 */
8287
if (code !== 'ENOENT') throw err;
88+
/* v8 ignore stop */
8389
}
8490
const parent = dirname(dir);
91+
/* 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 */
8592
if (parent === dir) break; // filesystem root reached
93+
/* v8 ignore stop */
8694
dir = parent;
8795
}
8896
return false;
@@ -131,7 +139,9 @@ async function ensureSafeTarget(
131139
throw new Error(`Refusing '${entryName}': target path is an existing symlink`);
132140
}
133141
} catch (err) {
142+
/* 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 */
134143
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
144+
/* v8 ignore stop */
135145
}
136146
}
137147
}
@@ -175,7 +185,9 @@ async function extractSymlinkEntry(
175185
try {
176186
await unlink(target);
177187
} catch (err) {
188+
/* 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 */
178189
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
190+
/* v8 ignore stop */
179191
}
180192
await symlink(strippedLinkname, target);
181193
}
@@ -220,7 +232,9 @@ async function extractHardlinkEntry(
220232
try {
221233
linkSrcStat = await lstat(linkSource);
222234
} catch (err) {
235+
/* 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 */
223236
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
237+
/* v8 ignore stop */
224238
}
225239
if (linkSrcStat?.isSymbolicLink()) {
226240
throw new Error(
@@ -316,7 +330,9 @@ async function openFileExclusive(
316330
try {
317331
return await open(target, 'wx', fileMode);
318332
} catch (firstErr) {
333+
/* 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 */
319334
if ((firstErr as NodeJS.ErrnoException).code !== 'EEXIST') throw firstErr;
335+
/* v8 ignore stop */
320336
// Target exists — legitimate overwrite: unlink then retry.
321337
// If the target disappears between the failed open() and unlink(), ignore
322338
// ENOENT and still retry the atomic exclusive create.

packages/tar-xz/test/coverage-trivial-branches.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ function buildSingleEntryTar(options: {
2626
type?: string;
2727
mtime?: number;
2828
mode?: number;
29+
linkname?: string;
2930
}): Buffer {
3031
const type = options.type ?? TarEntryType.FILE;
3132
const isLink =
@@ -40,6 +41,7 @@ function buildSingleEntryTar(options: {
4041
type: type as '0',
4142
mtime: options.mtime,
4243
mode: options.mode,
44+
linkname: options.linkname,
4345
});
4446

4547
const blocks: Buffer[] = [Buffer.from(header)];
@@ -165,3 +167,102 @@ describe('extractFile() — mode=0 in TAR header (mode code path, POSIX only)',
165167
}
166168
);
167169
});
170+
171+
// ---------------------------------------------------------------------------
172+
// Test B — file.ts:170 SYMLINK with linkname fully stripped → early return
173+
//
174+
// When strip=N removes all components from the SYMLINK linkname, the early-
175+
// return `if (!strippedLinkname) return;` fires and the entry is silently
176+
// skipped (no symlink created on disk, no error).
177+
// ---------------------------------------------------------------------------
178+
179+
describe('extractFile() — SYMLINK entry skipped when strip removes entire linkname', () => {
180+
let tempDir: string;
181+
182+
beforeEach(async () => {
183+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-xz-zeta-sym-'));
184+
});
185+
186+
afterEach(async () => {
187+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
188+
});
189+
190+
it('silently skips a SYMLINK entry when strip=1 removes its 1-component linkname', async () => {
191+
// entry name: "prefix/link-target.txt" → strip=1 strips "prefix/" → yielded as "link-target.txt"
192+
// linkname: "only-one-part" → extractSymlinkEntry strips 1 component →
193+
// parts = ['only-one-part'], parts.slice(1) = [] → strippedLinkname = '' → early return
194+
const rawTar = buildSingleEntryTar({
195+
name: 'prefix/link-target.txt',
196+
content: Buffer.alloc(0),
197+
type: TarEntryType.SYMLINK,
198+
linkname: 'only-one-part',
199+
});
200+
201+
const archivePath = path.join(tempDir, 'sym-strip.tar.xz');
202+
await fs.writeFile(archivePath, xzSync(rawTar));
203+
204+
const dest = path.join(tempDir, 'out');
205+
await fs.mkdir(dest);
206+
207+
// strip=1 strips "prefix/" from the entry name (yielded as "link-target.txt")
208+
// and also strips "only-one-part" → strippedLinkname = '' → early return in extractSymlinkEntry
209+
await extractFile(archivePath, { cwd: dest, strip: 1 });
210+
211+
// The symlink must NOT have been created (silently skipped)
212+
const symlinkPath = path.join(dest, 'link-target.txt');
213+
const exists = await fs
214+
.lstat(symlinkPath)
215+
.then(() => true)
216+
.catch(() => false);
217+
expect(exists).toBe(false);
218+
});
219+
});
220+
221+
// ---------------------------------------------------------------------------
222+
// Test C — file.ts:208 HARDLINK with linkname fully stripped → early return
223+
//
224+
// When strip=N removes all components from the HARDLINK linkname, the early-
225+
// return `if (!strippedLinkname) return;` fires and the entry is silently
226+
// skipped (no hardlink created on disk, no error).
227+
// ---------------------------------------------------------------------------
228+
229+
describe('extractFile() — HARDLINK entry skipped when strip removes entire linkname', () => {
230+
let tempDir: string;
231+
232+
beforeEach(async () => {
233+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tar-xz-zeta-hard-'));
234+
});
235+
236+
afterEach(async () => {
237+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
238+
});
239+
240+
it('silently skips a HARDLINK entry when strip=1 removes its 1-component linkname', async () => {
241+
// HARDLINK linkname has exactly 1 component: "only-one-part"
242+
// After strip=1 → parts.slice(1) = [] → strippedLinkname = '' → early return
243+
const rawTar = buildSingleEntryTar({
244+
name: 'prefix/hardlink-target.txt',
245+
content: Buffer.alloc(0),
246+
type: TarEntryType.HARDLINK,
247+
linkname: 'only-one-part',
248+
});
249+
250+
const archivePath = path.join(tempDir, 'hard-strip.tar.xz');
251+
await fs.writeFile(archivePath, xzSync(rawTar));
252+
253+
const dest = path.join(tempDir, 'out');
254+
await fs.mkdir(dest);
255+
256+
// strip=1 strips "prefix/" from the entry name and strips
257+
// "only-one-part" → ['only-one-part'].slice(1) = [] → strippedLinkname = ''
258+
await extractFile(archivePath, { cwd: dest, strip: 1 });
259+
260+
// The hardlink must NOT have been created (silently skipped)
261+
const hardlinkPath = path.join(dest, 'hardlink-target.txt');
262+
const exists = await fs
263+
.lstat(hardlinkPath)
264+
.then(() => true)
265+
.catch(() => false);
266+
expect(exists).toBe(false);
267+
});
268+
});

0 commit comments

Comments
 (0)