diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c59f6df..324782a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,5 +34,8 @@ jobs: - name: Test run: npm test + - name: Fuzz smoke + run: npm run fuzz:smoke + - name: Build run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index c577c0c..d5b932b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes will be documented here. The project intends to follow seman ### Added +- Reproducible fast-check properties for arbitrary-byte inspection, parser mutations, subview isolation, limits, cleaners, and fail-closed verification. +- Finite public-API fuzz harness with explicit seed, iteration, input-size, target, and counterexample-path replay controls. +- Deterministic 250-iteration fuzz smoke coverage in CI and documented regression-promotion workflow. + - Deterministic malformed-input corpus for generic bytes, JPEG, WebP, PNG, and shared TIFF corruption families. - Cross-format invariants for deterministic inspection and cleaning, native-exception containment, fail-closed operations, input immutability, and cheap limit stress. - Testing and fuzz-readiness guidance with future property and fuzz targets; random fuzzing remains outside normal CI. diff --git a/README.md b/README.md index cede10e..7b1365a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ GPS rational components remain exact numerator/denominator pairs; decimal coordi `verifyMetadata` supports `absent`, `present`, or `ignore` expectations. Concepts not implemented for a format produce no check rather than implying an exhaustive search. Verification fails closed if metadata reporting reaches its configured entry limit. Single-file verification observes supported container presence or absence and cannot prove provenance or pixel privacy. +## Generated testing + +The deterministic corpus is supplemented by fixed-seed property tests and a finite public-API fuzz harness. CI runs only the bounded smoke profile; extended local runs remain explicitly iteration- and input-size-limited. See the [testing model](docs/testing.md) for replay and regression-promotion commands. + ## Security philosophy Every byte is untrusted. All offsets are interpreted within bounded views, traversal is iterative and limited, and malformed structures fail without unchecked access. PNG image data and compressed metadata are never inflated. Unknown JPEG APP segments, WebP chunks, and PNG ancillary chunks are preserved by default. See the [security model](docs/security-model.md), [architecture](docs/architecture.md), [testing model](docs/testing.md), and [cleaning policy](docs/cleaning-policy.md). diff --git a/docs/architecture.md b/docs/architecture.md index b4bfad2..5f5a49b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,8 +39,8 @@ bounded binary primitives → format fixture tests → deterministic malformed corpus → cross-format invariants - → future property tests - → future fuzzing + → reproducible property tests + → finite public-API fuzz harness ``` -Production parsers remain internal. Public operations are the preferred cross-format targets; the shared TIFF parser is directly callable only from test code. See [testing and fuzz readiness](testing.md). +Production parsers remain internal. Public operations are the fuzz targets; JPEG, WebP, PNG, and shared TIFF internals are imported only by property tests. Generated-test code and `fast-check` remain outside `src`, `dist`, and the npm payload. See [testing and fuzz readiness](testing.md). diff --git a/docs/security-model.md b/docs/security-model.md index d5d9e96..746df90 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -31,4 +31,4 @@ Core production code has zero runtime dependencies and no network, analytics, te Malformed input is part of the expected threat model. A small deterministic corpus covers representative generic, JPEG, WebP, PNG, and TIFF structural corruption families, including truncation, corrupt lengths and offsets, cycles, and configured work limits. Parser loops must advance or terminate, and unsafe outer container boundaries cause typed fail-closed cleaning and verification errors before output is produced. -The corpus is regression coverage, not proof of parser correctness. No decompression is implemented, so decompression bombs are outside the current attack surface and `maxDecompressedBytes` remains reserved. Reproducible property testing and dedicated fuzzing are planned future layers; random fuzzing is not part of normal CI. See [testing and fuzz readiness](testing.md). +The corpus, fixed-seed property tests, and finite public-API fuzz harness improve regression confidence but are not proof of parser correctness or security. Current protections still come from bounded readers, checked arithmetic, explicit traversal limits, deterministic parser progress, and fail-closed cleaning. No decompression is implemented, so decompression bombs are outside the current attack surface and `maxDecompressedBytes` remains reserved. Normal CI runs only a bounded deterministic fuzz smoke profile; extended local runs remain finite. See [testing and fuzz readiness](testing.md). diff --git a/docs/testing.md b/docs/testing.md index e756fca..731c539 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2,50 +2,101 @@ The test suite uses deterministic TypeScript fixture builders for JPEG markers, WebP RIFF chunks, PNG chunks and CRCs, and TIFF IFD structures. Fixtures stay -small, readable, and cheap; binary files are used only when they would be -clearer than the builder expression. +small and reviewable; binary files are used only when clearer than builder code. ## Test layers ```text -bounded binary primitives +bounded binary unit tests → format fixtures and parser algorithms → deterministic malformed corpus - → cross-format public API invariants - → future reproducible property tests - → future fuzz targets + → cross-format invariants + → reproducible property tests + → finite fuzz harness ``` -The malformed corpus covers generic byte patterns plus representative JPEG, -WebP, PNG, and shared TIFF truncation, corrupt length, invalid offset, cycle, -and configured-limit families. Corpus assertions focus on stable contracts: -format/status, relevant diagnostic codes, deterministic results, caller-input -immutability, typed fail-closed cleaning and verification, and safe removal of -bounded malformed metadata. They intentionally avoid full-report snapshots and -timing thresholds. - -Security-limit tests use tiny inputs with small custom values for input, -segment, chunk, IFD entry/depth, metadata entry, string, and diagnostic limits. -`maxDecompressedBytes` remains unused because the library performs no -decompression. - -## Future property and fuzz targets - -Likely targets are: - -- `inspectMetadata(bytes)` through the public API; -- bounded JPEG, WebP, and PNG parser entry points in test/fuzz builds; -- the bounded TIFF parser as a test-only internal target; -- `cleanMetadata(bytes, policy)` through the public API. - -Strong future properties include containment of native bounds exceptions, -deterministic inspection and cleaning, re-inspectable clean output, Privacy -Clean idempotency, input immutability, removal-only output sizing, preservation -of unknown structures, and default ICC preservation. WebP is permitted to patch -the RIFF size and applicable VP8X metadata flags. - -No random fuzzing runs in normal CI, and no property/fuzz dependency is -currently installed. A future sprint can add reproducible seeded property tests -or dedicated fuzz harnesses if their coverage benefit justifies the development -dependency and CI cost. The deterministic corpus is regression coverage, not a -proof of parser correctness. +The 67-case malformed corpus covers named generic, JPEG, WebP, PNG, and shared +TIFF corruption families. It remains the permanent regression foundation. +Property tests supplement it with generated variations, while the fuzz harness +repeatedly explores the public API. Generated iterations are not counted as +individual Vitest tests. + +## Property tests + +Property tests use the single dev-only `fast-check` dependency for bounded +arbitraries, deterministic seed/path replay, and automatic shrinking. They run +as part of `npm test` and can be invoked alone: + +```text +npm test -- tests/property +``` + +Defaults are fixed and finite: + +- parser/inspection properties: 64 runs each; +- cleaner/verification properties: 48 runs each; +- infrastructure smoke property: 16 runs; +- generated property input: at most 1,024 bytes; +- default seed: `0x5ec00009`. + +`PROPERTY_SEED`, `PROPERTY_RUNS`, and `PROPERTY_PATH` override those settings. +For example, in PowerShell: + +```powershell +$env:PROPERTY_SEED="1589641225" +$env:PROPERTY_PATH="0:0:1" +npm test -- tests/property +``` + +On failure, fast-check reports the seed, counterexample path, and shrunk input. +Use both seed and path to replay the minimized counterexample. + +## Fuzz harness + +The harness builds the package and exercises only the public +`inspectMetadata`, `cleanMetadata`, and `verifyMetadata` API targets. + +```text +npm run fuzz:smoke +npm run fuzz -- --seed 9 --runs 10000 --max-bytes 4096 --target all +``` + +`fuzz:smoke` is the deterministic CI profile: seed `20260825`, 250 total +iterations, and a 512-byte input maximum. `fuzz` is the finite local profile: +seed `0x5ec00009`, 5,000 total iterations, and a 4,096-byte maximum. Supported +targets are `all`, `inspect`, `clean`, and `verify`. CLI options may also be set +with `FUZZ_SEED`, `FUZZ_RUNS`, `FUZZ_MAX_BYTES`, `FUZZ_TARGET`, and `FUZZ_PATH`. +The maximum permitted generated input is 4,096 bytes. + +A failure prints its selected target, configured target, seed, run count, +counterexample path, shrink count, and bounded hexadecimal input. It also prints +an exact replay command. Runs are iteration-bounded, never elapsed-time or +infinite campaigns. + +## Regression promotion + +A generated failure is handled as follows: + +```text +replay seed and path + → understand the root cause + → minimize with built-in shrinking + → fix the production defect + → promote the smallest meaningful input to a named corpus/regression test + → rerun property and fuzz coverage +``` + +A fixed seed alone is not a permanent regression test. The minimal semantic case +must be persisted so later generator changes cannot hide it. + +## Security limits and scope + +Security tests use tiny custom input, segment, chunk, IFD entry/depth, metadata +entry, string, and diagnostic limits. `maxDecompressedBytes` remains unused +because the library performs no decompression. JPEG scan data, WebP image chunks, +and PNG IDAT remain opaque; generated testing does not add codecs or decoding. + +Property and fuzz testing improve regression confidence but do not prove parser +correctness or security. The primary protections remain bounded readers, checked +arithmetic, explicit traversal limits, deterministic parser progress, and +fail-closed cleaning. diff --git a/package-lock.json b/package-lock.json index ac2afd9..eda630e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "eslint": "^10.9.0", + "fast-check": "^4.9.0", "prettier": "^3.9.6", "tsup": "^8.5.1", "typescript": "5.9.3", @@ -2248,6 +2249,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3137,6 +3161,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/package.json b/package.json index a9e5ae6..f41fd66 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "fuzz:smoke": "npm run build --silent && node scripts/fuzz.mjs --seed 20260825 --runs 250 --max-bytes 512", + "fuzz": "npm run build --silent && node scripts/fuzz.mjs", "typecheck": "tsc --noEmit" }, "engines": { @@ -35,6 +37,7 @@ "@eslint/js": "^10.0.1", "@types/node": "^24.13.3", "eslint": "^10.9.0", + "fast-check": "^4.9.0", "prettier": "^3.9.6", "tsup": "^8.5.1", "typescript": "5.9.3", diff --git a/scripts/fuzz.mjs b/scripts/fuzz.mjs new file mode 100644 index 0000000..5a71282 --- /dev/null +++ b/scripts/fuzz.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; + +import fc from "fast-check"; + +import { + cleanMetadata, + inspectMetadata, + SecureMetadataError, + verifyMetadata, +} from "../dist/index.js"; + +const DEFAULT_SEED = 0x5ec00009; +const DEFAULT_RUNS = 5_000; +const DEFAULT_MAX_BYTES = 4_096; +const TARGETS = ["inspect", "clean", "verify"]; + +function option(name) { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? undefined : process.argv[index + 1]; +} + +function integerOption(name, environmentName, fallback, minimum, maximum) { + const raw = option(name) ?? process.env[environmentName]; + if (raw === undefined) { + return fallback; + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error( + `${name} must be an integer from ${String(minimum)} to ${String(maximum)}; received ${raw}.`, + ); + } + return value; +} + +const seed = integerOption( + "seed", + "FUZZ_SEED", + DEFAULT_SEED, + -0x8000_0000, + 0x7fff_ffff, +); +const runs = integerOption("runs", "FUZZ_RUNS", DEFAULT_RUNS, 1, 1_000_000); +const maxBytes = integerOption( + "max-bytes", + "FUZZ_MAX_BYTES", + DEFAULT_MAX_BYTES, + 0, + DEFAULT_MAX_BYTES, +); +const target = option("target") ?? process.env.FUZZ_TARGET ?? "all"; +const path = option("path") ?? process.env.FUZZ_PATH; +if (target !== "all" && !TARGETS.includes(target)) { + throw new Error( + `target must be all, ${TARGETS.join(", ")}; received ${target}.`, + ); +} + +const targetArbitrary = + target === "all" ? fc.constantFrom(...TARGETS) : fc.constant(target); +const inputArbitrary = fc.uint8Array({ maxLength: maxBytes }); + +function runTarget(selectedTarget, input) { + const before = Uint8Array.from(input); + + if (selectedTarget === "inspect") { + const first = inspectMetadata(input); + const second = inspectMetadata(input); + assert.deepStrictEqual(first, second); + } else if (selectedTarget === "clean") { + try { + const first = cleanMetadata(input); + const second = cleanMetadata(input); + assert.deepStrictEqual(first.output, second.output); + assert.ok(first.output.byteLength <= input.byteLength); + assert.notStrictEqual( + inspectMetadata(first.output).inspectionStatus, + "container-partial", + ); + } catch (error) { + assert.ok(error instanceof SecureMetadataError); + } + } else { + try { + const result = verifyMetadata(input); + if (result.report.metadataTruncated === true) { + assert.notStrictEqual(result.valid, true); + } + } catch (error) { + assert.ok(error instanceof SecureMetadataError); + } + } + + assert.deepStrictEqual(input, before); +} + +const property = fc.property( + fc.record({ target: targetArbitrary, input: inputArbitrary }), + ({ target: selectedTarget, input }) => runTarget(selectedTarget, input), +); +const details = fc.check(property, { + seed, + numRuns: runs, + ...(path === undefined || path.length === 0 ? {} : { path }), +}); + +if (details.failed) { + const counterexample = details.counterexample?.[0]; + const input = counterexample?.input; + const failure = { + target: counterexample?.target ?? target, + configuredTarget: target, + seed: details.seed, + path: details.counterexamplePath, + run: details.numRuns, + shrinks: details.numShrinks, + inputHex: + input instanceof Uint8Array + ? Buffer.from(input).toString("hex") + : undefined, + error: + details.errorInstance instanceof Error + ? details.errorInstance.message + : String(details.errorInstance), + }; + console.error(JSON.stringify(failure, null, 2)); + console.error( + `Replay with: npm run fuzz -- --seed ${String(details.seed)} --path ${details.counterexamplePath} --runs 1 --max-bytes ${String(maxBytes)} --target ${String(failure.configuredTarget)}`, + ); + console.error(`fast-check replay path: ${details.counterexamplePath}`); + process.exitCode = 1; +} else { + console.log( + JSON.stringify({ + status: "passed", + target, + seed: details.seed, + runs: details.numRuns, + maxBytes, + }), + ); +} diff --git a/tests/property/cleaner-properties.test.ts b/tests/property/cleaner-properties.test.ts new file mode 100644 index 0000000..7bae29d --- /dev/null +++ b/tests/property/cleaner-properties.test.ts @@ -0,0 +1,238 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + cleanMetadata, + inspectMetadata, + SecureMetadataError, + verifyMetadata, + type CleaningPolicy, +} from "../../src/index.js"; +import { + concat as jpegConcat, + EXIF, + ICC, + jpeg, + MARKER, + segment, +} from "../helpers/jpeg-builder.js"; +import { chunk as pngChunk, png, textChunk } from "../helpers/png-builder.js"; +import { chunk as webpChunk, vp8x, webp } from "../helpers/webp-builder.js"; +import { CLEANER_PROPERTY_RUNS, propertyParameters } from "./config.js"; + +const POLICY = fc.constantFrom( + {}, + { removeExif: false }, + { preserveIcc: false }, + { removeExif: false, preserveIcc: false }, +); + +function assertCommonCleanerProperties( + input: Uint8Array, + policy: CleaningPolicy, + expected: Uint8Array, +): void { + const before = Uint8Array.from(input); + const first = cleanMetadata(input, policy); + const second = cleanMetadata(input, policy); + const idempotent = cleanMetadata(first.output, policy); + + expect(first.output).toEqual(expected); + expect(second.output).toEqual(first.output); + expect(idempotent.output).toEqual(first.output); + expect(first.output.byteLength).toBeLessThanOrEqual(input.byteLength); + expect(inspectMetadata(first.output).inspectionStatus).not.toBe( + "container-partial", + ); + expect(input).toEqual(before); +} + +describe("cleaner properties", () => { + it("preserves JPEG scan, ICC, and unknown bytes under bounded policies", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 24 }), + fc.uint8Array({ maxLength: 24 }), + fc.uint8Array({ maxLength: 24 }), + fc + .array(fc.integer({ min: 0, max: 0xfe }), { maxLength: 32 }) + .map((values) => Uint8Array.from(values)), + POLICY, + (iccPayload, unknownPayload, exifPayload, scan, policy) => { + const icc = segment(MARKER.APP2, jpegConcat(ICC, iccPayload)); + const unknown = segment(0xe3, unknownPayload); + const exif = segment(MARKER.APP1, jpegConcat(EXIF, exifPayload)); + const sos = segment(MARKER.SOS); + const input = jpeg(icc, unknown, exif, sos, scan); + const expected = jpeg( + ...(policy.preserveIcc === false ? [] : [icc]), + unknown, + ...(policy.removeExif === false ? [exif] : []), + sos, + scan, + ); + + assertCommonCleanerProperties(input, policy, expected); + }, + ), + propertyParameters({ numRuns: CLEANER_PROPERTY_RUNS }), + ); + }); + + it("repairs only WebP RIFF/VP8X metadata bookkeeping under bounded policies", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 24 }), + fc.uint8Array({ maxLength: 24 }), + fc.uint8Array({ maxLength: 24 }), + fc.uint8Array({ maxLength: 24 }), + fc.integer({ min: 0, max: 0xff }), + fc.constantFrom(0, 0x10, 0x02, 0x12), + POLICY, + ( + iccPayload, + unknownPayload, + exifPayload, + imagePayload, + paddingByte, + unrelatedFlags, + policy, + ) => { + const icc = webpChunk("ICCP", iccPayload, paddingByte); + const unknown = webpChunk("zzZZ", unknownPayload, paddingByte); + const exif = webpChunk("EXIF", exifPayload, paddingByte); + const xmp = webpChunk("XMP ", Uint8Array.of(1), paddingByte); + const image = webpChunk("VP8 ", imagePayload, paddingByte); + const input = webp([ + vp8x(unrelatedFlags | 0x2c), + icc, + unknown, + exif, + xmp, + image, + ]); + const keepIcc = policy.preserveIcc !== false; + const keepExif = policy.removeExif === false; + const expectedFlags = + unrelatedFlags | (keepIcc ? 0x20 : 0) | (keepExif ? 0x08 : 0); + const expected = webp([ + vp8x(expectedFlags), + ...(keepIcc ? [icc] : []), + unknown, + ...(keepExif ? [exif] : []), + image, + ]); + + assertCommonCleanerProperties(input, policy, expected); + expect( + new DataView(expected.buffer, expected.byteOffset).getUint32( + 4, + true, + ) + 8, + ).toBe(expected.byteLength); + }, + ), + propertyParameters({ numRuns: CLEANER_PROPERTY_RUNS }), + ); + }); + + it("preserves PNG IDAT, unknown chunks, and retained CRC bytes", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 24 }), + fc.uint8Array({ maxLength: 24 }), + fc.uint8Array({ maxLength: 24 }), + fc.integer({ min: 0, max: 0xffff_ffff }), + POLICY, + (iccPayload, unknownPayload, imagePayload, imageCrc, policy) => { + const icc = pngChunk("iCCP", iccPayload); + const unknown = pngChunk("vpAg", unknownPayload); + const exif = pngChunk("eXIf", Uint8Array.of(0x49)); + const text = textChunk("Author"); + const image = pngChunk("IDAT", imagePayload, imageCrc); + const iend = pngChunk("IEND"); + const input = png([icc, unknown, exif, text, image, iend]); + const expected = png([ + ...(policy.preserveIcc === false ? [] : [icc]), + unknown, + ...(policy.removeExif === false ? [exif] : []), + image, + iend, + ]); + + assertCommonCleanerProperties(input, policy, expected); + }, + ), + propertyParameters({ numRuns: CLEANER_PROPERTY_RUNS }), + ); + }); + + it("preserves generated ICC payloads under the default policy", () => { + fc.assert( + fc.property( + fc.constantFrom("jpeg" as const, "webp" as const, "png" as const), + fc.uint8Array({ maxLength: 32 }), + (format, payload) => { + const input = + format === "jpeg" + ? jpeg(segment(MARKER.APP2, jpegConcat(ICC, payload))) + : format === "webp" + ? webp([webpChunk("ICCP", payload)]) + : png([pngChunk("iCCP", payload), pngChunk("IEND")]); + const result = cleanMetadata(input); + + expect(result.report.entries).toContainEqual( + expect.objectContaining({ namespace: "icc" }), + ); + }, + ), + propertyParameters({ numRuns: CLEANER_PROPERTY_RUNS }), + ); + }); +}); + +describe("verification properties", () => { + it("never treats incomplete or metadata-truncated observations as valid", () => { + fc.assert( + fc.property( + fc.constantFrom("jpeg" as const, "webp" as const, "png" as const), + fc.boolean(), + (format, truncateMetadata) => { + if (!truncateMetadata) { + const incomplete = + format === "jpeg" + ? jpeg(segment(MARKER.APP1, EXIF)).slice(0, -2) + : format === "webp" + ? webp([webpChunk("EXIF")]).slice(0, -1) + : png([pngChunk("IDAT")]); + try { + const result = verifyMetadata(incomplete); + expect(result.valid).not.toBe(true); + } catch (error) { + expect(error).toBeInstanceOf(SecureMetadataError); + } + return; + } + + const metadataRich = + format === "jpeg" + ? jpeg(segment(MARKER.APP1, EXIF), segment(MARKER.APP2, ICC)) + : format === "webp" + ? webp([webpChunk("EXIF"), webpChunk("XMP ")]) + : png([ + textChunk("Author"), + pngChunk("tIME"), + pngChunk("IEND"), + ]); + const result = verifyMetadata(metadataRich, { + limits: { maxMetadataEntries: 1 }, + }); + + expect(result.report.metadataTruncated).toBe(true); + expect(result.valid).toBe(false); + }, + ), + propertyParameters({ numRuns: CLEANER_PROPERTY_RUNS }), + ); + }); +}); diff --git a/tests/property/config.ts b/tests/property/config.ts new file mode 100644 index 0000000..3fb7a51 --- /dev/null +++ b/tests/property/config.ts @@ -0,0 +1,33 @@ +import type { Parameters } from "fast-check"; + +export const PROPERTY_SEED = 0x5ec0_0009; +export const PROPERTY_RUNS = 64; +export const CLEANER_PROPERTY_RUNS = 48; +export const MAX_PROPERTY_BYTES = 1_024; + +function environmentInteger(name: string): number | undefined { + const raw = process.env[name]; + if (raw === undefined) { + return undefined; + } + const value = Number(raw); + if (!Number.isSafeInteger(value)) { + throw new Error(`${name} must be a safe integer; received ${raw}.`); + } + return value; +} + +export function propertyParameters( + defaults: { + readonly seed?: number; + readonly numRuns?: number; + } = {}, +): Parameters { + const path = process.env.PROPERTY_PATH; + return { + seed: environmentInteger("PROPERTY_SEED") ?? defaults.seed ?? PROPERTY_SEED, + numRuns: + environmentInteger("PROPERTY_RUNS") ?? defaults.numRuns ?? PROPERTY_RUNS, + ...(path === undefined || path.length === 0 ? {} : { path }), + }; +} diff --git a/tests/property/infrastructure.test.ts b/tests/property/infrastructure.test.ts new file mode 100644 index 0000000..f9db418 --- /dev/null +++ b/tests/property/infrastructure.test.ts @@ -0,0 +1,20 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { propertyParameters } from "./config.js"; + +describe("reproducible property infrastructure", () => { + it("runs a bounded seeded property with shrinkable values", () => { + let runs = 0; + + fc.assert( + fc.property(fc.integer(), (value) => { + runs += 1; + expect(Number.isSafeInteger(value)).toBe(true); + }), + propertyParameters({ seed: 9, numRuns: 16 }), + ); + + expect(runs).toBe(16); + }); +}); diff --git a/tests/property/parser-properties.test.ts b/tests/property/parser-properties.test.ts new file mode 100644 index 0000000..4cb120e --- /dev/null +++ b/tests/property/parser-properties.test.ts @@ -0,0 +1,365 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { ByteReader } from "../../src/core/binary/byte-reader.js"; +import { TIFF_FIELD_TYPE } from "../../src/exif/field-types.js"; +import { TIFF_TAG } from "../../src/exif/tags.js"; +import { parseTiff, type TiffParseLimits } from "../../src/exif/tiff.js"; +import { + cleanMetadata, + inspectMetadata, + SecureMetadataError, +} from "../../src/index.js"; +import { parseJpeg } from "../../src/jpeg/parser.js"; +import { parsePng } from "../../src/png/parser.js"; +import { parseWebP } from "../../src/webp/parser.js"; +import { + concat as jpegConcat, + EXIF, + jpeg, + MARKER, + segment, +} from "../helpers/jpeg-builder.js"; +import { chunk as pngChunk, png, textChunk } from "../helpers/png-builder.js"; +import { TiffBuilder } from "../helpers/tiff-builder.js"; +import { + chunk as webpChunk, + webp, + withRiffSize, +} from "../helpers/webp-builder.js"; +import { MAX_PROPERTY_BYTES, propertyParameters } from "./config.js"; + +function expectTypedFailure(operation: () => unknown): void { + try { + operation(); + expect.fail("Expected a typed library failure."); + } catch (error) { + expect(error).toBeInstanceOf(SecureMetadataError); + expect(error).not.toBeInstanceOf(RangeError); + expect(error).not.toBeInstanceOf(TypeError); + } +} + +function mutateJpeg(payload: Uint8Array, mutation: number): Uint8Array { + const input = jpeg(segment(MARKER.APP1, jpegConcat(EXIF, payload))); + if (mutation === 0) { + return input.slice(0, -2); + } + const output = Uint8Array.from(input); + if (mutation === 1) { + output[4] = 0xff; + output[5] = 0xff; + } else { + return input.slice(0, 5); + } + return output; +} + +function mutateWebP(payload: Uint8Array, mutation: number): Uint8Array { + const input = webp([webpChunk("EXIF", payload)]); + if (mutation === 0) { + return withRiffSize(input, 0xffff_ffff); + } + if (mutation === 1) { + return input.slice(0, -1); + } + const output = Uint8Array.from(input); + output.fill(0xff, 16, 20); + return output; +} + +function mutatePng(payload: Uint8Array, mutation: number): Uint8Array { + const input = png([pngChunk("IDAT", payload), pngChunk("IEND")]); + if (mutation === 0) { + return input.slice(0, -12); + } + if (mutation === 1) { + const output = Uint8Array.from(input); + output.fill(0xff, 8, 12); + return output; + } + return input.slice(0, -2); +} + +const TIFF_LIMITS: TiffParseLimits = { + maxIfdEntries: 16, + maxIfdDepth: 4, + maxMetadataEntries: 16, + maxStringBytes: 128, + maxDiagnostics: 4, +}; + +function malformedTiff( + order: "little" | "big", + mode: + "offset" | "count" | "pointer" | "cycle" | "rational" | "type" | "maker", + makerPayload: Uint8Array, +): Uint8Array { + if (mode === "cycle") { + return new TiffBuilder(order).ifd(8, [], 8).finish(); + } + if (mode === "pointer") { + return new TiffBuilder(order) + .ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 900, + }, + ]) + .finish(); + } + if (mode === "rational" || mode === "maker") { + const builder = new TiffBuilder(order).ifd(8, [ + { + tag: TIFF_TAG.EXIF_IFD_POINTER, + type: TIFF_FIELD_TYPE.LONG, + count: 1, + value: 40, + }, + ]); + if (mode === "maker") { + return builder + .ifd(40, [ + { + tag: TIFF_TAG.MAKER_NOTE, + type: TIFF_FIELD_TYPE.UNDEFINED, + count: makerPayload.byteLength, + valueOffset: 100, + }, + ]) + .bytes(100, makerPayload) + .finish(); + } + return builder + .ifd(40, [ + { + tag: TIFF_TAG.EXPOSURE_TIME, + type: TIFF_FIELD_TYPE.RATIONAL, + count: 1, + valueOffset: 100, + }, + ]) + .rational(100, [[1, 0]]) + .finish(); + } + if (mode === "type") { + return new TiffBuilder(order) + .ifd(8, [{ tag: TIFF_TAG.MAKE, type: 99, count: 1 }]) + .finish(); + } + return new TiffBuilder(order) + .ifd(8, [ + { + tag: TIFF_TAG.MAKE, + type: TIFF_FIELD_TYPE.ASCII, + count: mode === "count" ? 0xffff_ffff : 8, + valueOffset: mode === "count" ? 100 : 900, + }, + ]) + .finish(); +} +describe("inspection properties", () => { + it("contains arbitrary-byte failures and preserves deterministic bounded reports", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: MAX_PROPERTY_BYTES }), + fc.integer({ min: 0, max: 4 }), + fc.integer({ min: 0, max: 4 }), + (input, maxDiagnostics, maxMetadataEntries) => { + const before = Uint8Array.from(input); + const options = { + limits: { + maxInputBytes: MAX_PROPERTY_BYTES, + maxDiagnostics, + maxMetadataEntries, + }, + }; + const first = inspectMetadata(input, options); + const second = inspectMetadata(input, options); + + expect(first).toEqual(second); + expect(first.diagnostics.length).toBeLessThanOrEqual(maxDiagnostics); + expect(first.entries.length).toBeLessThanOrEqual(maxMetadataEntries); + expect(input).toEqual(before); + }, + ), + propertyParameters(), + ); + }); + + it("isolates arbitrary Uint8Array subviews from prefix and suffix bytes", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 16 }), + fc.uint8Array({ maxLength: 256 }), + fc.uint8Array({ maxLength: 16 }), + (prefix, payload, suffix) => { + const backing = new Uint8Array( + prefix.byteLength + payload.byteLength + suffix.byteLength, + ); + backing.set(prefix, 0); + backing.set(payload, prefix.byteLength); + backing.set(suffix, prefix.byteLength + payload.byteLength); + const view = new Uint8Array( + backing.buffer, + prefix.byteLength, + payload.byteLength, + ); + + expect(inspectMetadata(view)).toEqual( + inspectMetadata(Uint8Array.from(payload)), + ); + }, + ), + propertyParameters(), + ); + }); + + it("keeps generated metadata reports within maxMetadataEntries", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 8 }), + fc.integer({ min: 0, max: 7 }), + (entryCount, requestedLimit) => { + const maxMetadataEntries = Math.min(requestedLimit, entryCount - 1); + const input = png([ + ...Array.from({ length: entryCount }, (_, index) => + textChunk(`K${String(index)}`), + ), + pngChunk("IEND"), + ]); + const report = inspectMetadata(input, { + limits: { maxMetadataEntries }, + }); + + expect(report.entries).toHaveLength(maxMetadataEntries); + expect(report.metadataTruncated).toBe(true); + }, + ), + propertyParameters(), + ); + }); +}); + +describe("format parser properties", () => { + it("rejects generated incomplete JPEG structures without partial cleaning", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 64 }), + fc.integer({ min: 0, max: 2 }), + fc.integer({ min: 0, max: 3 }), + (payload, mutation, maxDiagnostics) => { + const input = mutateJpeg(payload, mutation); + const before = Uint8Array.from(input); + const first = parseJpeg(new ByteReader(input), 32, maxDiagnostics); + const second = parseJpeg(new ByteReader(input), 32, maxDiagnostics); + + expect(first).toEqual(second); + expect(first.complete).toBe(false); + expect(first.diagnostics.length).toBeLessThanOrEqual(maxDiagnostics); + expectTypedFailure(() => cleanMetadata(input)); + expect(input).toEqual(before); + }, + ), + propertyParameters(), + ); + }); + + it("rejects generated incomplete WebP structures without partial cleaning", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 64 }), + fc.integer({ min: 0, max: 2 }), + fc.integer({ min: 0, max: 3 }), + (payload, mutation, maxDiagnostics) => { + const input = mutateWebP(payload, mutation); + const before = Uint8Array.from(input); + const first = parseWebP(new ByteReader(input), 32, maxDiagnostics); + const second = parseWebP(new ByteReader(input), 32, maxDiagnostics); + + expect(first).toEqual(second); + expect(first.complete).toBe(false); + expect(first.diagnostics.length).toBeLessThanOrEqual(maxDiagnostics); + expectTypedFailure(() => cleanMetadata(input)); + expect(input).toEqual(before); + }, + ), + propertyParameters(), + ); + }); + + it("rejects generated incomplete PNG structures without partial cleaning", () => { + fc.assert( + fc.property( + fc.uint8Array({ maxLength: 64 }), + fc.integer({ min: 0, max: 2 }), + fc.integer({ min: 0, max: 3 }), + (payload, mutation, maxDiagnostics) => { + const input = mutatePng(payload, mutation); + const before = Uint8Array.from(input); + const first = parsePng( + new ByteReader(input), + 32, + 128, + maxDiagnostics, + ); + const second = parsePng( + new ByteReader(input), + 32, + 128, + maxDiagnostics, + ); + + expect(first).toEqual(second); + expect(first.complete).toBe(false); + expect(first.diagnostics.length).toBeLessThanOrEqual(maxDiagnostics); + expectTypedFailure(() => cleanMetadata(input)); + expect(input).toEqual(before); + }, + ), + propertyParameters(), + ); + }); + + it("bounds generated TIFF counts, offsets, pointers, cycles, types, and values", () => { + fc.assert( + fc.property( + fc.constantFrom("little" as const, "big" as const), + fc.constantFrom( + "offset" as const, + "count" as const, + "pointer" as const, + "cycle" as const, + "rational" as const, + "type" as const, + "maker" as const, + ), + fc.uint8Array({ minLength: 5, maxLength: 16 }), + fc.integer({ min: 0, max: 4 }), + (order, mode, makerPayload, maxDiagnostics) => { + const input = malformedTiff(order, mode, makerPayload); + const before = Uint8Array.from(input); + const limits = { ...TIFF_LIMITS, maxDiagnostics }; + const first = parseTiff(input, limits); + const second = parseTiff(input, limits); + + expect(first).toEqual(second); + if (mode === "maker") { + expect(first.complete).toBe(true); + expect(first.entries).toContainEqual( + expect.objectContaining({ name: "MakerNote" }), + ); + expect(first.entries[0]).not.toHaveProperty("value"); + } else { + expect(first.complete).toBe(false); + } + expect(first.diagnostics.length).toBeLessThanOrEqual(maxDiagnostics); + expect(input).toEqual(before); + }, + ), + propertyParameters(), + ); + }); +});