diff --git a/packages/cache/docs/path-validation-test-plan.md b/packages/cache/docs/path-validation-test-plan.md index 6151bad5..c1d1582b 100644 --- a/packages/cache/docs/path-validation-test-plan.md +++ b/packages/cache/docs/path-validation-test-plan.md @@ -208,8 +208,10 @@ and bsdtar on macOS (and `tar.exe` on Windows): - `'error'` mode, clean archive → every approved member is extracted - `'error'` mode, PAX path newline archive → throws `CacheIntegrityError` and writes nothing to the workspace -- `'error'` mode, leading `./` entry → still extracted (the `-T` allow-list - uses the canonical `cache/f` name, so the member is not silently skipped) +- `'error'` mode, leading `./` entry → still extracted; the `-T` allow-list + uses the member name verbatim (exactly as node-tar derived it from the + archive), which is what GNU tar matches anchored/exact — stripping the `./` + would make GNU tar report "Not found in archive" - `'error'` mode, long path (> 100 bytes) delivered via PAX → extracted, not dropped by the allow-list (exercises long-name matching in `-T`) diff --git a/packages/cache/src/internal/tar.ts b/packages/cache/src/internal/tar.ts index 93c83f38..4b04b745 100644 --- a/packages/cache/src/internal/tar.ts +++ b/packages/cache/src/internal/tar.ts @@ -431,11 +431,16 @@ function writeAllowList(approvedNames: string[]): string { .randomBytes(8) .toString('hex')}.lst` ) + // Write each approved name exactly as node-tar derived it from the archive + // bytes. That is the same name system tar reads from the archive header, so + // an anchored `-T` match succeeds for every member without us second-guessing + // tar's name handling. In particular we must NOT strip a leading `./`: GNU + // tar matches `-T` names anchored and exact and keeps the `./`, so a + // `cache/f` pattern does not match a `./cache/f` member (it fails with + // "Not found in archive", exit code 2) whereas the verbatim `./cache/f` + // matches and extracts to `cache/f`. const payload = Buffer.concat( - approvedNames.flatMap(name => [ - Buffer.from(canonicalMemberName(name), 'utf8'), - Buffer.from([0]) - ]) + approvedNames.flatMap(name => [Buffer.from(name, 'utf8'), Buffer.from([0])]) ) // `flag: 'wx'` (O_CREAT | O_EXCL | O_WRONLY) makes the open fail if the path // already exists, so a file or symlink pre-planted at the (randomized) temp @@ -446,19 +451,6 @@ function writeAllowList(approvedNames: string[]): string { return allowListPath } -/** - * Canonicalize an approved entry name to the form system `tar` matches against - * its archive members when reading the `-T` allow-list. node-tar already - * converts backslashes to forward slashes; here we additionally strip any - * leading `./` (both GNU tar and bsdtar normalize member names this way) so an - * entry node-tar surfaced as `./cache/f` still matches the member `cache/f` - * and is not silently skipped during extraction. A trailing slash on a - * directory entry is preserved because tar matches directories with it. - */ -function canonicalMemberName(name: string): string { - return name.replace(/^(?:\.\/)+/, '') -} - function reportViolations( violations: PathValidationViolation[], mode: PathValidationMode