diff --git a/CHANGELOG.md b/CHANGELOG.md index b9d87cd..e2b223f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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. ## 0.5.2 - 2026-08-02 diff --git a/native/src/tar_meter.rs b/native/src/tar_meter.rs index 6abb4cc..8e960b4 100644 --- a/native/src/tar_meter.rs +++ b/native/src/tar_meter.rs @@ -52,6 +52,9 @@ impl TarMetadataMeter { fn parse_size(field: &[u8; 12]) -> io::Result { if field[0] & 0x80 != 0 { + if field[0] != 0x80 || field[1..4].iter().any(|byte| *byte != 0) { + return Err(Self::invalid("base-256 size is negative or overflows u64")); + } let mut value = 0_u64; for byte in &field[4..] { value = value @@ -80,6 +83,18 @@ impl TarMetadataMeter { self.block_len = 0; return Ok(()); } + let name_end = self.block[..100] + .iter() + .position(|byte| *byte == 0) + .unwrap_or(100); + if name_end == 0 { + return Err(Self::invalid("entry path is empty")); + } + if self.block[156] != b'5' && self.block[..name_end].last() == Some(&b'/') { + return Err(Self::invalid( + "non-directory entry path ends with a separator", + )); + } let size = Self::parse_size(self.block[124..136].try_into().unwrap())?; let padded = Self::padded_size(size)?; let entry_type = self.block[156]; @@ -263,4 +278,24 @@ mod tests { .contains(INVALID_HEADER) ); } + + #[test] + fn rejects_base_256_sizes_with_nonzero_high_order_bytes() { + for offset in 1..4 { + let mut block = header(b'0', 0, true); + block[124 + offset] = 1; + let error = consume(block.to_vec(), 1024).unwrap_err(); + assert!(error.to_string().contains(INVALID_HEADER)); + assert!(error.to_string().contains("overflows u64")); + } + } + + #[test] + fn rejects_an_empty_entry_path() { + let mut block = header(b'0', 0, false); + block[..100].fill(0); + let error = consume(block.to_vec(), 1024).unwrap_err(); + assert!(error.to_string().contains(INVALID_HEADER)); + assert!(error.to_string().contains("entry path is empty")); + } } diff --git a/package.json b/package.json index 289a8be..3b106c8 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "@napi-rs/cli": "3.8.2", "@types/node": "^26.1.2", "@vitest/coverage-v8": "4.1.10", + "fast-check": "^4.9.0", "sigstore": "5.0.0", "typescript": "^7.0.2", "vite": "8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 998782d..79446d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) + fast-check: + specifier: ^4.9.0 + version: 4.9.0 sigstore: specifier: 5.0.0 version: 5.0.0 @@ -1224,6 +1227,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -1511,6 +1518,9 @@ packages: kerberos: optional: true + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -2676,6 +2686,10 @@ snapshots: expect-type@1.4.0: {} + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -2938,6 +2952,8 @@ snapshots: proxy-agent-negotiate@1.1.0: {} + pure-rand@8.4.2: {} + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 diff --git a/src/archive-entry.ts b/src/archive-entry.ts index bba9bc6..15dfb35 100644 --- a/src/archive-entry.ts +++ b/src/archive-entry.ts @@ -32,7 +32,21 @@ export function validateArchiveEntryPath( `archive entry contains a NUL byte: ${formatErrorDetail(entryPath)}`, ); } - const normalized = path.posix.normalize(normalizeArchiveEntryPath(entryPath)); + const slashNormalized = normalizeArchiveEntryPath(entryPath); + const normalized = path.posix.normalize(slashNormalized); + if ( + normalized.split("/").some((segment) => + Math.max( + Buffer.byteLength(segment.normalize("NFC")), + Buffer.byteLength(segment.normalize("NFD")), + ) > 255 + ) + ) { + throw new ArchiveSecurityError( + "entry-path", + `archive entry has an overlong path component: ${formatErrorDetail(entryPath)}`, + ); + } const escapeLabel = params?.escapeLabel ?? "destination"; if (normalized === ".." || normalized.startsWith("../")) { throw new ArchiveSecurityError( @@ -46,6 +60,12 @@ export function validateArchiveEntryPath( `archive entry is absolute: ${formatErrorDetail(entryPath)}`, ); } + if (slashNormalized.split("/").includes("..")) { + throw new ArchiveSecurityError( + "entry-path", + `archive entry contains a parent segment: ${formatErrorDetail(entryPath)}`, + ); + } } export function stripArchivePath(entryPath: string, stripComponents: number): string | null { diff --git a/src/archive-errors.ts b/src/archive-errors.ts index 0e84019..7c88ca2 100644 --- a/src/archive-errors.ts +++ b/src/archive-errors.ts @@ -27,3 +27,10 @@ export class ArchiveFormatError extends Error { this.code = "archive-header-invalid"; } } + +export function isArchiveFormatErrorMessage(message: string): boolean { + return ( + message.includes("archive-header-invalid") || + message.includes("archive entry size did not match its manifest") + ); +} diff --git a/src/archive-native.ts b/src/archive-native.ts index caf1b90..14a9f73 100644 --- a/src/archive-native.ts +++ b/src/archive-native.ts @@ -1,6 +1,10 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; -import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js"; +import { + ArchiveFormatError, + ArchiveSecurityError, + isArchiveFormatErrorMessage, +} from "./archive-errors.js"; import { formatErrorDetail } from "./error-detail.js"; import { createArchiveOutputPathTracker, @@ -40,9 +44,12 @@ function throwMappedNativeError(error: unknown): never { for (const code of Object.values(ARCHIVE_LIMIT_ERROR_CODE)) { if (error.message.includes(code)) throw new ArchiveLimitError(code); } - if (error.message.includes("archive-header-invalid")) { + if (isArchiveFormatErrorMessage(error.message)) { throw new ArchiveFormatError(error.message, { cause: error }); } + if ((error as Error & { code?: unknown }).code === "InvalidArg") { + throw new ArchiveFormatError(`invalid archive: ${error.message}`, { cause: error }); + } } throw error; } @@ -163,7 +170,7 @@ export async function extractNativeArchive(params: { plan, limits.maxMetaEntryBytes, params.deadline.signal, - ); + ).catch(throwMappedNativeError); } finally { await directory.close().catch(() => undefined); } diff --git a/src/archive-read.ts b/src/archive-read.ts index 2314e06..3c92761 100644 --- a/src/archive-read.ts +++ b/src/archive-read.ts @@ -2,7 +2,11 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import { Readable } from "node:stream"; import { readFileHandleBounded } from "./bounded-read.js"; -import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js"; +import { + ArchiveFormatError, + ArchiveSecurityError, + isArchiveFormatErrorMessage, +} from "./archive-errors.js"; import { formatErrorDetail } from "./error-detail.js"; import { normalizeArchiveEntryPath, @@ -18,9 +22,12 @@ import { } from "./archive-limits.js"; import { readTarEntryInfo } from "./archive-tar.js"; import { preflightTarMetadata } from "./archive-tar-meta.js"; -import { importOptionalTar } from "./archive-tar-runtime.js"; +import { importOptionalTar, normalizeTarParserError } from "./archive-tar-runtime.js"; import { loadZipArchiveWithPreflight } from "./archive-zip-preflight.js"; -import { createZipIntegrityTransform } from "./archive-zip-integrity.js"; +import { + createZipIntegrityTransform, + normalizeZipIntegrityError, +} from "./archive-zip-integrity.js"; import type { ZipEntry } from "./archive-zip-entry.js"; import { FsSafeError } from "./errors.js"; import { sameFileIdentity } from "./file-identity.js"; @@ -133,11 +140,13 @@ async function readZipEntry(buffer: Buffer, entryPath: string, maxBytes: number) ) { throw new Error(`archive entry is a link: ${formatErrorDetail(entryPath)}`); } - const stream = + const stream: NodeJS.ReadableStream = typeof entry.nodeStream === "function" ? entry.nodeStream() : Readable.from(await entry.async("nodebuffer")); - return await readStreamBounded(stream.pipe(createZipIntegrityTransform(entry)), maxBytes); + const integrity = createZipIntegrityTransform(entry); + stream.once("error", (error: Error) => integrity.destroy(normalizeZipIntegrityError(error))); + return await readStreamBounded(stream.pipe(integrity), maxBytes); } async function readTarEntry(archivePath: string, entryPath: string, maxBytes: number): Promise { @@ -149,44 +158,48 @@ async function readTarEntry(archivePath: string, entryPath: string, maxBytes: nu let matched: Promise | undefined; let entryError: Error | undefined; const seenPaths = new Set(); - await tar.t({ - file: archivePath, - strict: true, - maxMetaEntrySize: DEFAULT_MAX_META_ENTRY_BYTES, - onReadEntry(entry) { - const info = readTarEntryInfo(entry); - validateArchiveEntryPath(info.path, { escapeLabel: "archive root" }); - const normalized = normalizeArchiveEntryPath(info.path).replace(/^\.\//, ""); - if (seenPaths.has(normalized)) { - entryError ??= new ArchiveSecurityError( - "entry-path", - `archive contains duplicate entry path: ${formatErrorDetail(normalized)}`, - ); - entry.resume(); - return; - } - seenPaths.add(normalized); - if (normalized !== entryPath) { - entry.resume(); - return; - } - if (info.type !== "File" && info.type !== "OldFile" && info.type !== "ContiguousFile") { - entryError ??= new Error( - `archive entry is not a file: ${formatErrorDetail(entryPath)}`, - ); - entry.resume(); - return; - } - if (info.size > maxBytes) { - entryError ??= new ArchiveLimitError( - ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT, - ); - entry.resume(); - return; - } - matched = readStreamBounded(entry, maxBytes); - }, - }); + try { + await tar.t({ + file: archivePath, + strict: true, + maxMetaEntrySize: DEFAULT_MAX_META_ENTRY_BYTES, + onReadEntry(entry) { + const info = readTarEntryInfo(entry); + validateArchiveEntryPath(info.path, { escapeLabel: "archive root" }); + const normalized = normalizeArchiveEntryPath(info.path).replace(/^\.\//, ""); + if (seenPaths.has(normalized)) { + entryError ??= new ArchiveSecurityError( + "entry-path", + `archive contains duplicate entry path: ${formatErrorDetail(normalized)}`, + ); + entry.resume(); + return; + } + seenPaths.add(normalized); + if (normalized !== entryPath) { + entry.resume(); + return; + } + if (info.type !== "File" && info.type !== "OldFile" && info.type !== "ContiguousFile") { + entryError ??= new Error( + `archive entry is not a file: ${formatErrorDetail(entryPath)}`, + ); + entry.resume(); + return; + } + if (info.size > maxBytes) { + entryError ??= new ArchiveLimitError( + ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT, + ); + entry.resume(); + return; + } + matched = readStreamBounded(entry, maxBytes); + }, + }); + } catch (error) { + throw normalizeTarParserError(error); + } if (entryError) { throw entryError; } @@ -265,7 +278,7 @@ export async function readArchiveEntry( ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT, ); } - if (error instanceof Error && error.message.includes("archive-header-invalid")) { + if (error instanceof Error && isArchiveFormatErrorMessage(error.message)) { throw new ArchiveFormatError(error.message, { cause: error }); } throw error; diff --git a/src/archive-tar-meta.ts b/src/archive-tar-meta.ts index 3a30d7c..d53f17f 100644 --- a/src/archive-tar-meta.ts +++ b/src/archive-tar-meta.ts @@ -52,6 +52,14 @@ class TarMetadataMeter extends Transform { this.state = { kind: "header" }; return; } + const nameEnd = this.block.subarray(0, 100).indexOf(0); + const name = this.block.subarray(0, nameEnd < 0 ? 100 : nameEnd); + if (name.length === 0) { + throw this.invalid("entry path is empty"); + } + if (this.block[156] !== 0x35 && name.at(-1) === 0x2f) { + throw this.invalid("non-directory entry path ends with a separator"); + } const size = this.parseSize(); const type = this.block[156]; if ([0x78, 0x67, 0x4c, 0x4b, 0x58].includes(type ?? -1) && size > this.maxMetaEntryBytes) { diff --git a/src/archive-tar-runtime.ts b/src/archive-tar-runtime.ts index 9f995cd..f1636ea 100644 --- a/src/archive-tar-runtime.ts +++ b/src/archive-tar-runtime.ts @@ -1,3 +1,5 @@ +import { ArchiveFormatError } from "./archive-errors.js"; + export type TarParserEntry = { meta?: boolean; size: number; @@ -47,3 +49,14 @@ export async function importOptionalTar(): Promise { ); } } + +export function normalizeTarParserError(error: unknown): unknown { + const code = (error as { code?: unknown } | null)?.code; + if (typeof code !== "string" || !code.startsWith("TAR_")) { + return error; + } + const message = error instanceof Error ? error.message : String(error); + return new ArchiveFormatError(`invalid TAR archive: ${message}`, { + cause: error instanceof Error ? error : undefined, + }); +} diff --git a/src/archive-tar.ts b/src/archive-tar.ts index b0a85a1..73a706f 100644 --- a/src/archive-tar.ts +++ b/src/archive-tar.ts @@ -109,6 +109,7 @@ export function createTarEntryPreflightChecker(params: { ); } + budget.startEntry(); budget.addEntrySize(entry.size); return true; }; diff --git a/src/archive-zip-entry.ts b/src/archive-zip-entry.ts index 4581cc7..3c33538 100644 --- a/src/archive-zip-entry.ts +++ b/src/archive-zip-entry.ts @@ -7,11 +7,24 @@ export type ZipEntry = { name: string; dir: boolean; unixPermissions?: number; - _data?: { crc32?: number; uncompressedSize?: number }; + _data?: { crc32?: number; uncompressedSize?: number } | PromiseLike; nodeStream?: () => NodeJS.ReadableStream; async: (type: "nodebuffer") => Promise; }; +export function zipEntryIntegrityMetadata( + entry: ZipEntry, +): { crc32?: number; uncompressedSize?: number } | undefined { + const data = entry._data; + if (!data || "then" in data) return undefined; + return data; +} + +export function hasDeferredEmptyZipData(entry: ZipEntry): boolean { + const data = entry._data; + return Boolean(data && "then" in data); +} + const ZIP_UNIX_FILE_TYPE_MASK = 0o170000; const ZIP_UNIX_SYMLINK_TYPE = 0o120000; @@ -34,5 +47,5 @@ export function zipEntryMode( } export function zipEntryDeclaredSize(entry: ZipEntry): number { - return Math.max(0, Math.floor(entry._data?.uncompressedSize ?? 0)); + return Math.max(0, Math.floor(zipEntryIntegrityMetadata(entry)?.uncompressedSize ?? 0)); } diff --git a/src/archive-zip-integrity.ts b/src/archive-zip-integrity.ts index 495afd2..8dfeb4e 100644 --- a/src/archive-zip-integrity.ts +++ b/src/archive-zip-integrity.ts @@ -1,6 +1,10 @@ import { Transform } from "node:stream"; import { ArchiveFormatError } from "./archive-errors.js"; -import type { ZipEntry } from "./archive-zip-entry.js"; +import { + hasDeferredEmptyZipData, + zipEntryIntegrityMetadata, + type ZipEntry, +} from "./archive-zip-entry.js"; const CRC32_TABLE = Array.from({ length: 256 }, (_, index) => { let value = index; @@ -18,9 +22,21 @@ function updateCrc32(previous: number, buffer: Buffer): number { return (crc ^ -1) >>> 0; } +export function normalizeZipIntegrityError(error: unknown): Error { + if ( + error instanceof Error && + error.message.includes("uncompressed data size mismatch") + ) { + return new ArchiveFormatError(`invalid ZIP entry data: ${error.message}`, { cause: error }); + } + return error instanceof Error ? error : new Error(String(error)); +} + export function createZipIntegrityTransform(entry: ZipEntry): Transform { - const expectedCrc32 = entry._data?.crc32; - const expectedSize = entry._data?.uncompressedSize; + const metadata = zipEntryIntegrityMetadata(entry); + const deferredEmpty = hasDeferredEmptyZipData(entry); + const expectedCrc32 = deferredEmpty ? 0 : metadata?.crc32; + const expectedSize = deferredEmpty ? 0 : metadata?.uncompressedSize; if ( typeof expectedCrc32 !== "number" || !Number.isInteger(expectedCrc32) || diff --git a/src/archive-zip-preflight.ts b/src/archive-zip-preflight.ts index 96865af..8e39ad6 100644 --- a/src/archive-zip-preflight.ts +++ b/src/archive-zip-preflight.ts @@ -5,7 +5,7 @@ import { resolveExtractLimits, type ArchiveExtractLimits, } from "./archive-limits.js"; -import { ArchiveSecurityError } from "./archive-errors.js"; +import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js"; export type ZipArchiveWithFiles = { files: Record; @@ -211,7 +211,15 @@ export async function loadZipArchiveWithPreflight( assertArchiveEntryCountWithinLimit(entryCount, resolvedLimits); } const JSZip = await importOptionalJsZip(); - const archive = await JSZip.loadAsync(buffer); + let archive: ZipArchiveWithFiles; + try { + archive = await JSZip.loadAsync(buffer); + } catch (error) { + throw new ArchiveFormatError( + `invalid ZIP archive: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ); + } if (entryCount !== null && Object.keys(archive.files).length !== entryCount) { throw new ArchiveSecurityError( "entry-path", diff --git a/src/archive.ts b/src/archive.ts index b1db571..eb8f122 100644 --- a/src/archive.ts +++ b/src/archive.ts @@ -6,6 +6,7 @@ import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { createArchiveOutputPathTracker, + normalizeArchiveEntryPath, resolveArchiveOutputPath, stripArchivePath, validateArchiveEntryPath, @@ -43,7 +44,10 @@ import { zipEntryMode, type ZipEntry, } from "./archive-zip-entry.js"; -import { createZipIntegrityTransform } from "./archive-zip-integrity.js"; +import { + createZipIntegrityTransform, + normalizeZipIntegrityError, +} from "./archive-zip-integrity.js"; import { FsSafeError } from "./errors.js"; import { ArchiveSecurityError } from "./archive-errors.js"; import { extractNativeArchive } from "./archive-native.js"; @@ -53,7 +57,7 @@ import { resolveArchiveEntryMode, shouldExtractArchiveEntry, } from "./archive-policy.js"; -import { importOptionalTar } from "./archive-tar-runtime.js"; +import { importOptionalTar, normalizeTarParserError } from "./archive-tar-runtime.js"; import { preflightTarMetadata } from "./archive-tar-meta.js"; import type { ExtractArchiveOptions } from "./archive-options.js"; import { writeSiblingTempFile } from "./sibling-temp.js"; @@ -186,7 +190,7 @@ async function writeZipFileEntry(params: { { signal: params.deadline.signal }, ); } catch (err) { - throw createPipelineTimeoutError(err, params.deadline); + throw normalizeZipIntegrityError(createPipelineTimeoutError(err, params.deadline)); } params.deadline.check(); if (!handleClosedByStream) { @@ -390,6 +394,7 @@ export async function extractArchive(params: ExtractArchiveOptions): Promise segment.length === 0 || segment === ".") || + segments.some((segment) => segment.length === 0 || segment === ".") || + raw.normalize("NFC") !== raw || + segments.some((segment) => /[ .]$/u.test(segment)) + ) { + throw new FsSafeError("invalid-path", "store key must use one canonical relative spelling"); + } + return raw; } function resolveStorePath(rootDir: string, relativePath: string): string { diff --git a/test/archive-property-fuzz.test.ts b/test/archive-property-fuzz.test.ts new file mode 100644 index 0000000..b56a1e6 --- /dev/null +++ b/test/archive-property-fuzz.test.ts @@ -0,0 +1,451 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import fc from "fast-check"; +import { afterEach, describe, expect, it } from "vitest"; +import { + tarBytes, + tarEntriesBytes, + zipBytes, + type TarSizeEncoding, +} from "./helpers/archive-fuzz.js"; +import { + adversarialPath, + archiveAliasingPathPair, + propertyParameters, + WINDOWS_ARCHIVE_PORTABILITY_NAMES, +} from "./helpers/property.js"; +import { useRealTempDirs } from "./helpers/vitest.js"; +import { + ARCHIVE_LIMIT_ERROR_CODE, + extractArchive, + readArchiveEntry, + type ArchiveExtractLimits, + type ArchiveKind, +} from "../src/archive.js"; +import { + __resetFsSafeNativeConfigForTest, + configureFsSafeNative, +} from "../src/native-config.js"; +import { + __loadBundledNativeForTest, + __resetNativeLoaderForTest, + __setNativeLoaderForTest, + type NativeBinding, +} from "../src/native.js"; +import { isPathInside } from "../src/path.js"; + +const { tempRoot } = useRealTempDirs(); +const DOCUMENTED_REJECTION_CODES = new Set([ + "archive-header-invalid", + "device-path", + "entry-path", + ...Object.values(ARCHIVE_LIMIT_ERROR_CODE), +]); + +let native: NativeBinding | undefined; +try { + native = __loadBundledNativeForTest(); +} catch { + // JS-only jobs still run all parser properties except cross-reader equivalence. +} + +type Backend = "javascript" | "native"; +type ArchiveOutcome = { accepted: true } | { accepted: false; code: string }; +const backends = native ? (["javascript", "native"] as const) : (["javascript"] as const); + +function useBackend(backend: Backend): void { + if (backend === "native") { + __setNativeLoaderForTest(() => native!); + configureFsSafeNative({ mode: "require" }); + } else { + configureFsSafeNative({ mode: "off" }); + } +} + +async function collectFiles(root: string): Promise { + const found: string[] = []; + async function visit(directory: string): Promise { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(target); + else found.push(target); + } + } + await visit(root); + return found; +} + +async function extractOutcome(params: { + bytes: Buffer; + kind: ArchiveKind; + backend: Backend; + limits?: ArchiveExtractLimits; + allowedSystemCodes?: readonly string[]; +}): Promise { + useBackend(params.backend); + const base = await tempRoot(`fs-safe-${params.kind}-${params.backend}-property-`); + const archivePath = path.join(base, `input.${params.kind === "zip" ? "zip" : "tar"}`); + const destination = path.join(base, "destination"); + await fs.writeFile(archivePath, params.bytes); + await fs.mkdir(destination); + try { + await extractArchive({ + archivePath, + destDir: destination, + kind: params.kind, + limits: params.limits, + timeoutMs: 5_000, + }); + for (const file of await collectFiles(destination)) { + expect(isPathInside(destination, file), JSON.stringify({ file })).toBe(true); + expect(file).not.toBe(destination); + } + return { accepted: true }; + } 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(await fs.readdir(destination), String(error)).toEqual([]); + return { accepted: false, code: code as string }; + } +} + +afterEach(() => { + __resetFsSafeNativeConfigForTest(); + __resetNativeLoaderForTest(); +}); + +const tarEncoding = fc.constantFrom( + "octal", + "octal-max", + "base256", + "base256-u64-max", + "base256-high-bits", + "base256-negative", + "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( + fc.asyncProperty( + adversarialPath, + tarEncoding, + fc.integer({ min: 0, max: 32 }), + fc.integer({ min: 0, max: 34 }), + fc.option(fc.integer({ min: 0, max: 511 }), { nil: undefined }), + async (name, sizeEncoding, bodyLength, declaredSize, truncateTo) => { + const bytes = tarBytes({ + name, + body: Buffer.alloc(bodyLength, 0x61), + declaredSize, + sizeEncoding, + truncateTo, + }); + const javascript = await extractOutcome({ + bytes, + kind: "tar", + backend: "javascript", + allowedSystemCodes: windowsPortabilitySystemCodes(name), + }); + expect(javascript).toEqual(expect.objectContaining({ accepted: expect.any(Boolean) })); + }, + ), + propertyParameters(80), + ); + }, 15_000); + + it.runIf(Boolean(native))("keeps native and JavaScript TAR decisions equivalent", async () => { + await fc.assert( + fc.asyncProperty( + adversarialPath, + tarEncoding, + fc.integer({ min: 0, max: 32 }), + fc.integer({ min: 0, max: 34 }), + async (name, sizeEncoding, bodyLength, declaredSize) => { + const bytes = tarBytes({ + name, + body: Buffer.alloc(bodyLength, 0x61), + 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); + }, + ), + propertyParameters(60), + ); + }, 15_000); +}); + +describe("structured ZIP fuzz properties", () => { + it.runIf(Boolean(native))("keeps native and JavaScript ZIP decisions equivalent", async () => { + await fc.assert( + fc.asyncProperty( + adversarialPath, + fc.integer({ min: 0, max: 24 }), + fc.option(fc.integer({ min: 1, max: 16 }), { nil: undefined }), + fc.option(fc.integer({ min: 1, max: 3 }), { nil: undefined }), + async (name, bodyLength, truncateBy, declaredSizeDelta) => { + const bytes = await zipBytes({ + names: [name], + body: Buffer.alloc(bodyLength, 0x62), + 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); + }, + ), + propertyParameters(50), + ); + }); +}); + +describe.each(["tar", "zip"] as const)("%s Windows-name portability", (kind) => { + it.each(backends)( + "pins reserved devices, ADS, and trailing aliases with %s", + async (backend) => { + const observed: Array<{ name: string; outcome: ArchiveOutcome }> = []; + for (const name of WINDOWS_ARCHIVE_PORTABILITY_NAMES) { + const bytes = kind === "tar" + ? tarBytes({ name, body: Buffer.from("x") }) + : await zipBytes({ names: [name], body: Buffer.from("x") }); + const outcome = await extractOutcome({ + 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", + }); + } else { + expect(outcome, JSON.stringify({ kind, backend, name })).toEqual({ accepted: true }); + } + } + if ( + process.platform === "win32" || + process.env.FS_SAFE_PRINT_ARCHIVE_PORTABILITY === "1" + ) { + console.info(JSON.stringify({ platform: process.platform, kind, backend, observed })); + } + }, + 30_000, + ); +}); + +describe("minimized parser fuzz regressions", () => { + it.each(backends)("rejects full-width base-256 overflow with %s", async (backend) => { + await expect(extractOutcome({ + bytes: tarBytes({ name: "value", sizeEncoding: "base256-high-bits" }), + kind: "tar", + backend, + })).resolves.toEqual({ accepted: false, code: "archive-header-invalid" }); + }); + + it.each(backends)("types numeric TAR fields at and past their maxima with %s", async (backend) => { + for (const sizeEncoding of [ + "octal-max", + "invalid-octal", + "base256-u64-max", + "base256-high-bits", + ] as const) { + await expect(extractOutcome({ + bytes: tarBytes({ name: "value", sizeEncoding }), + kind: "tar", + backend, + })).resolves.toEqual({ accepted: false, code: "archive-header-invalid" }); + } + }); + + it.each(backends)("types empty and separator-terminated TAR file names with %s", async (backend) => { + for (const name of ["", "value/"]) { + await expect(extractOutcome({ + bytes: tarBytes({ name, body: Buffer.from("x") }), + kind: "tar", + backend, + })).resolves.toEqual({ accepted: false, code: "archive-header-invalid" }); + } + }); + + it.each(backends)("extracts a normalized backslash TAR name with %s", async (backend) => { + await expect(extractOutcome({ + bytes: tarBytes({ name: "nested\\value", body: Buffer.from("x") }), + kind: "tar", + 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) }), + kind: "zip", + backend, + limits: { maxEntryBytes: 0, maxExtractedBytes: 0 }, + })).resolves.toEqual({ accepted: true }); + }); + + it("types JSZip stream size mismatches for extraction and bounded reads", async () => { + const bytes = await zipBytes({ + names: ["payload"], + body: Buffer.alloc(0), + declaredSizeDelta: 1, + }); + await expect(extractOutcome({ bytes, kind: "zip", backend: "javascript" })) + .resolves.toEqual({ accepted: false, code: "archive-header-invalid" }); + + configureFsSafeNative({ mode: "off" }); + const base = await tempRoot("fs-safe-zip-size-mismatch-"); + const archivePath = path.join(base, "payload.zip"); + await fs.writeFile(archivePath, bytes); + await expect(readArchiveEntry(archivePath, "payload", { maxBytes: 1, kind: "zip" })) + .rejects.toMatchObject({ code: "archive-header-invalid" }); + }); + + it.each(backends)("types truncated and overlong ZIP inputs with %s", async (backend) => { + await expect(extractOutcome({ + bytes: await zipBytes({ names: ["value"], truncateBy: 1 }), + kind: "zip", + backend, + })).resolves.toEqual({ accepted: false, code: "archive-header-invalid" }); + await expect(extractOutcome({ + bytes: await zipBytes({ names: [`long/${"x".repeat(256)}`] }), + kind: "zip", + backend, + })).resolves.toEqual({ accepted: false, code: "entry-path" }); + }); +}); + +describe.each(["tar", "zip"] as const)("%s collision properties", (kind) => { + it.each(backends)( + "rejects distinct spellings of one output with %s", + async (backend) => { + await fc.assert( + fc.asyncProperty(archiveAliasingPathPair, async ([first, second]) => { + const bytes = kind === "tar" + ? tarEntriesBytes([{ name: first }, { name: second }]) + : await zipBytes({ names: [first, second] }); + await expect(extractOutcome({ bytes, kind, backend })).resolves.toMatchObject({ + accepted: false, + code: "entry-path", + }); + }), + propertyParameters(20), + ); + }, + 10_000, + ); +}); + +describe.each(["tar", "zip"] as const)("%s declared limits", (kind) => { + it.each(backends)( + "honours maxEntryBytes exactly at and one past the boundary with %s", + async (backend) => { + await fc.assert( + fc.asyncProperty(fc.integer({ min: 0, max: 64 }), async (size) => { + const body = Buffer.alloc(size, 0x63); + const sizeEncoding = size % 2 === 0 ? "octal" : "base256"; + const bytes = kind === "tar" + ? tarBytes({ name: "payload", body, sizeEncoding }) + : await zipBytes({ names: ["payload"], body }); + await expect(extractOutcome({ + bytes, + kind, + backend, + limits: { maxEntryBytes: size, maxExtractedBytes: size }, + })).resolves.toEqual({ accepted: true }); + if (size > 0) { + await expect(extractOutcome({ + bytes, + kind, + backend, + limits: { maxEntryBytes: size - 1, maxExtractedBytes: size }, + })).resolves.toMatchObject({ + accepted: false, + code: ARCHIVE_LIMIT_ERROR_CODE.ENTRY_EXTRACTED_SIZE_EXCEEDS_LIMIT, + }); + } + }), + propertyParameters(20), + ); + }, + 20_000, + ); + + it.each(backends)( + "honours entry-count, total-byte, path-component, and archive-byte edges with %s", + async (backend) => { + const body = Buffer.from("x"); + const archive = kind === "tar" + ? tarEntriesBytes([{ name: "a/one", body }, { name: "a/two", body }]) + : await zipBytes({ names: ["a/one", "a/two"], body }); + const entryCount = kind === "zip" ? 3 : 2; + await expect(extractOutcome({ + bytes: archive, + kind, + backend, + limits: { + maxArchiveBytes: archive.byteLength, + maxEntries: entryCount, + maxEntryBytes: 1, + maxExtractedBytes: 2, + maxEntryPathComponents: 2, + }, + })).resolves.toEqual({ accepted: true }); + + for (const [limits, code] of [ + [{ maxEntries: entryCount - 1 }, ARCHIVE_LIMIT_ERROR_CODE.ENTRY_COUNT_EXCEEDS_LIMIT], + [{ maxExtractedBytes: 1 }, ARCHIVE_LIMIT_ERROR_CODE.EXTRACTED_SIZE_EXCEEDS_LIMIT], + [{ maxEntryPathComponents: 1 }, ARCHIVE_LIMIT_ERROR_CODE.ENTRY_PATH_COMPONENTS_EXCEEDS_LIMIT], + [{ maxArchiveBytes: archive.byteLength - 1 }, ARCHIVE_LIMIT_ERROR_CODE.ARCHIVE_SIZE_EXCEEDS_LIMIT], + ] as const) { + await expect(extractOutcome({ bytes: archive, kind, backend, limits })) + .resolves.toMatchObject({ accepted: false, code }); + } + }, + 15_000, + ); +}); diff --git a/test/file-store-property.test.ts b/test/file-store-property.test.ts new file mode 100644 index 0000000..69d33de --- /dev/null +++ b/test/file-store-property.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { useRealTempDirs } from "./helpers/vitest.js"; +import { + adversarialPath, + aliasingPathPair, + propertyParameters, +} from "./helpers/property.js"; +import { fileStore, fileStoreSync } from "../src/file-store.js"; +import { isPathInside } from "../src/path.js"; + +const { tempRoot } = useRealTempDirs(); + +type ValidationResult = + | { accepted: true; path: string } + | { accepted: false; code: unknown }; + +function validatePath(operation: () => string): ValidationResult { + try { + return { accepted: true, path: operation() }; + } catch (error) { + return { accepted: false, code: (error as { code?: unknown }).code }; + } +} + +function portableKey(key: string): string { + return path.posix.normalize(key).normalize("NFC").replace(/[ .]+(?=\/|$)/gu, ""); +} + +async function entriesIfPresent(directory: string): Promise { + try { + return await fs.readdir(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +describe("FileStore path properties", () => { + it("keeps every accepted key canonical, strictly contained, and sync-equivalent", async () => { + const base = await tempRoot("fs-safe-store-property-"); + const rootDir = path.join(base, "root"); + const asyncStore = fileStore({ rootDir }); + const syncStore = fileStoreSync({ rootDir }); + + await fc.assert( + fc.asyncProperty(adversarialPath, async (key) => { + const asyncResult = validatePath(() => asyncStore.path(key)); + const syncResult = validatePath(() => syncStore.path(key)); + expect(syncResult, JSON.stringify({ key })).toEqual(asyncResult); + + if (asyncResult.accepted) { + expect(asyncResult.path, JSON.stringify({ key })).not.toBe(rootDir); + expect(isPathInside(rootDir, asyncResult.path), JSON.stringify({ key })).toBe(true); + expect(key, JSON.stringify({ key })).toBe(portableKey(key)); + return; + } + + expect(asyncResult.code, JSON.stringify({ key })).toBe("invalid-path"); + const before = await entriesIfPresent(rootDir); + await expect(asyncStore.write(key, "async"), JSON.stringify({ key })).rejects.toMatchObject({ + code: "invalid-path", + }); + expect(() => syncStore.write(key, "sync"), JSON.stringify({ key })).toThrow( + expect.objectContaining({ code: "invalid-path" }), + ); + expect(await entriesIfPresent(rootDir), JSON.stringify({ key })).toEqual(before); + }), + propertyParameters(250), + ); + }); + + it("never accepts two portable-equivalent spellings for one output path", async () => { + const base = await tempRoot("fs-safe-store-alias-property-"); + const store = fileStore({ rootDir: path.join(base, "root") }); + + await fc.assert( + fc.asyncProperty(aliasingPathPair, async ([first, second]) => { + expect(portableKey(first), JSON.stringify({ first, second })).toBe(portableKey(second)); + const firstResult = validatePath(() => store.path(first)); + const secondResult = validatePath(() => store.path(second)); + expect( + firstResult.accepted && secondResult.accepted, + JSON.stringify({ first, second }), + ).toBe(false); + }), + propertyParameters(150), + ); + }); +}); diff --git a/test/helpers/archive-fuzz.ts b/test/helpers/archive-fuzz.ts new file mode 100644 index 0000000..4ba0117 --- /dev/null +++ b/test/helpers/archive-fuzz.ts @@ -0,0 +1,108 @@ +import JSZip from "jszip"; + +export type TarSizeEncoding = + | "octal" + | "octal-max" + | "base256" + | "base256-u64-max" + | "base256-high-bits" + | "base256-negative" + | "invalid-octal"; + +function writeString(block: Buffer, offset: number, length: number, value: string): void { + block.write(value, offset, Math.min(length, Buffer.byteLength(value)), "utf8"); +} + +function writeOctal(block: Buffer, offset: number, length: number, value: number): void { + writeString(block, offset, length, `${value.toString(8).padStart(length - 1, "0")}\0`); +} + +function updateTarChecksum(header: Buffer): void { + header.fill(0x20, 148, 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + writeString(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `); +} + +export function tarBytes(params: { + name: string; + body?: Buffer; + declaredSize?: number; + sizeEncoding?: TarSizeEncoding; + truncateTo?: number; +}): Buffer { + const body = params.body ?? Buffer.alloc(0); + const declaredSize = params.declaredSize ?? body.byteLength; + const encoding = params.sizeEncoding ?? "octal"; + const header = Buffer.alloc(512); + writeString(header, 0, 100, params.name); + writeOctal(header, 100, 8, 0o644); + writeOctal(header, 108, 8, 0); + writeOctal(header, 116, 8, 0); + writeOctal(header, 136, 12, 0); + writeString(header, 156, 1, "0"); + writeString(header, 257, 6, "ustar\0"); + writeString(header, 263, 2, "00"); + + if (encoding === "octal") { + writeOctal(header, 124, 12, declaredSize); + } else if (encoding === "octal-max") { + writeString(header, 124, 12, "77777777777\0"); + } else if (encoding === "base256") { + header[124] = 0x80; + header.writeBigUInt64BE(BigInt(declaredSize), 128); + } else if (encoding === "base256-u64-max") { + header[124] = 0x80; + header.fill(0xff, 128, 136); + } else if (encoding === "base256-high-bits") { + header[124] = 0x80; + header[125] = 1; + header.writeBigUInt64BE(BigInt(declaredSize), 128); + } else if (encoding === "base256-negative") { + header.fill(0xff, 124, 136); + } else { + header.fill(0x38, 124, 136); + } + updateTarChecksum(header); + + const archive = Buffer.concat([ + header, + body, + Buffer.alloc((512 - (body.byteLength % 512)) % 512), + Buffer.alloc(1024), + ]); + return params.truncateTo === undefined ? archive : archive.subarray(0, params.truncateTo); +} + +export function tarEntriesBytes( + entries: ReadonlyArray<{ name: string; body?: Buffer }>, +): Buffer { + return Buffer.concat([ + ...entries.map((entry) => tarBytes(entry).subarray(0, -1024)), + Buffer.alloc(1024), + ]); +} + +export async function zipBytes(params: { + names: readonly string[]; + body?: Buffer; + truncateBy?: number; + declaredSizeDelta?: number; +}): Promise { + const zip = new JSZip(); + for (const [index, name] of params.names.entries()) { + zip.file(name, params.body ?? Buffer.from(`entry-${index}`)); + } + const bytes = await zip.generateAsync({ type: "nodebuffer", compression: "STORE" }); + if (params.declaredSizeDelta) { + const local = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04])); + const central = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + if (local >= 0 && central >= 0) { + const size = bytes.readUInt32LE(local + 22) + params.declaredSizeDelta; + bytes.writeUInt32LE(size, local + 22); + bytes.writeUInt32LE(size, central + 24); + } + } + return params.truncateBy + ? bytes.subarray(0, Math.max(0, bytes.byteLength - params.truncateBy)) + : bytes; +} diff --git a/test/helpers/property.ts b/test/helpers/property.ts new file mode 100644 index 0000000..16fb22b --- /dev/null +++ b/test/helpers/property.ts @@ -0,0 +1,139 @@ +import fc, { type Parameters } from "fast-check"; + +export const PROPERTY_SEED = 0x5eed_c0de; + +const WINDOWS_RESERVED_ARCHIVE_NAMES = [ + "CON", + "PRN", + "AUX", + "NUL", + ...Array.from({ length: 9 }, (_, index) => `COM${index + 1}`), + ...Array.from({ length: 9 }, (_, index) => `LPT${index + 1}`), +] as const; + +export const WINDOWS_ARCHIVE_PORTABILITY_NAMES = [ + ...WINDOWS_RESERVED_ARCHIVE_NAMES.flatMap((name) => [name, `${name}.txt`]), + "NUL.", + "NUL ", + "CON.txt.", + "CON.txt ", + "COM1.", + "COM1 ", + "LPT9.txt.", + "LPT9.txt ", + "file:stream", + "file.txt:stream", +] as const; + +export const windowsArchivePortabilityName = fc.constantFrom( + ...WINDOWS_ARCHIVE_PORTABILITY_NAMES, +); + +export function propertyParameters(numRuns: number): Parameters { + return { + numRuns, + seed: PROPERTY_SEED, + verbose: 2, + }; +} + +const PATH_TOKENS = [ + "", + ".", + "..", + "...", + "/", + "\\", + "//", + "\\\\", + "C:", + "C:relative", + "c:relative", + "//server/share", + "\\\\server\\share", + "%2e%2e", + "%252e%252e%252f", + " ", + "\t", + "\0", + "é", + "e\u0301", + "a.", + "a ", + "CON", + "nul.txt", + "safe", +] as const; + +const tokenPath = fc + .array(fc.constantFrom(...PATH_TOKENS), { minLength: 0, maxLength: 10 }) + .map((tokens) => tokens.join("")); + +export const adversarialPath = fc.oneof( + { depthIdentifier: "path-kind" }, + tokenPath, + windowsArchivePortabilityName, + fc.constantFrom( + "", + ".", + "./", + "../escape", + "nested/../../escape", + "nested\\..\\..\\escape", + "/absolute", + "//server/share/file", + "\\\\server\\share\\file", + "C:\\absolute", + "C:relative", + "nested/C:relative", + "safe//child", + "safe/./child", + "safe/../child", + "safe/child/", + " safe/child", + "safe/child ", + "safe./child", + "safe/child.", + "café/value", + "cafe\u0301/value", + `long/${"x".repeat(256)}`, + "nul\0byte", + ), +); + +const safePart = fc + .array(fc.constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789_-"), { + minLength: 1, + maxLength: 12, + }) + .map((characters) => characters.join("")); + +export const aliasingPathPair = fc.oneof( + fc.tuple(safePart, safePart).map(([parent, child]) => [ + `${parent}/${child}`, + `${parent}//${child}`, + ] as const), + fc.tuple(safePart, safePart).map(([parent, child]) => [ + `${parent}/${child}`, + `${parent}/./${child}`, + ] as const), + fc.tuple(safePart, safePart).map(([parent, child]) => [ + `${parent}/${child}`, + `./${parent}/${child}`, + ] as const), + safePart.map((part) => [`${part}/café`, `${part}/cafe\u0301`] as const), + safePart.map((part) => [`${part}/value`, `${part}/value.`] as const), + safePart.map((part) => [`${part}/value`, `${part}/value `] as const), +); + +export const archiveAliasingPathPair = fc.oneof( + fc.tuple(safePart, safePart).map(([parent, child]) => [ + `${parent}/${child}`, + `${parent}//${child}`, + ] as const), + fc.tuple(safePart, safePart).map(([parent, child]) => [ + `${parent}/${child}`, + `${parent}/./${child}`, + ] as const), + safePart.map((part) => [`${part}/café`, `${part}/cafe\u0301`] as const), +);