Skip to content

Commit c1a8a28

Browse files
committed
fix(archives): normalize ENOENT surface across zip/tar/tar.gz
Before: `extractZip` on a missing path surfaced adm-zip's generic `"ADM-ZIP: Invalid filename"` (no path, no error code), while `extractTar` and `extractTarGz` surfaced the raw Node-level `ENOENT: no such file or directory, open '<path>'`. Callers branching on `err.code === 'ENOENT'` (standard fs pattern) got inconsistent behavior depending on archive format. After: all three extractors call a new private `assertArchiveExists` preflight that surfaces a Node-style Error with `code: 'ENOENT'`, `path: archivePath`, and a message that includes the path. The preflight runs BEFORE the underlying extractor ever sees the archive, so the ENOENT path is identical across zip, tar, tar.gz, and the `extractArchive` dispatcher. Tests: - Three `.rejects.toThrow()` assertions that only verified *some* rejection happened are now `.rejects.toMatchObject({ code: 'ENOENT', message: stringContaining(path) })` — they now catch a semantic regression (e.g. silent success, wrong error) rather than passing on any thrown value. Misc: - `scripts/build-externals/config.mts` — comment referenced the removed `@socketsecurity/lib/validation/validate-schema` path; updated to `@socketsecurity/lib/schema/validate`. All 38 archive tests + 6327 full-suite tests pass.
1 parent 00c8afa commit c1a8a28

3 files changed

Lines changed: 65 additions & 9 deletions

File tree

scripts/build-externals/config.mts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ export const scopedPackages = [
8686
},
8787
// @sinclair/typebox powers validateSchema()'s TypeBox path. Bundle
8888
// so consumers don't need to install typebox separately — they just
89-
// import from @socketsecurity/lib/validation/validate-schema and
90-
// pass in TypeBox schemas built with our vendored copy of Type.*.
89+
// import from @socketsecurity/lib/schema/validate and pass in
90+
// TypeBox schemas built with our vendored copy of Type.*.
9191
//
9292
// Bundles both the core entry (for Type.* builders) and the /value
9393
// runtime (for Value.Check + Value.Errors used internally).

src/archives.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Supports zip, tar, tar.gz, and tgz formats.
44
*/
55

6-
import { createReadStream } from 'node:fs'
6+
import { createReadStream, existsSync } from 'node:fs'
77
import process from 'node:process'
88
import { pipeline } from 'node:stream/promises'
99
import { createGunzip } from 'node:zlib'
@@ -109,6 +109,29 @@ function validatePathWithinBase(
109109
}
110110
}
111111

112+
/**
113+
* Assert that an archive file exists on disk before handing it to the
114+
* underlying extractor. Normalizes the "missing archive" surface across
115+
* all three extractors (zip/tar/tar.gz): each now throws a Node-style
116+
* `ENOENT` error with the archive path. Without this preflight, `zip`
117+
* goes through adm-zip and surfaces as `"Invalid filename"`, while
118+
* `tar`/`tar.gz` surface the raw Node `ENOENT` — inconsistent, and
119+
* adm-zip's message didn't include the path.
120+
*
121+
* @throws Error with `code: 'ENOENT'` if archivePath doesn't exist.
122+
* @private
123+
*/
124+
function assertArchiveExists(archivePath: string): void {
125+
if (!existsSync(archivePath)) {
126+
const err = new Error(
127+
`ENOENT: no such file or directory, open '${archivePath}'`,
128+
) as Error & { code: string; path: string }
129+
err.code = 'ENOENT'
130+
err.path = archivePath
131+
throw err
132+
}
133+
}
134+
112135
/**
113136
* Detect archive format from file path.
114137
*
@@ -199,6 +222,11 @@ export async function extractTar(
199222
outputDir: string,
200223
options: ExtractOptions = {},
201224
): Promise<void> {
225+
// Normalize the "missing archive" surface (see extractZip) — throw
226+
// ENOENT up front with a clear message rather than letting the
227+
// Node-level createReadStream eventually surface as a stream error.
228+
assertArchiveExists(archivePath)
229+
202230
const {
203231
maxEntries = DEFAULT_MAX_ENTRIES,
204232
maxFileSize = DEFAULT_MAX_FILE_SIZE,
@@ -331,6 +359,9 @@ export async function extractTarGz(
331359
outputDir: string,
332360
options: ExtractOptions = {},
333361
): Promise<void> {
362+
// Normalize the "missing archive" surface (see extractZip).
363+
assertArchiveExists(archivePath)
364+
334365
const {
335366
maxEntries = DEFAULT_MAX_ENTRIES,
336367
maxFileSize = DEFAULT_MAX_FILE_SIZE,
@@ -463,6 +494,10 @@ export async function extractZip(
463494
outputDir: string,
464495
options: ExtractOptions = {},
465496
): Promise<void> {
497+
// Normalize the "missing archive" surface — throws ENOENT before
498+
// AdmZip can surface its generic "Invalid filename" message.
499+
assertArchiveExists(archivePath)
500+
466501
const {
467502
maxEntries = DEFAULT_MAX_ENTRIES,
468503
maxFileSize = DEFAULT_MAX_FILE_SIZE,

test/unit/archives.test.mts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,21 @@ describe('archives', () => {
243243
}, 'extractZip-windows-path-')
244244
})
245245

246-
it('should throw on nonexistent zip file', async () => {
246+
it('should throw ENOENT on nonexistent zip file', async () => {
247247
await runWithTempDir(async tempDir => {
248248
const nonexistentPath = path.join(tempDir, 'nonexistent.zip')
249-
await expect(extractZip(nonexistentPath, tempDir)).rejects.toThrow()
249+
// All three extractors normalize "missing archive" to ENOENT
250+
// via the shared `assertArchiveExists` preflight — previously
251+
// extractZip surfaced adm-zip's generic "Invalid filename"
252+
// while tar/tar.gz surfaced the raw Node ENOENT. Assert on
253+
// `.code === 'ENOENT'` so the test catches a semantic
254+
// regression rather than merely any rejection.
255+
await expect(
256+
extractZip(nonexistentPath, tempDir),
257+
).rejects.toMatchObject({
258+
code: 'ENOENT',
259+
message: expect.stringContaining(nonexistentPath),
260+
})
250261
}, 'extractZip-error-')
251262
})
252263
})
@@ -321,10 +332,15 @@ describe('archives', () => {
321332
}, 'extractTar-windows-path-')
322333
})
323334

324-
it('should throw on nonexistent tar file', async () => {
335+
it('should throw ENOENT on nonexistent tar file', async () => {
325336
await runWithTempDir(async tempDir => {
326337
const nonexistentPath = path.join(tempDir, 'nonexistent.tar')
327-
await expect(extractTar(nonexistentPath, tempDir)).rejects.toThrow()
338+
await expect(
339+
extractTar(nonexistentPath, tempDir),
340+
).rejects.toMatchObject({
341+
code: 'ENOENT',
342+
message: expect.stringContaining(nonexistentPath),
343+
})
328344
}, 'extractTar-error-')
329345
})
330346
})
@@ -421,10 +437,15 @@ describe('archives', () => {
421437
}, 'extractTarGz-windows-path-')
422438
})
423439

424-
it('should throw on nonexistent tar.gz file', async () => {
440+
it('should throw ENOENT on nonexistent tar.gz file', async () => {
425441
await runWithTempDir(async tempDir => {
426442
const nonexistentPath = path.join(tempDir, 'nonexistent.tar.gz')
427-
await expect(extractTarGz(nonexistentPath, tempDir)).rejects.toThrow()
443+
await expect(
444+
extractTarGz(nonexistentPath, tempDir),
445+
).rejects.toMatchObject({
446+
code: 'ENOENT',
447+
message: expect.stringContaining(nonexistentPath),
448+
})
428449
}, 'extractTarGz-error-')
429450
})
430451
})

0 commit comments

Comments
 (0)