From 1ee4635e27365d469c77f743d39467cae28a8ec8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 23:42:28 -0700 Subject: [PATCH 1/3] ci: merge coverage across platforms --- .github/workflows/coverage.yml | 62 +++++++++++++++++++++++-- CHANGELOG.md | 1 + package.json | 5 ++ pnpm-lock.yaml | 9 ++++ scripts/merge-coverage.mjs | 85 ++++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 scripts/merge-coverage.mjs diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 9ecc6f4..c77b53d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -14,9 +14,19 @@ permissions: jobs: coverage: - name: Node 22 coverage - runs-on: ubuntu-latest + name: Node 22 coverage (${{ matrix.os }}) + runs-on: ${{ matrix.os }} timeout-minutes: 15 + strategy: + fail-fast: false + # Platform branches depend on the OS, not the supported Node release. + # Node 22 preserves comparison with the previous coverage lane without + # duplicating the native build on all six test-matrix combinations. + matrix: + os: + - ubuntu-latest + - macos-15 + - windows-latest steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -41,7 +51,48 @@ jobs: run: pnpm native:build - name: Coverage - run: pnpm test:coverage + run: pnpm test:coverage:collect + + - name: Upload coverage input + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ runner.os }} + path: coverage/coverage-final.json + if-no-files-found: error + + report: + name: Merged coverage + needs: coverage + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 10.34.5 + run_install: false + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download coverage inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: coverage-* + path: coverage-inputs + + - name: Merge coverage and enforce thresholds + run: pnpm test:coverage:merge - name: Summarize coverage if: always() @@ -55,7 +106,7 @@ jobs: fs.appendFileSync( process.env.GITHUB_STEP_SUMMARY, [ - "# fs-safe coverage", + "# fs-safe merged coverage", "", "| Metric | Percent |", "|---|---:|", @@ -68,9 +119,10 @@ jobs: ); NODE - - name: Upload coverage results + - name: Upload merged coverage results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: fs-safe-coverage-${{ github.run_id }} path: coverage/ + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 52bf746..fd9e8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ ### Docs and Tooling +- Measure coverage once per operating system and merge the platform reports before enforcing thresholds, so coverage reflects existing cross-platform execution rather than implying new test coverage. - Refresh the native package build CLI to `@napi-rs/cli` 3.8.2. - Give parallel native archive tests collision-free temporary paths so one fixture cannot remove another test's file on coarse-resolution clocks. - Include the README banner in the npm tarball and require it during pack checks so the published README does not reference a missing package asset; sanitize pnpm-only npm configuration from package-smoke subprocesses so the documented release check stays warning-free on newer npm versions. diff --git a/package.json b/package.json index 3b106c8..7369d7c 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,8 @@ "prepack": "node scripts/prepack-build.mjs", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:coverage:collect": "vitest run --coverage --coverage.reporter=json --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.statements=0 --coverage.thresholds.branches=0", + "test:coverage:merge": "node scripts/merge-coverage.mjs", "test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts", "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm docs:check && pnpm test && node scripts/check-pack.mjs", "docs:check": "node scripts/check-doc-examples.mjs", @@ -155,6 +157,9 @@ "@types/node": "^26.1.2", "@vitest/coverage-v8": "4.1.10", "fast-check": "^4.9.0", + "istanbul-lib-coverage": "3.2.2", + "istanbul-lib-report": "3.0.1", + "istanbul-reports": "3.2.0", "sigstore": "5.0.0", "typescript": "^7.0.2", "vite": "8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79446d0..477327a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,15 @@ importers: fast-check: specifier: ^4.9.0 version: 4.9.0 + istanbul-lib-coverage: + specifier: 3.2.2 + version: 3.2.2 + istanbul-lib-report: + specifier: 3.0.1 + version: 3.0.1 + istanbul-reports: + specifier: 3.2.0 + version: 3.2.0 sigstore: specifier: 5.0.0 version: 5.0.0 diff --git a/scripts/merge-coverage.mjs b/scripts/merge-coverage.mjs new file mode 100644 index 0000000..0141b79 --- /dev/null +++ b/scripts/merge-coverage.mjs @@ -0,0 +1,85 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import libCoverage from "istanbul-lib-coverage"; +import libReport from "istanbul-lib-report"; +import reports from "istanbul-reports"; +import { loadConfigFromFile } from "vite"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const coverageDir = join(repoRoot, "coverage"); +const inputPaths = [ + "coverage-inputs/coverage-Linux/coverage-final.json", + "coverage-inputs/coverage-macOS/coverage-final.json", + "coverage-inputs/coverage-Windows/coverage-final.json", +].map((path) => join(repoRoot, path)); + +const coverageMap = libCoverage.createCoverageMap({}); + +for (const inputPath of inputPaths) { + if (!existsSync(inputPath)) { + throw new Error(`Missing required coverage input: ${relative(repoRoot, inputPath)}`); + } + + const input = JSON.parse(readFileSync(inputPath, "utf8")); + const normalized = {}; + for (const [sourcePath, fileCoverage] of Object.entries(input)) { + const portablePath = sourcePath.replaceAll("\\", "/"); + const markerIndex = portablePath.lastIndexOf("/src/"); + const projectPath = markerIndex >= 0 + ? portablePath.slice(markerIndex + 1) + : portablePath.startsWith("src/") + ? portablePath + : undefined; + if (!projectPath) { + throw new Error(`Coverage input contains a non-project path: ${sourcePath}`); + } + + const normalizedPath = join(repoRoot, ...projectPath.split("/")); + if (!existsSync(normalizedPath)) { + throw new Error(`Coverage input references a missing source file: ${projectPath}`); + } + if (normalized[normalizedPath]) { + throw new Error(`Coverage input contains duplicate source file: ${projectPath}`); + } + normalized[normalizedPath] = { ...fileCoverage, path: normalizedPath }; + } + coverageMap.merge(normalized); +} + +rmSync(coverageDir, { recursive: true, force: true }); +const reportContext = libReport.createContext({ + dir: coverageDir, + coverageMap, +}); +for (const reporter of ["text", "json", "json-summary", "html", "lcov"]) { + reports.create(reporter, { projectRoot: repoRoot }).execute(reportContext); +} + +const loadedConfig = await loadConfigFromFile( + { command: "serve", mode: "test" }, + join(repoRoot, "vitest.config.ts"), +); +const thresholds = loadedConfig?.config?.test?.coverage?.thresholds; +if (!thresholds) { + throw new Error("vitest.config.ts does not define coverage thresholds"); +} + +const summary = coverageMap.getCoverageSummary().toJSON(); +let failed = false; +for (const metric of ["lines", "functions", "statements", "branches"]) { + const threshold = thresholds[metric]; + if (typeof threshold !== "number") { + throw new Error(`vitest.config.ts does not define a numeric ${metric} threshold`); + } + if (summary[metric].pct < threshold) { + console.error( + `ERROR: Coverage for ${metric} (${summary[metric].pct}%) does not meet threshold (${threshold}%)`, + ); + failed = true; + } +} +if (failed) { + process.exitCode = 1; +} From e6af38a0de7c406a0bb869f2a9ebf3aeebacb612 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 4 Aug 2026 15:31:00 -0700 Subject: [PATCH 2/3] test: isolate Windows permission fallback coverage --- CHANGELOG.md | 5 ++++- test/new-primitives.test.ts | 17 +++++++++++++++++ test/permissions-exec.test.ts | 11 ++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9e8b8..dae8e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - Preserve semantic path and validation codes in synchronous `FileStore` and temp-workspace reads: missing temp leaves now report `not-found`, stable directories report `not-file`, and hardlinks and symlinks report `hardlink` and `symlink`, replacing `path-mismatch` and fabricated raw `ENOENT` respectively to match their asynchronous twins; consumers treating either old result as absence or identity drift should match the specific path-state code instead. - Report an existing non-directory ancestor as `not-file` from both `assertNoSymlinkParents()` variants, replacing asynchronous success and the synchronous helper's platform-dependent success or raw `ENOTDIR` under default `allowMissing`; callers relying on that acceptance should ensure every existing prefix component is a directory or handle `not-file`. +### Docs and Tooling + +- Measure coverage once per operating system and merge the platform reports before enforcing thresholds, so coverage reflects existing cross-platform execution rather than implying new test coverage. + ## 0.5.2 - 2026-08-02 ### Security and Correctness @@ -73,7 +77,6 @@ ### Docs and Tooling -- Measure coverage once per operating system and merge the platform reports before enforcing thresholds, so coverage reflects existing cross-platform execution rather than implying new test coverage. - Refresh the native package build CLI to `@napi-rs/cli` 3.8.2. - Give parallel native archive tests collision-free temporary paths so one fixture cannot remove another test's file on coarse-resolution clocks. - Include the README banner in the npm tarball and require it during pack checks so the published README does not reference a missing package asset; sanitize pnpm-only npm configuration from package-smoke subprocesses so the documented release check stays warning-free on newer npm versions. diff --git a/test/new-primitives.test.ts b/test/new-primitives.test.ts index c1a055a..1986aaa 100644 --- a/test/new-primitives.test.ts +++ b/test/new-primitives.test.ts @@ -32,6 +32,10 @@ import { writeSiblingTempFile } from "../src/sibling-temp.js"; import { acquireFileLock, createFileLockManager, withFileLock } from "../src/file-lock.js"; import { fileStore, fileStoreSync } from "../src/file-store.js"; import { jsonStore } from "../src/json-store.js"; +import { + __resetFsSafeNativeConfigForTest, + configureFsSafeNative, +} from "../src/native-config.js"; import { createIcaclsResetCommand, formatIcaclsResetCommand, @@ -55,6 +59,10 @@ import { let root: string; const execFileAsync = promisify(execFile); +function useWindowsPermissionFallback(): void { + configureFsSafeNative({ mode: "off" }); +} + async function secureWindowsTestFile(filePath: string): Promise { const username = os.userInfo().username; const commandEnv = { SystemRoot: process.env.SystemRoot }; @@ -78,6 +86,7 @@ beforeEach(async () => { afterEach(async () => { vi.unstubAllEnvs(); + __resetFsSafeNativeConfigForTest(); await fs.rm(root, { recursive: true, force: true }); }); @@ -313,6 +322,7 @@ describe("secure file reads", () => { }); it("uses Windows ACL permission checks for secure reads when requested", async () => { + useWindowsPermissionFallback(); const filePath = path.join(root, "windows-secret.txt"); await fs.writeFile(filePath, "secret", { mode: 0o600 }); const exec = vi @@ -520,6 +530,7 @@ describe("secure file reads", () => { }); it("reports broad Windows SID writes as world-writable", async () => { + useWindowsPermissionFallback(); const target = path.join(root, "windows-acl-token.txt"); await fs.writeFile(target, "secret", { mode: 0o600 }); const exec = vi.fn(async (command: string) => { @@ -549,6 +560,7 @@ describe("secure file reads", () => { }); it("reports a foreign Windows owner even when the visible ACL is read-only", async () => { + useWindowsPermissionFallback(); const target = path.join(root, "windows-foreign-owner.txt"); await fs.writeFile(target, "secret", { mode: 0o600 }); const exec = vi.fn(async (command: string) => { @@ -586,6 +598,7 @@ describe("secure file reads", () => { it.each(["S-1-5-21-42", "S-1-5-18", "S-1-5-32-544"])( "trusts the supported Windows owner SID %s", async (ownerSid) => { + useWindowsPermissionFallback(); const target = path.join(root, `windows-trusted-owner-${ownerSid}.txt`); await fs.writeFile(target, "secret", { mode: 0o600 }); const exec = vi.fn(async (command: string) => { @@ -617,6 +630,7 @@ describe("secure file reads", () => { ); it("queries the canonical Windows owner SID without a friendly-name round trip", async () => { + useWindowsPermissionFallback(); const target = path.join(root, "windows-canonical-owner.txt"); await fs.writeFile(target, "secret", { mode: 0o600 }); const exec = vi.fn(async (command: string, _args: string[]) => { @@ -652,6 +666,7 @@ describe("secure file reads", () => { }); it("leaves Windows ownership unverified when the owner query fails", async () => { + useWindowsPermissionFallback(); const target = path.join(root, "windows-owner-query-failure.txt"); await fs.writeFile(target, "secret", { mode: 0o600 }); const exec = vi.fn(async (command: string) => { @@ -679,6 +694,7 @@ describe("secure file reads", () => { }); it("fails Windows ACL verification closed when a principal SID cannot be translated", async () => { + useWindowsPermissionFallback(); const target = path.join(root, "windows-untranslated-principal.txt"); await fs.writeFile(target, "secret", { mode: 0o600 }); const exec = vi.fn(async () => ({ @@ -705,6 +721,7 @@ describe("secure file reads", () => { }); it("does not trust well-known local owners on remote Windows filesystems", async () => { + useWindowsPermissionFallback(); const target = path.join(root, "windows-remote-owner.txt"); await fs.writeFile(target, "secret", { mode: 0o600 }); const exec = vi.fn(async (command: string) => { diff --git a/test/permissions-exec.test.ts b/test/permissions-exec.test.ts index 568d45c..725a5cc 100644 --- a/test/permissions-exec.test.ts +++ b/test/permissions-exec.test.ts @@ -1,18 +1,27 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { expectFsSafeError } from "./helpers/security.js"; import { DEFAULT_PERMISSION_EXEC_TIMEOUT_MS, executePermissionCommand, } from "../src/permission-exec.js"; +import { + __resetFsSafeNativeConfigForTest, + configureFsSafeNative, +} from "../src/native-config.js"; import { inspectPathPermissions } from "../src/permissions.js"; import { readSecureFile } from "../src/secure-file.js"; const tempDirs: string[] = []; +beforeEach(() => { + configureFsSafeNative({ mode: "off" }); +}); + afterEach(async () => { + __resetFsSafeNativeConfigForTest(); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); From 35b82ff9ca49fa25691cdf6fd183820923a10d90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 4 Aug 2026 15:57:46 -0700 Subject: [PATCH 3/3] fix: reject Windows archive ADS paths --- CHANGELOG.md | 1 + docs/archive.md | 4 +-- native/src/windows.rs | 44 ++++++++++++++++++++++++++++-- src/archive-entry.ts | 9 ++++++ test/archive-property-fuzz.test.ts | 41 ++++++++++++---------------- test/extracted-helpers.test.ts | 7 +++++ test/helpers/property.ts | 2 ++ 7 files changed, 80 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae8e7c..5aabe3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Security and Correctness +- Reject NTFS alternate data stream archive entry names on Windows before extraction, keep JavaScript and native TAR/ZIP policy aligned, and fix one-code-unit native rename and hardlink metadata buffers. - Reject synchronous secret reads when the path is retargeted after the preview check, matching the asynchronous reader's `path-mismatch` contract instead of returning bytes from the replacement file. - Preserve dangling symlinks when trash moves cross filesystems instead of failing while following their missing targets. - Reject non-canonical FileStore keys and malformed archive names before filesystem access, keep JavaScript/native TAR and ZIP rejection semantics aligned (including full-width base-256 sizes and empty ZIP files), and add deterministic property-based regression coverage for path aliasing, parser boundaries, collisions, truncation, and extraction limits. diff --git a/docs/archive.md b/docs/archive.md index 1b77d27..c0c6386 100644 --- a/docs/archive.md +++ b/docs/archive.md @@ -148,7 +148,7 @@ codes remain `"destination-not-directory"`, `"destination-symlink"`, and ## What it defends against -- **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). +- **Path traversal:** entries with `..`, absolute paths, NUL bytes, or Windows drive-relative segments such as `C:secret` and `nested/C:secret` are rejected (`ArchiveSecurityError`). On Windows, path segments containing `:` are also rejected as alternate data stream names before either backend writes to the filesystem. - **Symlink/hardlink entries:** rejected by default. Some archives ship symlink/hardlink entries that point outside the destination once resolved; `extractArchive` does not follow them. - **Ambiguous output names:** duplicate names and distinct names that collide after `stripComponents`, case normalization, or Unicode normalization are rejected instead of relying on backend- or volume-specific overwrite order. - **TOCTOU during merge:** extraction first writes to a private temp dir, then merges into `destDir` using the same boundary checks as `root().write()`. Destination symlink swaps are checked with the selected platform mechanism; non-Linux routes retain the best-effort race window documented in the [security model](security-model.md#containment-guarantees-by-platform). @@ -255,7 +255,7 @@ import { } from "@openclaw/fs-safe/archive"; ``` -- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths. +- `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows. - `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes. - `stripArchivePath(entryPath, n)` — strip the leading N path components, returning `null` if not enough remain. - `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation. diff --git a/native/src/windows.rs b/native/src/windows.rs index f7ddaf3..b2e7b17 100644 --- a/native/src/windows.rs +++ b/native/src/windows.rs @@ -428,7 +428,7 @@ fn set_rename_information( ) -> NativeResult<()> { let name = wide_relative(target_path)?; let name_bytes = std::mem::size_of_val(name.as_slice()); - let byte_len = FILE_NAME_OFFSET + name_bytes; + let byte_len = (FILE_NAME_OFFSET + name_bytes).max(size_of::()); let mut buffer = aligned_name_buffer(byte_len); // SAFETY: the zeroed usize storage is suitably aligned, the fixed fields // end at offset 20 on the supported Windows x64 ABI, and the allocation is @@ -480,7 +480,7 @@ fn set_link_information( ) -> NativeResult<()> { let name = wide_relative(target_path)?; let name_bytes = std::mem::size_of_val(name.as_slice()); - let byte_len = FILE_NAME_OFFSET + name_bytes; + let byte_len = (FILE_NAME_OFFSET + name_bytes).max(size_of::()); let mut buffer = aligned_name_buffer(byte_len); // SAFETY: FILE_LINK_INFORMATION uses the same x64 filename offset. unsafe { @@ -781,6 +781,46 @@ mod tests { .unwrap(); assert_eq!(fs::read(root.join("target")).unwrap(), b"replacement"); drop(replacement); + + for (index, target_name) in ["a", "é"].into_iter().enumerate() { + let source_name = format!("short-source-{index}"); + fs::write(root.join(&source_name), target_name.as_bytes()).unwrap(); + let source = nt_open_relative( + root_handle.as_raw_handle() as HANDLE, + &source_name, + FILE_READ_ATTRIBUTES | DELETE_ACCESS, + FILE_OPEN, + FILE_NON_DIRECTORY_FILE, + ) + .unwrap(); + set_rename_information( + source.0, + root_handle.as_raw_handle() as HANDLE, + target_name, + false, + "rename short target", + ) + .unwrap(); + drop(source); + assert_eq!( + fs::read(root.join(target_name)).unwrap(), + target_name.as_bytes() + ); + } + + fs::write(root.join("link-source"), b"linked").unwrap(); + let link_source = nt_open_relative( + root_handle.as_raw_handle() as HANDLE, + "link-source", + FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES, + FILE_OPEN, + FILE_NON_DIRECTORY_FILE, + ) + .unwrap(); + set_link_information(link_source.0, root_handle.as_raw_handle() as HANDLE, "l").unwrap(); + drop(link_source); + assert_eq!(fs::read(root.join("l")).unwrap(), b"linked"); + fs::remove_dir_all(root).unwrap(); } } diff --git a/src/archive-entry.ts b/src/archive-entry.ts index 15dfb35..fdba6fa 100644 --- a/src/archive-entry.ts +++ b/src/archive-entry.ts @@ -33,6 +33,15 @@ export function validateArchiveEntryPath( ); } const slashNormalized = normalizeArchiveEntryPath(entryPath); + if ( + process.platform === "win32" && + slashNormalized.split("/").some((segment) => segment.includes(":")) + ) { + throw new ArchiveSecurityError( + "entry-path", + `archive entry uses a Windows alternate data stream path: ${formatErrorDetail(entryPath)}`, + ); + } const normalized = path.posix.normalize(slashNormalized); if ( normalized.split("/").some((segment) => diff --git a/test/archive-property-fuzz.test.ts b/test/archive-property-fuzz.test.ts index b56a1e6..0c744b5 100644 --- a/test/archive-property-fuzz.test.ts +++ b/test/archive-property-fuzz.test.ts @@ -80,7 +80,6 @@ async function extractOutcome(params: { kind: ArchiveKind; backend: Backend; limits?: ArchiveExtractLimits; - allowedSystemCodes?: readonly string[]; }): Promise { useBackend(params.backend); const base = await tempRoot(`fs-safe-${params.kind}-${params.backend}-property-`); @@ -104,11 +103,7 @@ async function extractOutcome(params: { } catch (error) { const code = (error as { code?: unknown }).code; expect(typeof code, String(error)).toBe("string"); - const acceptedCodes = new Set([ - ...DOCUMENTED_REJECTION_CODES, - ...(params.allowedSystemCodes ?? []), - ]); - expect(acceptedCodes, String(error)).toContain(code); + expect(DOCUMENTED_REJECTION_CODES, String(error)).toContain(code); expect(await fs.readdir(destination), String(error)).toEqual([]); return { accepted: false, code: code as string }; } @@ -129,15 +124,6 @@ const tarEncoding = fc.constantFrom( "invalid-octal", ); -function windowsPortabilitySystemCodes(name: string): readonly string[] | undefined { - // PR #118 deliberately left Windows ADS policy open; pin its raw errno only - // for that explicit corpus without weakening the general rejection property. - return process.platform === "win32" && - (name === "file:stream" || name === "file.txt:stream") - ? ["ENOENT"] - : undefined; -} - describe("structured TAR fuzz properties", () => { it("classifies malformed headers and hostile names without a third outcome", async () => { await fc.assert( @@ -159,7 +145,6 @@ describe("structured TAR fuzz properties", () => { bytes, kind: "tar", backend: "javascript", - allowedSystemCodes: windowsPortabilitySystemCodes(name), }); expect(javascript).toEqual(expect.objectContaining({ accepted: expect.any(Boolean) })); }, @@ -182,18 +167,15 @@ describe("structured TAR fuzz properties", () => { declaredSize, sizeEncoding, }); - const allowedSystemCodes = windowsPortabilitySystemCodes(name); const javascript = await extractOutcome({ bytes, kind: "tar", backend: "javascript", - allowedSystemCodes, }); const nativeResult = await extractOutcome({ bytes, kind: "tar", backend: "native", - allowedSystemCodes, }); expect(nativeResult.accepted, JSON.stringify({ name, sizeEncoding, bodyLength, declaredSize })) .toBe(javascript.accepted); @@ -219,18 +201,15 @@ describe("structured ZIP fuzz properties", () => { truncateBy, declaredSizeDelta, }); - const allowedSystemCodes = windowsPortabilitySystemCodes(name); const javascript = await extractOutcome({ bytes, kind: "zip", backend: "javascript", - allowedSystemCodes, }); const nativeResult = await extractOutcome({ bytes, kind: "zip", backend: "native", - allowedSystemCodes, }); expect(nativeResult.accepted, JSON.stringify({ name, bodyLength, truncateBy, declaredSizeDelta })) .toBe(javascript.accepted); @@ -254,13 +233,12 @@ describe.each(["tar", "zip"] as const)("%s Windows-name portability", (kind) => bytes, kind, backend, - allowedSystemCodes: windowsPortabilitySystemCodes(name), }); observed.push({ name, outcome }); if (process.platform === "win32") { expect(outcome, JSON.stringify({ kind, backend, name })).toEqual({ accepted: false, - code: name.includes(":") ? "ENOENT" : "device-path", + code: name.includes(":") ? "entry-path" : "device-path", }); } else { expect(outcome, JSON.stringify({ kind, backend, name })).toEqual({ accepted: true }); @@ -319,6 +297,21 @@ describe("minimized parser fuzz regressions", () => { })).resolves.toEqual({ accepted: true }); }); + it.each(backends)("extracts one-code-unit names with %s", async (backend) => { + for (const name of ["a", "é"]) { + await expect(extractOutcome({ + bytes: tarBytes({ name, body: Buffer.from("x") }), + kind: "tar", + backend, + })).resolves.toEqual({ accepted: true }); + await expect(extractOutcome({ + bytes: await zipBytes({ names: [name], body: Buffer.from("x") }), + kind: "zip", + backend, + })).resolves.toEqual({ accepted: true }); + } + }); + it.each(backends)("accepts a valid empty ZIP file with %s", async (backend) => { await expect(extractOutcome({ bytes: await zipBytes({ names: ["empty"], body: Buffer.alloc(0) }), diff --git a/test/extracted-helpers.test.ts b/test/extracted-helpers.test.ts index c13762c..e95149d 100644 --- a/test/extracted-helpers.test.ts +++ b/test/extracted-helpers.test.ts @@ -139,6 +139,13 @@ describe("archive entry helpers", () => { expect(() => validateArchiveEntryPath("nested/secret\0.txt")).toThrow( "archive entry contains a NUL byte", ); + if (process.platform === "win32") { + expect(() => validateArchiveEntryPath("nested/file:stream")).toThrow( + "archive entry uses a Windows alternate data stream path", + ); + } else { + expect(() => validateArchiveEntryPath("nested/file:stream")).not.toThrow(); + } }); it("resolves archive output paths under the destination root", () => { diff --git a/test/helpers/property.ts b/test/helpers/property.ts index 16fb22b..afb9798 100644 --- a/test/helpers/property.ts +++ b/test/helpers/property.ts @@ -23,6 +23,8 @@ export const WINDOWS_ARCHIVE_PORTABILITY_NAMES = [ "LPT9.txt ", "file:stream", "file.txt:stream", + "nested/file:stream", + "éc:relative", ] as const; export const windowsArchivePortabilityName = fc.constantFrom(