From 34b59ff15bc89d38635efc255d58c0307397ac8f Mon Sep 17 00:00:00 2001 From: Matt Carvin <90224411+mcarvin8@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:02:48 -0400 Subject: [PATCH 1/2] test: add permanent property-based fuzz test for XML round trip Adds fast-check as a devDependency and a generator that produces arbitrary nested XML shapes (mixed element/text/CDATA/comment content, unicode, whitespace-only nodes) instead of relying only on curated fixtures, ported from the downstream consumer (mcarvin8/sf-decomposer) that first found #127/#128/#130/#132 this way. Checks the same tolerance model as this project's byte-retention + idempotence conventions: every non-whitespace leaf value must survive one round trip, and a second round trip must be byte-identical to the first. numRuns kept modest (100) for regular CI; bump locally for a deeper sweep when iterating on a fix (see file header for the [patch.crates-io] local-iteration workflow this was developed against). NOT green yet against the currently pinned config-disassembler 0.10.7: it correctly fails on a fourth family of bugs (mcarvin8/config-disassembler#134, not yet released) found via this same harness. Will go green once that lands and this repo's dependency is bumped in the same commit/PR as this one, matching the existing "hold until upstream lands" pattern already used for #127/#128/#130/#132. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011ADqqfdwfnhTPJQp17sH3C --- package-lock.json | 41 ++++++++ package.json | 1 + test/xml-roundtrip-fuzz.spec.ts | 176 ++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 test/xml-roundtrip-fuzz.spec.ts diff --git a/package-lock.json b/package-lock.json index a9fc1ac..eaf688e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@commitlint/config-conventional": "21.2.2", "@napi-rs/cli": "3.8.6", "@types/node": "26.4.0", + "fast-check": "4.9.0", "husky": "9.1.7", "ls-engines": "0.10.1", "typescript": "7.0.2", @@ -4062,6 +4063,29 @@ "dev": true, "license": "MIT" }, + "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-content-type-parse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", @@ -6288,6 +6312,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/read-cmd-shim": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", diff --git a/package.json b/package.json index 26dbd70..d62ed00 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@commitlint/config-conventional": "21.2.2", "@napi-rs/cli": "3.8.6", "@types/node": "26.4.0", + "fast-check": "4.9.0", "husky": "9.1.7", "ls-engines": "0.10.1", "typescript": "7.0.2", diff --git a/test/xml-roundtrip-fuzz.spec.ts b/test/xml-roundtrip-fuzz.spec.ts new file mode 100644 index 0000000..4bde854 --- /dev/null +++ b/test/xml-roundtrip-fuzz.spec.ts @@ -0,0 +1,176 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import fc from "fast-check"; + +import { DisassembleXMLFileHandler, ReassembleXMLFileHandler } from "../"; + +// Property-based fuzzing of the decompose/recompose round trip against +// arbitrary nested XML shapes, ported from the downstream consumer +// (mcarvin8/sf-decomposer) that found #127/#128/#130/#132/#134 this way. +// Kept here permanently as a standing regression guard against this whole +// class of bug. During development, new bugs can be diagnosed and fixed +// locally in far fewer cycles than a crates.io + npm publish per bug by +// adding a temporary `[patch.crates-io] config-disassembler = { path = +// "../config-disassembler" }` to Cargo.toml (never commit that patch - +// it points at a sibling checkout's local path) and rebuilding the native +// binding against it before running this test. +// +// Two properties are checked, matching this project's own byte-retention + +// idempotence tolerance model (not asserting original-bytes-equal-first- +// round-trip-bytes, which is stricter than the project holds itself to +// anywhere else): +// 1. No content loss: every non-whitespace leaf value in the generated +// document must still appear verbatim after one round trip. +// 2. Idempotence: a second round trip must produce byte-identical output +// to the first. +// +// numRuns is kept modest (100) for regular CI; bump it locally (500-1000+) +// for a deeper one-off sweep when iterating on a fix. + +const MAX_DEPTH = 3; +const TAGS = ["alpha", "beta", "gamma", "wrapper", "group", "entry", "node"] as const; + +type FuzzNode = + | { kind: "text"; value: string } + | { kind: "cdata"; value: string } + | { kind: "comment"; value: string } + | { kind: "element"; tag: string; children: FuzzNode[] }; + +// XML 1.0 forbids most C0 control characters. Built from char codes at +// runtime (rather than \u escapes in a regex literal) so no raw control +// bytes ever need to appear in this source file. +const ILLEGAL_XML_CHAR_CODES = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, +]; +const ILLEGAL_XML_CHARS_PATTERN = new RegExp( + `[${ILLEGAL_XML_CHAR_CODES.map((code) => String.fromCharCode(code)).join("")}]`, + "g", +); + +const stripIllegalControlChars = (raw: string): string => raw.replace(ILLEGAL_XML_CHARS_PATTERN, ""); + +// Quotes are legal, unescaped XML text but get entity-encoded ("/') +// by the writer same as `<`/`&`/`>` - stripped here too so the literal-value +// substring check below isn't comparing against the wrong (escaped) form. +const sanitizeText = (raw: string): string => stripIllegalControlChars(raw).replace(/[<&>"']/g, " "); + +const sanitizeCdata = (raw: string): string => stripIllegalControlChars(raw).replace(/]]>/g, "] ]>"); + +const sanitizeComment = (raw: string): string => { + const s = stripIllegalControlChars(raw).replace(/--/g, "- -"); + return s.endsWith("-") ? `${s} ` : s; +}; + +const tagArb = fc.constantFrom(...TAGS); +const textLeafArb = fc.string({ maxLength: 24 }).map((v): FuzzNode => ({ kind: "text", value: sanitizeText(v) })); +const cdataLeafArb = fc.string({ maxLength: 24 }).map((v): FuzzNode => ({ kind: "cdata", value: sanitizeCdata(v) })); +const commentLeafArb = fc + .string({ maxLength: 24 }) + .map((v): FuzzNode => ({ kind: "comment", value: sanitizeComment(v) })); + +function nodeArb(depth: number): fc.Arbitrary { + const leaves = [textLeafArb, cdataLeafArb, commentLeafArb]; + if (depth >= MAX_DEPTH) { + return fc.oneof(...leaves); + } + return fc.oneof( + { weight: 3, arbitrary: textLeafArb }, + { weight: 1, arbitrary: cdataLeafArb }, + { weight: 1, arbitrary: commentLeafArb }, + { + weight: 2, + arbitrary: fc + .record({ tag: tagArb, children: fc.array(nodeArb(depth + 1), { maxLength: 3 }) }) + .map((r): FuzzNode => ({ kind: "element", ...r })), + }, + ); +} + +// Each item is the child-node list wrapped in a synthetic, sequential +// at serialization time - keeps naming collision-free so we're +// fuzzing content/structure, not the separate unique-id-fallback behavior. +const documentArb = fc.array(fc.array(nodeArb(1), { minLength: 1, maxLength: 4 }), { minLength: 2, maxLength: 5 }); + +function serializeNode(node: FuzzNode): string { + switch (node.kind) { + case "text": + return node.value; + case "cdata": + return ``; + case "comment": + return ``; + case "element": + return `<${node.tag}>${node.children.map(serializeNode).join("")}`; + } +} + +function buildDocument(items: FuzzNode[][]): string { + const itemsXml = items + .map((children, i) => `Item${i}${children.map(serializeNode).join("")}`) + .join(""); + return `\n${itemsXml}`; +} + +function collectLeafValues(nodes: FuzzNode[], out: string[]): void { + for (const node of nodes) { + if (node.kind === "element") { + collectLeafValues(node.children, out); + } else if (node.value.trim().length > 0) { + // Whitespace-only leaves are excluded: whether insignificant whitespace + // survives verbatim is exactly the ambiguous, tool-normalized case this + // suite already knows not to pin down byte-for-byte (see file header). + out.push(node.value); + } + } +} + +async function roundTripOnce(dir: string, filePath: string): Promise { + const disassembler = new DisassembleXMLFileHandler(); + await disassembler.disassemble({ + filePath, + strategy: "unique-id", + uniqueIdElements: "fullName", + prePurge: true, + postPurge: true, + format: "xml", + }); + + const reassembler = new ReassembleXMLFileHandler(); + await reassembler.reassemble({ + filePath: join(dir, "Fuzz"), + fileExtension: "fuzz-meta.xml", + postPurge: true, + }); + + return readFile(filePath, "utf-8"); +} + +describe("xml decompose/recompose fuzz", () => { + it("preserves every leaf value and stabilizes after one round trip, across arbitrary nested XML shapes", async () => { + await fc.assert( + fc.asyncProperty(documentArb, async (items) => { + const xml = buildDocument(items); + const leafValues: string[] = []; + for (const children of items) collectLeafValues(children, leafValues); + + const dir = await mkdtemp(join(tmpdir(), "xml-fuzz-")); + const filePath = join(dir, "Fuzz.fuzz-meta.xml"); + try { + await writeFile(filePath, xml, "utf-8"); + + const pass1 = await roundTripOnce(dir, filePath); + for (const value of leafValues) { + expect(pass1.includes(value), `leaf value ${JSON.stringify(value)} missing after round trip`).toBe(true); + } + + const pass2 = await roundTripOnce(dir, filePath); + expect(pass2, "second round trip must be byte-identical to the first (idempotence)").toBe(pass1); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }), + { numRuns: 100 }, + ); + }, 20_000); +}); From 99d25708e4f568732513c5ef1d5d580974bdb19b Mon Sep 17 00:00:00 2001 From: Matt Carvin <90224411+mcarvin8@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:06:13 -0400 Subject: [PATCH 2/2] fix(rust): bump config-disassembler to 0.10.8 Bumps the underlying config-disassembler Rust crate 0.10.7 -> 0.10.8, pulling in mcarvin8/config-disassembler#134: four related bugs in flush_text_buffer that lost or duplicated text mixed with child elements and CDATA (whitespace trimmed off real content on concatenation, whitespace-only prior runs permanently baked into merged values causing unbounded growth on repeated round trips, and real text silently dropped once CDATA was present on the same element). This is the fourth and final fix from the property-based fuzzing effort downstream in mcarvin8/sf-decomposer, following #127 (0.10.4), #128 (0.10.5), and #130 (0.10.6). The fuzz harness added to this repo in the prior commit (test/xml-roundtrip-fuzz.spec.ts) is now green against this version - 13/13 test files, 95/95 tests passing, confirmed stable across multiple independently-seeded runs. No API changes on the Node side -- pure dependency bump. Verified with `cargo build`/`cargo test` against 0.10.8, plus a full native-binding `npm test` run built for this machine's actual host arch (aarch64-pc-windows-msvc). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011ADqqfdwfnhTPJQp17sH3C --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 056e667..b926a2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,9 +112,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "config-disassembler" -version = "0.10.7" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aea9e45485b3565740f713d7e535a13ae3825b725da4bf7324a3aed99eebd320" +checksum = "0494bb7f3bfd96ee8a24f28826690ec638a0733ab6e9830fa3c41af04e6089c3" dependencies = [ "anyhow", "configparser", diff --git a/Cargo.toml b/Cargo.toml index a8f5035..9d6750d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib"] [dependencies] napi = { version = "3", default-features = false, features = ["napi4", "serde-json", "async"] } napi-derive = "3" -config-disassembler = "0.10.7" +config-disassembler = "0.10.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"] } env_logger = "0.11" serde_json = "1"