mirror of
https://github.com/actions/toolkit.git
synced 2026-08-15 00:00:25 +02:00
feat: optional path validation during cache restore
This commit is contained in:
+387
@@ -0,0 +1,387 @@
|
||||
import {mkdirSync, mkdtempSync, writeFileSync, rmSync} from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as zlib from 'zlib'
|
||||
import {execSync} from 'child_process'
|
||||
import {Header} from 'tar'
|
||||
import {CompressionMethod} from '../src/internal/constants'
|
||||
import {listAndValidate} from '../src/internal/listAndValidate'
|
||||
|
||||
/**
|
||||
* Real-archive integration tests for listAndValidate. These build small tar
|
||||
* archives in-memory using `tar.Header`, write them to disk, and run them
|
||||
* through the same parser the production code uses. No mocks.
|
||||
*/
|
||||
|
||||
interface TestEntry {
|
||||
path: string
|
||||
type: 'File' | 'Directory' | 'SymbolicLink' | 'Link' | 'CharacterDevice'
|
||||
linkpath?: string
|
||||
body?: Buffer
|
||||
}
|
||||
|
||||
function buildTarArchive(entries: TestEntry[]): Buffer {
|
||||
const blocks: Buffer[] = []
|
||||
for (const entry of entries) {
|
||||
const body = entry.body ?? Buffer.alloc(0)
|
||||
const header = new Header({
|
||||
path: entry.path,
|
||||
mode: 0o644,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
size: body.length,
|
||||
mtime: new Date(0),
|
||||
type: entry.type,
|
||||
linkpath: entry.linkpath,
|
||||
uname: 'root',
|
||||
gname: 'root'
|
||||
})
|
||||
const headerBuf = Buffer.alloc(512)
|
||||
header.encode(headerBuf, 0)
|
||||
blocks.push(headerBuf)
|
||||
if (body.length > 0) {
|
||||
blocks.push(body)
|
||||
const pad = (512 - (body.length % 512)) % 512
|
||||
if (pad > 0) blocks.push(Buffer.alloc(pad))
|
||||
}
|
||||
}
|
||||
// Two zero blocks mark end of archive.
|
||||
blocks.push(Buffer.alloc(1024))
|
||||
return Buffer.concat(blocks)
|
||||
}
|
||||
|
||||
const TEST_ROOT = mkdtempSync(path.join(os.tmpdir(), 'cache-listAndValidate-'))
|
||||
|
||||
/**
|
||||
* Detect whether the `zstd` binary is on PATH at module load. We use a
|
||||
* conditional `describe.skip` (rather than per-test early-returns) so that
|
||||
* Jest reports skipped tests as `skipped` in its summary — silently
|
||||
* `return`-ing from a test reports it as `passed`, which masks coverage gaps
|
||||
* on machines where zstd is missing.
|
||||
*/
|
||||
const ZSTD_AVAILABLE = ((): boolean => {
|
||||
try {
|
||||
execSync(process.platform === 'win32' ? 'where zstd' : 'which zstd', {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
const describeZstd = ZSTD_AVAILABLE ? describe : describe.skip
|
||||
|
||||
function workspace(): string {
|
||||
return path.join(TEST_ROOT, 'workspace')
|
||||
}
|
||||
|
||||
function writeArchive(name: string, data: Buffer): string {
|
||||
mkdirSync(TEST_ROOT, {recursive: true})
|
||||
const fullPath = path.join(TEST_ROOT, name)
|
||||
writeFileSync(fullPath, data)
|
||||
return fullPath
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(workspace(), {recursive: true})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(TEST_ROOT, {recursive: true, force: true})
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
})
|
||||
|
||||
describe('listAndValidate (real archives)', () => {
|
||||
describe('uncompressed tar', () => {
|
||||
test('clean archive: zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/file1.txt', type: 'File', body: Buffer.from('hello')},
|
||||
{path: 'cache/sub/file2.txt', type: 'File', body: Buffer.from('world')},
|
||||
{path: 'cache/sub/', type: 'Directory'}
|
||||
])
|
||||
const archivePath = writeArchive('clean.tar', archive)
|
||||
// Inject a fake gzip header by re-compressing? No — we want to test
|
||||
// the uncompressed path. listAndValidate doesn't have a "raw" code
|
||||
// path; it always assumes compression based on `compressionMethod`.
|
||||
// To test uncompressed bytes we run it through gzip and pass Gzip.
|
||||
const gzipped = zlib.gzipSync(archive)
|
||||
writeFileSync(archivePath, gzipped)
|
||||
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('gzip-compressed tar', () => {
|
||||
test('clean archive: zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/file.txt', type: 'File', body: Buffer.from('hi')}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'clean.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
test('classic ../../../etc/passwd traversal: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/legit.txt', type: 'File', body: Buffer.from('ok')},
|
||||
{
|
||||
path: '../../../etc/passwd',
|
||||
type: 'File',
|
||||
body: Buffer.from('pwned')
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'traversal.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].path).toBe('../../../etc/passwd')
|
||||
expect(violations[0].entryType).toBe('File')
|
||||
})
|
||||
|
||||
test('absolute path entry: one violation', async () => {
|
||||
const absPath =
|
||||
process.platform === 'win32'
|
||||
? 'C:/Windows/System32/evil.dll'
|
||||
: '/etc/cron.d/evil'
|
||||
const archive = buildTarArchive([
|
||||
{path: absPath, type: 'File', body: Buffer.from('x')}
|
||||
])
|
||||
const archivePath = writeArchive('abs.tar.gz', zlib.gzipSync(archive))
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].path).toBe(absPath)
|
||||
})
|
||||
|
||||
test('symlink with absolute target: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'SymbolicLink',
|
||||
linkpath: '/etc/passwd'
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'symlink-abs.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].entryType).toBe('SymbolicLink')
|
||||
expect(violations[0].linkpath).toBe('/etc/passwd')
|
||||
})
|
||||
|
||||
test('symlink target traversing out of allowed roots: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'SymbolicLink',
|
||||
linkpath: '../../../etc/passwd'
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'symlink-traverse.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('hardlink to ../etc/passwd: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'Link',
|
||||
linkpath: '../../../etc/passwd'
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'hardlink-traverse.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].entryType).toBe('Link')
|
||||
})
|
||||
|
||||
test('mixed clean and malicious entries: only the bad ones are reported', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/a.txt', type: 'File', body: Buffer.from('1')},
|
||||
{path: '../escape.txt', type: 'File', body: Buffer.from('2')},
|
||||
{path: 'cache/sub/b.txt', type: 'File', body: Buffer.from('3')},
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'SymbolicLink',
|
||||
linkpath: '/tmp/x'
|
||||
},
|
||||
{path: 'cache/sub/c.txt', type: 'File', body: Buffer.from('4')}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'mixed.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
const paths = violations.map(v => v.path).sort()
|
||||
expect(paths).toEqual(['../escape.txt', 'cache/link'])
|
||||
})
|
||||
|
||||
test('character device entry is rejected', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/dev', type: 'CharacterDevice'}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'chardev.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].entryType).toBe('CharacterDevice')
|
||||
})
|
||||
|
||||
test('archive with a single small entry: zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/tiny.txt', type: 'File', body: Buffer.from('1')}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'single.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
test('corrupted / non-tar bytes: throws Error', async () => {
|
||||
const archivePath = writeArchive(
|
||||
'corrupt.tar.gz',
|
||||
zlib.gzipSync(Buffer.from('this is not a tar archive at all'))
|
||||
)
|
||||
await expect(
|
||||
listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describeZstd('zstd-compressed tar', () => {
|
||||
test('clean archive (Zstd with --long): zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/x.bin', type: 'File', body: Buffer.from('hello')}
|
||||
])
|
||||
// Compress via the zstd binary with --long=30 to mirror the real
|
||||
// cache-creation pipeline.
|
||||
const archivePath = path.join(TEST_ROOT, 'clean.tar.zst')
|
||||
mkdirSync(TEST_ROOT, {recursive: true})
|
||||
writeFileSync(`${archivePath}.raw`, archive)
|
||||
execSync(
|
||||
`zstd --long=30 --force -o "${archivePath}" "${archivePath}.raw"`,
|
||||
{stdio: 'ignore'}
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Zstd,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
test('traversal in zstd archive: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: '../escape.txt', type: 'File', body: Buffer.from('x')}
|
||||
])
|
||||
const archivePath = path.join(TEST_ROOT, 'evil.tar.zst')
|
||||
writeFileSync(`${archivePath}.raw`, archive)
|
||||
execSync(
|
||||
`zstd --long=30 --force -o "${archivePath}" "${archivePath}.raw"`,
|
||||
{stdio: 'ignore'}
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Zstd,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('ZstdWithoutLong compression method works', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/y.bin', type: 'File', body: Buffer.from('z')}
|
||||
])
|
||||
const archivePath = path.join(TEST_ROOT, 'clean.short.tar.zst')
|
||||
writeFileSync(`${archivePath}.raw`, archive)
|
||||
execSync(`zstd --force -o "${archivePath}" "${archivePath}.raw"`, {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.ZstdWithoutLong,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
+5
-2
@@ -11,6 +11,7 @@ const downloadConcurrency = 8
|
||||
const timeoutInMs = 30000
|
||||
const segmentTimeoutInMs = 600000
|
||||
const lookupOnly = false
|
||||
const pathValidation = 'off'
|
||||
|
||||
test('getDownloadOptions sets defaults', async () => {
|
||||
const actualOptions = getDownloadOptions()
|
||||
@@ -21,7 +22,8 @@ test('getDownloadOptions sets defaults', async () => {
|
||||
downloadConcurrency,
|
||||
timeoutInMs,
|
||||
segmentTimeoutInMs,
|
||||
lookupOnly
|
||||
lookupOnly,
|
||||
pathValidation
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,7 +34,8 @@ test('getDownloadOptions overrides all settings', async () => {
|
||||
downloadConcurrency: 14,
|
||||
timeoutInMs: 20000,
|
||||
segmentTimeoutInMs: 3600000,
|
||||
lookupOnly: true
|
||||
lookupOnly: true,
|
||||
pathValidation: 'error'
|
||||
}
|
||||
|
||||
const actualOptions = getDownloadOptions(expectedOptions)
|
||||
|
||||
+963
@@ -0,0 +1,963 @@
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
PathValidationViolation,
|
||||
deriveAllowedRoots,
|
||||
formatViolationSummary,
|
||||
validateEntry
|
||||
} from '../src/internal/pathValidation'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin'
|
||||
|
||||
describe('deriveAllowedRoots', () => {
|
||||
const cwd = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
|
||||
describe('glob-prefix stripping', () => {
|
||||
test('literal absolute path is returned unchanged', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('star suffix strips trailing glob segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar\\*' : '/foo/bar/*'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('question-mark glob strips its segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar\\?c' : '/foo/bar/?c'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('character class strips its segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\[bc]\\d' : '/foo/[bc]/d'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('brace expansion strips its segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\{x,y}\\d' : '/foo/{x,y}/d'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('** in the middle strips at the **', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\**\\d' : '/foo/**/d'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('leading ** falls back to extraction CWD', () => {
|
||||
const expected = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
expect(deriveAllowedRoots(['**/node_modules'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('single * falls back to extraction CWD', () => {
|
||||
const expected = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
expect(deriveAllowedRoots(['*'], cwd)).toEqual([expected])
|
||||
})
|
||||
})
|
||||
|
||||
describe('negation handling', () => {
|
||||
test('negation pattern (! prefix) is dropped from allowed roots', () => {
|
||||
const input = IS_WINDOWS
|
||||
? ['!C:\\foo\\secret']
|
||||
: ['!/foo/secret']
|
||||
expect(deriveAllowedRoots(input, cwd)).toEqual([])
|
||||
})
|
||||
|
||||
test('negation does not subtract from a sibling allowed root', () => {
|
||||
const allowed = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const negated = IS_WINDOWS ? '!C:\\foo\\secret' : '!/foo/secret'
|
||||
expect(deriveAllowedRoots([allowed, negated], cwd)).toEqual([allowed])
|
||||
})
|
||||
})
|
||||
|
||||
describe('path expansion', () => {
|
||||
test('~ expands to home directory', () => {
|
||||
expect(deriveAllowedRoots(['~'], cwd)).toEqual([os.homedir()])
|
||||
})
|
||||
|
||||
test('~/x expands to home/x', () => {
|
||||
const expected = path.join(os.homedir(), '.cache')
|
||||
expect(deriveAllowedRoots(['~/.cache'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('${VAR} expands an environment variable', () => {
|
||||
const original = process.env['CACHE_TEST_ROOT']
|
||||
process.env['CACHE_TEST_ROOT'] = IS_WINDOWS ? 'C:\\envroot' : '/envroot'
|
||||
try {
|
||||
const expected = IS_WINDOWS ? 'C:\\envroot\\sub' : '/envroot/sub'
|
||||
expect(
|
||||
deriveAllowedRoots(['${CACHE_TEST_ROOT}/sub'], cwd)
|
||||
).toEqual([expected])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_ROOT']
|
||||
else process.env['CACHE_TEST_ROOT'] = original
|
||||
}
|
||||
})
|
||||
|
||||
test('$VAR style expands an environment variable', () => {
|
||||
const original = process.env['CACHE_TEST_ROOT']
|
||||
process.env['CACHE_TEST_ROOT'] = IS_WINDOWS ? 'C:\\envroot' : '/envroot'
|
||||
try {
|
||||
const expected = IS_WINDOWS ? 'C:\\envroot\\sub' : '/envroot/sub'
|
||||
expect(deriveAllowedRoots(['$CACHE_TEST_ROOT/sub'], cwd)).toEqual([
|
||||
expected
|
||||
])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_ROOT']
|
||||
else process.env['CACHE_TEST_ROOT'] = original
|
||||
}
|
||||
})
|
||||
|
||||
test('%VAR% Windows-style expands an environment variable', () => {
|
||||
const original = process.env['CACHE_TEST_WIN_ROOT']
|
||||
process.env['CACHE_TEST_WIN_ROOT'] = IS_WINDOWS ? 'C:\\winroot' : '/winroot'
|
||||
try {
|
||||
const expected = IS_WINDOWS ? 'C:\\winroot\\sub' : '/winroot/sub'
|
||||
expect(
|
||||
deriveAllowedRoots(['%CACHE_TEST_WIN_ROOT%/sub'], cwd)
|
||||
).toEqual([expected])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_WIN_ROOT']
|
||||
else process.env['CACHE_TEST_WIN_ROOT'] = original
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown env var expands to empty string', () => {
|
||||
delete process.env['DEFINITELY_NOT_SET_VAR_XYZ123']
|
||||
// After expansion: "/sub", which is absolute on POSIX, so it stays /sub.
|
||||
// On Windows it becomes a relative path resolved against cwd.
|
||||
const result = deriveAllowedRoots(
|
||||
['${DEFINITELY_NOT_SET_VAR_XYZ123}/sub'],
|
||||
cwd
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
// Just ensure no crash and produces a deterministic value.
|
||||
expect(typeof result[0]).toBe('string')
|
||||
})
|
||||
|
||||
test('env value containing glob characters is preserved verbatim, not truncated', () => {
|
||||
// Regression test: an earlier implementation expanded env vars before
|
||||
// detecting glob characters, so an env value that happened to contain
|
||||
// a `*` or `{` would truncate the prefix mid-path and silently broaden
|
||||
// the allowed root. Glob detection must run on the pre-expansion text.
|
||||
const original = process.env['CACHE_TEST_ROOT']
|
||||
process.env['CACHE_TEST_ROOT'] = IS_WINDOWS
|
||||
? 'C:\\envroot*odd'
|
||||
: '/envroot*odd'
|
||||
try {
|
||||
const expected = IS_WINDOWS
|
||||
? 'C:\\envroot*odd\\sub'
|
||||
: '/envroot*odd/sub'
|
||||
expect(
|
||||
deriveAllowedRoots(['${CACHE_TEST_ROOT}/sub'], cwd)
|
||||
).toEqual([expected])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_ROOT']
|
||||
else process.env['CACHE_TEST_ROOT'] = original
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalization', () => {
|
||||
test('trailing slash is normalized away', () => {
|
||||
const a = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
const b = IS_WINDOWS ? 'C:\\foo\\bar\\' : '/foo/bar/'
|
||||
expect(deriveAllowedRoots([a], cwd)).toEqual(deriveAllowedRoots([b], cwd))
|
||||
})
|
||||
|
||||
test('relative paths resolve against CWD', () => {
|
||||
const expected = IS_WINDOWS
|
||||
? 'C:\\workspace\\node_modules'
|
||||
: '/workspace/node_modules'
|
||||
expect(deriveAllowedRoots(['node_modules'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('. resolves to CWD', () => {
|
||||
const expected = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
expect(deriveAllowedRoots(['.'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('whitespace-only path is dropped', () => {
|
||||
expect(deriveAllowedRoots([' '], cwd)).toEqual([])
|
||||
})
|
||||
|
||||
test('empty string path is dropped', () => {
|
||||
expect(deriveAllowedRoots([''], cwd)).toEqual([])
|
||||
})
|
||||
|
||||
test('input with embedded ./ segments normalizes', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\.\\bar' : '/foo/./bar'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('input with embedded ../ segments normalizes', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar\\..\\baz' : '/foo/bar/../baz'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\baz' : '/foo/baz'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deduplication and subsumption', () => {
|
||||
test('identical roots are deduplicated', () => {
|
||||
const a = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([a, a], cwd)).toEqual([a])
|
||||
})
|
||||
|
||||
test('child root is subsumed by parent', () => {
|
||||
const parent = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const child = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([parent, child], cwd)).toEqual([parent])
|
||||
})
|
||||
|
||||
test('child first then parent still results in just parent', () => {
|
||||
const parent = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const child = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([child, parent], cwd)).toEqual([parent])
|
||||
})
|
||||
|
||||
test('sibling prefix collision does NOT subsume', () => {
|
||||
// /aa is NOT a child of /a — must be kept as a separate root.
|
||||
const a = IS_WINDOWS ? 'C:\\a' : '/a'
|
||||
const aa = IS_WINDOWS ? 'C:\\aa' : '/aa'
|
||||
const result = deriveAllowedRoots([a, aa], cwd)
|
||||
expect(result).toContain(a)
|
||||
expect(result).toContain(aa)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('completely disjoint roots are both kept', () => {
|
||||
const a = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const b = IS_WINDOWS ? 'C:\\bar' : '/bar'
|
||||
const result = deriveAllowedRoots([a, b], cwd)
|
||||
expect(result).toContain(a)
|
||||
expect(result).toContain(b)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
if (CASE_INSENSITIVE) {
|
||||
test('case-insensitive FS: roots that differ only in case dedupe', () => {
|
||||
const lower = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
const upper = IS_WINDOWS ? 'C:\\FOO\\bar' : '/FOO/bar'
|
||||
const result = deriveAllowedRoots([lower, upper], cwd)
|
||||
// Either form may win, but only one root should survive.
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('validateEntry', () => {
|
||||
const cwd = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
const allowedRoots = [
|
||||
IS_WINDOWS ? 'C:\\workspace\\node_modules' : '/workspace/node_modules',
|
||||
IS_WINDOWS ? 'C:\\workspace\\.cache' : '/workspace/.cache'
|
||||
]
|
||||
|
||||
describe('legitimate entries (must pass)', () => {
|
||||
test('regular file under root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('deeply nested file', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/' + Array(50).fill('sub').join('/') + '/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('entry with leading ./', () => {
|
||||
const r = validateEntry(
|
||||
'./node_modules/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('entry with non-escaping .. segment', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/sub/../foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('entry with double slash normalizes ok', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules//foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('directory entry (trailing slash)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/',
|
||||
undefined,
|
||||
'Directory',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('filename with spaces', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/My File.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('Unicode filename', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/节点/файл.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('hidden file (.git/HEAD)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/.git/HEAD',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('filename containing .. as substring (not segment)', () => {
|
||||
for (const name of ['..hidden', 'file..txt', 'a..b/c']) {
|
||||
const r = validateEntry(
|
||||
'node_modules/' + name,
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('symlink within same root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/.bin/cmd',
|
||||
'../foo/bin/cmd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('symlink crossing into a different allowed root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
// From /workspace/node_modules/, "../.cache/x" → /workspace/.cache/x (an allowed root)
|
||||
'../.cache/x',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('hardlink within same root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/dup.js',
|
||||
// Hardlink target is relative to extraction CWD
|
||||
'node_modules/orig.js',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('path traversal attacks (must reject)', () => {
|
||||
test('classic ../../etc/passwd', () => {
|
||||
const r = validateEntry(
|
||||
'../../../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('inside-then-out traversal', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/../../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('hidden in middle', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/sub/../../../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('multiple slashes around .. still rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/..//../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('trailing .. inside root is allowed (does not escape)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/sub/..',
|
||||
undefined,
|
||||
'Directory',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('absolute / UNC paths (must reject)', () => {
|
||||
test('POSIX absolute path', () => {
|
||||
const r = validateEntry(
|
||||
'/etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('POSIX root', () => {
|
||||
const r = validateEntry('/', undefined, 'Directory', allowedRoots, cwd)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
test('Windows absolute path with backslash', () => {
|
||||
const r = validateEntry(
|
||||
'C:\\Windows\\System32\\drivers\\etc\\hosts',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('Windows absolute path with forward slash', () => {
|
||||
const r = validateEntry(
|
||||
'C:/Windows/System32/drivers/etc/hosts',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('Windows drive-relative (no slash)', () => {
|
||||
const r = validateEntry(
|
||||
'C:foo',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('UNC path with backslashes', () => {
|
||||
const r = validateEntry(
|
||||
'\\\\attacker\\share\\payload',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNC_PATH')
|
||||
})
|
||||
|
||||
test('UNC path with forward slashes', () => {
|
||||
const r = validateEntry(
|
||||
'//attacker/share/payload',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNC_PATH')
|
||||
})
|
||||
|
||||
test('UNC long-path prefix', () => {
|
||||
const r = validateEntry(
|
||||
'\\\\?\\C:\\foo',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNC_PATH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NUL byte attacks (must reject)', () => {
|
||||
test('NUL in path', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe\0/../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('NUL_BYTE')
|
||||
})
|
||||
|
||||
test('NUL in symlink target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'safe\0/etc/passwd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('NUL_BYTE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('symlink/hardlink attacks (must reject)', () => {
|
||||
test('symlink with absolute target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'/etc/passwd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('symlink with .. traversal', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'../../../etc/passwd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('hardlink with absolute target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/dup',
|
||||
'/etc/passwd',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('hardlink with .. traversal', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/dup',
|
||||
'../../etc/passwd',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('symlink-then-write-through-link attack: symlink target outside roots is rejected', () => {
|
||||
// This is the critical "symlink-then-write" attack:
|
||||
// Entry 1: node_modules/x → /etc (a symlink target outside allowed roots)
|
||||
// Entry 2: node_modules/x/payload (which extracts THROUGH entry 1's link
|
||||
// and lands at /etc/payload)
|
||||
// We must reject Entry 1 because its target is outside the allowed roots.
|
||||
// Entry 2's nominal path looks safe, so we cannot rely on per-entry path
|
||||
// validation alone — we must catch the symlink target.
|
||||
const r = validateEntry(
|
||||
'node_modules/x',
|
||||
'/etc',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('symlink with empty target is allowed (filtered as undefined linkpath)', () => {
|
||||
// Empty string for linkpath is treated as "no link target to validate".
|
||||
const r = validateEntry(
|
||||
'node_modules/x',
|
||||
'',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('self-referential symlink (link to .)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/x',
|
||||
'.',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
// "." resolves to the entry's directory which is under the root.
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported entry types (must reject)', () => {
|
||||
test('CharacterDevice is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'CharacterDevice',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('BlockDevice is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'BlockDevice',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('FIFO is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'FIFO',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('ContiguousFile is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'ContiguousFile',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('unknown / future entry type is rejected by the allow-list', () => {
|
||||
// node-tar can surface an unknown typeflag byte as a non-standard
|
||||
// string. The allow-list approach guarantees we reject it rather than
|
||||
// silently passing it through to the extractor.
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'SomeFutureType',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('arbitrary typeflag-byte string is rejected', () => {
|
||||
// Simulate an attacker-supplied non-standard typeflag like '\u0001'
|
||||
// surfacing as a literal string from the tar parser.
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'\u0001',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('empty entry type string is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('allow-listed entry types (must accept)', () => {
|
||||
test.each(['File', 'OldFile', 'Directory'])(
|
||||
'%s is accepted for an in-root path',
|
||||
entryType => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
entryType,
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('SymbolicLink is accepted for an in-root target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'real',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('Link (hardlink) is accepted for an in-root target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/hardlink',
|
||||
'node_modules/real',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('header-only entry types (must accept without validation)', () => {
|
||||
test('GlobalExtendedHeader is accepted regardless of path content', () => {
|
||||
// PAX global headers don't materialize as a file; their "path" is metadata.
|
||||
const r = validateEntry(
|
||||
'/anything/at/all',
|
||||
undefined,
|
||||
'GlobalExtendedHeader',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('ExtendedHeader is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'PaxHeader/path-doesnt-matter',
|
||||
undefined,
|
||||
'ExtendedHeader',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('NextFileHasLongPath is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'@LongLink',
|
||||
undefined,
|
||||
'NextFileHasLongPath',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('NextFileHasLongLinkpath is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'@LongLink',
|
||||
undefined,
|
||||
'NextFileHasLongLinkpath',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('control characters in filenames (legal but unusual)', () => {
|
||||
test('embedded newline is treated literally as part of the segment', () => {
|
||||
// node-tar reports the full filename including the embedded newline.
|
||||
// It's a single segment under node_modules — must be accepted.
|
||||
const r = validateEntry(
|
||||
'node_modules/file\nwith newline',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('embedded tab is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/file\twith tab',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('case sensitivity', () => {
|
||||
if (CASE_INSENSITIVE) {
|
||||
test('on Windows/macOS, NODE_MODULES matches node_modules', () => {
|
||||
const r = validateEntry(
|
||||
'NODE_MODULES/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
} else {
|
||||
test('on Linux, NODE_MODULES does NOT match node_modules', () => {
|
||||
const r = validateEntry(
|
||||
'NODE_MODULES/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('sibling-prefix non-collision', () => {
|
||||
test('an entry under /foo-extra is NOT accepted by allowed-root /foo', () => {
|
||||
const roots = [IS_WINDOWS ? 'C:\\workspace\\foo' : '/workspace/foo']
|
||||
const r = validateEntry(
|
||||
'../foo-extra/payload',
|
||||
undefined,
|
||||
'File',
|
||||
roots,
|
||||
IS_WINDOWS ? 'C:\\workspace\\foo' : '/workspace/foo'
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatViolationSummary', () => {
|
||||
function v(reason: string): PathValidationViolation {
|
||||
return {
|
||||
path: 'x',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
test('empty list produces empty string', () => {
|
||||
expect(formatViolationSummary([])).toBe('')
|
||||
})
|
||||
|
||||
test('shows up to maxShown items verbatim', () => {
|
||||
const out = formatViolationSummary(
|
||||
[v('a'), v('b'), v('c')],
|
||||
5
|
||||
)
|
||||
expect(out).toContain(' - a')
|
||||
expect(out).toContain(' - b')
|
||||
expect(out).toContain(' - c')
|
||||
expect(out).not.toContain('more')
|
||||
})
|
||||
|
||||
test('truncates excess items with summary line', () => {
|
||||
const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(v)
|
||||
const out = formatViolationSummary(items, 3)
|
||||
expect(out).toContain(' - a')
|
||||
expect(out).toContain(' - b')
|
||||
expect(out).toContain(' - c')
|
||||
expect(out).toContain('and 4 more')
|
||||
expect(out).not.toContain(' - d')
|
||||
})
|
||||
})
|
||||
+12
-3
@@ -166,7 +166,10 @@ test('restore with gzip compressed cache found', async () => {
|
||||
expect(getArchiveFileSizeInBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
@@ -227,7 +230,10 @@ test('restore with zstd compressed cache found', async () => {
|
||||
expect(infoMock).toHaveBeenCalledWith(`Cache Size: ~60 MB (62915000 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -285,7 +291,10 @@ test('restore with cache found for restore key', async () => {
|
||||
expect(infoMock).toHaveBeenCalledWith(`Cache Size: ~0 MB (142 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
+12
-3
@@ -210,7 +210,10 @@ test('restore with gzip compressed cache found', async () => {
|
||||
expect(logInfoMock).toHaveBeenCalledWith(`Cache Size: ~0 MB (142 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
@@ -287,7 +290,10 @@ test('restore with zstd compressed cache found', async () => {
|
||||
expect(logInfoMock).toHaveBeenCalledWith(`Cache Size: ~60 MB (62915000 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
@@ -367,7 +373,10 @@ test('restore with cache found for restore key', async () => {
|
||||
expect(logInfoMock).toHaveBeenCalledWith(`Cache Size: ~0 MB (142 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
import * as exec from '@actions/exec'
|
||||
import * as core from '@actions/core'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import {CompressionMethod} from '../src/internal/constants'
|
||||
import * as tar from '../src/internal/tar'
|
||||
import {CacheIntegrityError} from '../src/internal/cacheIntegrityError'
|
||||
import * as listAndValidate from '../src/internal/listAndValidate'
|
||||
|
||||
jest.mock('@actions/exec')
|
||||
jest.mock('@actions/io')
|
||||
jest.mock('../src/internal/listAndValidate')
|
||||
|
||||
function getTempDir(): string {
|
||||
return path.join(__dirname, '_temp', 'tarPathValidation')
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(io, 'which').mockImplementation(async tool => tool)
|
||||
process.env['GITHUB_WORKSPACE'] = process.cwd()
|
||||
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
jest.spyOn(io, 'which').mockImplementation(async tool => tool)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env['GITHUB_WORKSPACE']
|
||||
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
||||
})
|
||||
|
||||
const archive = 'cache.tar.gz'
|
||||
|
||||
describe('extractTar path validation integration', () => {
|
||||
describe("mode 'off' (default)", () => {
|
||||
test('does not call listAndValidate when no options passed', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip)
|
||||
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1) // system tar still runs
|
||||
})
|
||||
|
||||
test("explicit pathValidation='off' skips validation", async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'off'
|
||||
})
|
||||
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mode 'warn'", () => {
|
||||
test('clean archive: no warnings emitted, extraction proceeds', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('violations present: exactly one warning, debug per violation, extraction still proceeds', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue([
|
||||
{
|
||||
path: '../escape.txt',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason: 'escapes allowed roots'
|
||||
},
|
||||
{
|
||||
path: 'cache/link',
|
||||
linkpath: '/etc/passwd',
|
||||
resolved: '',
|
||||
entryType: 'SymbolicLink',
|
||||
code: 'LINK_OUTSIDE_ROOTS',
|
||||
reason: 'symlink target outside allowed roots'
|
||||
}
|
||||
])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
const debugSpy = jest.spyOn(core, 'debug').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
// Exactly one summary warning
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/2 entries/)
|
||||
expect(warnSpy.mock.calls[0][0]).not.toMatch(/failed integrity/)
|
||||
// One debug entry per violation
|
||||
expect(debugSpy).toHaveBeenCalledTimes(2)
|
||||
expect(debugSpy.mock.calls[0][0]).toMatch(/path-validation/)
|
||||
// Extraction still happens
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('single violation: warning text uses singular wording', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue([
|
||||
{
|
||||
path: '../boom.txt',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason: 'escapes'
|
||||
}
|
||||
])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
jest.spyOn(core, 'debug').mockImplementation()
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/1 entry/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mode 'error'", () => {
|
||||
test('violations present: throws CacheIntegrityError, system tar NEVER invoked', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue([
|
||||
{
|
||||
path: '../etc/passwd',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason: 'escapes allowed roots'
|
||||
}
|
||||
])
|
||||
jest.spyOn(core, 'warning').mockImplementation()
|
||||
jest.spyOn(core, 'debug').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
const mkdirMock = jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
||||
|
||||
await expect(
|
||||
tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
).rejects.toThrow(CacheIntegrityError)
|
||||
|
||||
// The critical security assertion: no extraction directory was created
|
||||
// and system tar was never invoked.
|
||||
expect(execMock).not.toHaveBeenCalled()
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('thrown error has code PATH_VIOLATION and exposes violations', async () => {
|
||||
const violations = [
|
||||
{
|
||||
path: '../boom.txt',
|
||||
resolved: '',
|
||||
entryType: 'File' as const,
|
||||
code: 'OUTSIDE_ROOTS' as const,
|
||||
reason: 'escapes'
|
||||
}
|
||||
]
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue(violations)
|
||||
jest.spyOn(core, 'warning').mockImplementation()
|
||||
jest.spyOn(core, 'debug').mockImplementation()
|
||||
|
||||
try {
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
fail('expected CacheIntegrityError')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CacheIntegrityError)
|
||||
const e = err as CacheIntegrityError
|
||||
expect(e.code).toBe('PATH_VIOLATION')
|
||||
expect(e.violations).toEqual(violations)
|
||||
expect(e.message).toMatch(/Refusing to extract/)
|
||||
}
|
||||
})
|
||||
|
||||
test('clean archive: no throw, extraction proceeds normally', async () => {
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('listAndValidate throws parse error: wrapped as CacheIntegrityError(PARSE_ERROR), no extraction', async () => {
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockRejectedValue(new Error('tar parse error (TAR_BAD_ARCHIVE): bad'))
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
const mkdirMock = jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
||||
|
||||
try {
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
fail('expected throw')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CacheIntegrityError)
|
||||
expect((err as CacheIntegrityError).code).toBe('PARSE_ERROR')
|
||||
expect((err as CacheIntegrityError).message).toMatch(
|
||||
/tar parse error/
|
||||
)
|
||||
}
|
||||
expect(execMock).not.toHaveBeenCalled()
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("listAndValidate parse failure in 'warn' mode: logs warning, skips validation, extraction proceeds", async () => {
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockRejectedValue(new Error('bad bytes'))
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
|
||||
// In 'warn' mode a parse failure is non-fatal: the validator's tar
|
||||
// parser is stricter than the system `tar` that performs the actual
|
||||
// extraction, so the archive may still extract cleanly. We log a
|
||||
// warning and proceed.
|
||||
await expect(
|
||||
tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/integrity check failed/)
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/bad bytes/)
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allowed-roots derivation', () => {
|
||||
test('declaredPaths is forwarded to listAndValidate', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['build/', 'node_modules/'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
const [, , allowedRoots] = listMock.mock.calls[0]
|
||||
// Both declared roots should appear after resolution
|
||||
expect(allowedRoots.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('missing declaredPaths falls back to workspace as the sole allowed root', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
const [, , allowedRoots, extractCwd] = listMock.mock.calls[0]
|
||||
// Empty declaredPaths array → fail-safe fallback to extractCwd
|
||||
expect(allowedRoots).toEqual([extractCwd])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compression method forwarding', () => {
|
||||
test('Gzip', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.Gzip)
|
||||
})
|
||||
|
||||
test('Zstd', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Zstd, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.Zstd)
|
||||
})
|
||||
|
||||
test('ZstdWithoutLong', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.ZstdWithoutLong, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock.mock.calls[0][1]).toBe(
|
||||
CompressionMethod.ZstdWithoutLong
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user