@adeu/core 1.21.0 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1150 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -12
- package/dist/index.d.ts +156 -12
- package/dist/index.js +1142 -88
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +500 -1
- package/src/docx/bridge.ts +13 -0
- package/src/domain.ts +68 -15
- package/src/engine.ts +391 -28
- package/src/index.ts +6 -5
- package/src/ingest.ts +77 -5
- package/src/mapper.ts +101 -12
- package/src/markup.test.ts +2 -1
- package/src/markup.ts +214 -20
- package/src/repro_qa_report_2026_07_18.test.ts +1244 -0
- package/src/repro_qa_report_v5.test.ts +399 -0
- package/src/sanitize/core.ts +3 -0
- package/src/sanitize/transforms.ts +66 -2
- package/src/utils/docx.ts +257 -16
- package/src/utils/safe-regex.ts +90 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// FILE: src/repro_qa_report_v5.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Node-side regression tests for the 2026-07-17 exploratory QA report
|
|
4
|
+
// (adeu 1.21.0). The report was executed against the Python CLI; per the
|
|
5
|
+
// "Make Both Perfect" principle each engine-level finding is verified (and
|
|
6
|
+
// where the defect existed, fixed) here too:
|
|
7
|
+
//
|
|
8
|
+
// F3 delete-comment no-op — Node was already safe (TS lazy getter);
|
|
9
|
+
// pinned here as a parity regression test
|
|
10
|
+
// F4 core-property scrub gaps — shared defect, fixed
|
|
11
|
+
// F5 ReDoS via regex:true — shared defect, fixed (vm time budget)
|
|
12
|
+
// F6 duplicate-definition prune — shared defect, fixed
|
|
13
|
+
// F8 pinned edits skip shape
|
|
14
|
+
// validation — Node was already safe; pinned as parity
|
|
15
|
+
// F9 duplicate w:id resolution — shared defect, fixed
|
|
16
|
+
// F10 0-based [Edit:N] — shared defect, fixed
|
|
17
|
+
// F11 control characters — shared defect, fixed (xmldom would have
|
|
18
|
+
// silently serialized an invalid DOCX)
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from "vitest";
|
|
21
|
+
import { createTestDocument, addParagraph } from "./test-utils.js";
|
|
22
|
+
import { RedlineEngine, BatchValidationError, validate_edit_strings } from "./engine.js";
|
|
23
|
+
import { CommentsManager } from "./comments.js";
|
|
24
|
+
import { extract_all_domain_metadata } from "./domain.js";
|
|
25
|
+
import { apply_edits_to_markdown } from "./markup.js";
|
|
26
|
+
import { scrub_doc_properties, remove_all_comments } from "./sanitize/transforms.js";
|
|
27
|
+
import { RegexTimeoutError, userFindAllMatches } from "./utils/safe-regex.js";
|
|
28
|
+
import { serializeXml } from "./docx/dom.js";
|
|
29
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
30
|
+
|
|
31
|
+
function addTrackedInsertion(
|
|
32
|
+
doc: DocumentObject,
|
|
33
|
+
p: Element,
|
|
34
|
+
text: string,
|
|
35
|
+
wid: string,
|
|
36
|
+
author: string,
|
|
37
|
+
): Element {
|
|
38
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
39
|
+
const ins = xmlDoc.createElement("w:ins");
|
|
40
|
+
ins.setAttribute("w:id", wid);
|
|
41
|
+
ins.setAttribute("w:author", author);
|
|
42
|
+
ins.setAttribute("w:date", "2026-01-02T10:00:00Z");
|
|
43
|
+
const r = xmlDoc.createElement("w:r");
|
|
44
|
+
const t = xmlDoc.createElement("w:t");
|
|
45
|
+
t.setAttribute("xml:space", "preserve");
|
|
46
|
+
t.textContent = text;
|
|
47
|
+
r.appendChild(t);
|
|
48
|
+
ins.appendChild(r);
|
|
49
|
+
p.appendChild(ins);
|
|
50
|
+
return ins;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// F3 — parity: deleteComment must work on a freshly constructed manager
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
describe("F3 parity: comment deletion on a fresh CommentsManager", () => {
|
|
58
|
+
it("deletes the comment from the comments part (no backing-field no-op)", async () => {
|
|
59
|
+
const doc = await createTestDocument();
|
|
60
|
+
const p = addParagraph(doc, "The purchase price shall be negotiated.");
|
|
61
|
+
const cm = new CommentsManager(doc);
|
|
62
|
+
const cid = cm.addComment("Mallory Insider", "Walk away below 9M.");
|
|
63
|
+
|
|
64
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
65
|
+
const rs = xmlDoc.createElement("w:commentRangeStart");
|
|
66
|
+
rs.setAttribute("w:id", cid);
|
|
67
|
+
p.insertBefore(rs, p.firstChild);
|
|
68
|
+
const re = xmlDoc.createElement("w:commentRangeEnd");
|
|
69
|
+
re.setAttribute("w:id", cid);
|
|
70
|
+
p.appendChild(re);
|
|
71
|
+
|
|
72
|
+
// A FRESH manager (the sanitize path) must actually delete — this is the
|
|
73
|
+
// exact seam where the Python engine silently no-opped (QA F3).
|
|
74
|
+
const fresh = new CommentsManager(doc);
|
|
75
|
+
fresh.deleteComment(cid);
|
|
76
|
+
|
|
77
|
+
const commentsPart = doc.pkg.parts.find((part) =>
|
|
78
|
+
part.partname.includes("comments"),
|
|
79
|
+
);
|
|
80
|
+
const xml = commentsPart ? serializeXml(commentsPart._element) : "";
|
|
81
|
+
expect(xml).not.toContain("Walk away below 9M.");
|
|
82
|
+
expect(xml).not.toContain("Mallory Insider");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("remove_all_comments leaves no comment text or author in the package", async () => {
|
|
86
|
+
const doc = await createTestDocument();
|
|
87
|
+
const p = addParagraph(doc, "The parties agree to negotiate in good faith.");
|
|
88
|
+
const cm = new CommentsManager(doc);
|
|
89
|
+
const cid = cm.addComment("Mallory Insider", "Board approved 12M ceiling.");
|
|
90
|
+
const xmlDoc = doc.element.ownerDocument!;
|
|
91
|
+
const rs = xmlDoc.createElement("w:commentRangeStart");
|
|
92
|
+
rs.setAttribute("w:id", cid);
|
|
93
|
+
p.insertBefore(rs, p.firstChild);
|
|
94
|
+
|
|
95
|
+
const lines = remove_all_comments(doc);
|
|
96
|
+
expect(lines.join("\n")).toContain("Comments removed: 1");
|
|
97
|
+
|
|
98
|
+
for (const part of doc.pkg.parts) {
|
|
99
|
+
if (!part.partname.includes("comments")) continue;
|
|
100
|
+
const xml = serializeXml(part._element);
|
|
101
|
+
expect(xml).not.toContain("Board approved 12M ceiling.");
|
|
102
|
+
expect(xml).not.toContain("Mallory Insider");
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// F4 — sanitize must scrub (or report) all leaky core properties
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe("F4: core property scrub covers title/category/keywords/subject/status", () => {
|
|
112
|
+
async function docWithCoreProps(): Promise<DocumentObject> {
|
|
113
|
+
const doc = await createTestDocument();
|
|
114
|
+
addParagraph(doc, "Body text.");
|
|
115
|
+
const corePart = doc.pkg.getPartByPath("docProps/core.xml");
|
|
116
|
+
expect(corePart).toBeTruthy();
|
|
117
|
+
const coreDoc = corePart!._element.ownerDocument!;
|
|
118
|
+
const root = corePart!._element;
|
|
119
|
+
const add = (tag: string, value: string) => {
|
|
120
|
+
const el = coreDoc.createElement(tag);
|
|
121
|
+
el.textContent = value;
|
|
122
|
+
root.appendChild(el);
|
|
123
|
+
};
|
|
124
|
+
add("dc:title", "Secret Merger Agreement");
|
|
125
|
+
add("cp:category", "Project Falcon");
|
|
126
|
+
add("cp:keywords", "confidential,merger,project-falcon");
|
|
127
|
+
add("dc:subject", "Acquisition of TargetCo");
|
|
128
|
+
add("cp:contentStatus", "Draft - privileged");
|
|
129
|
+
add("dc:description", "Internal: do not circulate");
|
|
130
|
+
return doc;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it("strips category/keywords/subject/contentStatus/description and reports them", async () => {
|
|
134
|
+
const doc = await docWithCoreProps();
|
|
135
|
+
const lines = scrub_doc_properties(doc);
|
|
136
|
+
const report = lines.join("\n");
|
|
137
|
+
|
|
138
|
+
const coreXml = serializeXml(doc.pkg.getPartByPath("docProps/core.xml")!._element);
|
|
139
|
+
expect(coreXml).not.toContain("Project Falcon");
|
|
140
|
+
expect(coreXml).not.toContain("project-falcon");
|
|
141
|
+
expect(coreXml).not.toContain("Acquisition of TargetCo");
|
|
142
|
+
expect(coreXml).not.toContain("Draft - privileged");
|
|
143
|
+
expect(coreXml).not.toContain("do not circulate");
|
|
144
|
+
expect(report).toContain("Category");
|
|
145
|
+
expect(report).toContain("Keywords");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("reports the title as kept instead of silently ignoring it", async () => {
|
|
149
|
+
const doc = await docWithCoreProps();
|
|
150
|
+
const report = scrub_doc_properties(doc).join("\n");
|
|
151
|
+
const coreXml = serializeXml(doc.pkg.getPartByPath("docProps/core.xml")!._element);
|
|
152
|
+
expect(coreXml).toContain("Secret Merger Agreement"); // kept by design
|
|
153
|
+
expect(report).toContain("Secret Merger Agreement"); // ...but visibly
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// F5 — LLM-controlled regex needs a wall-clock budget
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe("F5: user regex runs under a time budget", () => {
|
|
162
|
+
const REDOS_PATTERN = "(a|a)*$";
|
|
163
|
+
const HAYSTACK = "x" + "a".repeat(40) + "!";
|
|
164
|
+
|
|
165
|
+
it(
|
|
166
|
+
"userFindAllMatches interrupts catastrophic backtracking",
|
|
167
|
+
() => {
|
|
168
|
+
const t0 = Date.now();
|
|
169
|
+
expect(() => userFindAllMatches(REDOS_PATTERN, HAYSTACK)).toThrow(RegexTimeoutError);
|
|
170
|
+
expect(Date.now() - t0).toBeLessThan(10_000);
|
|
171
|
+
},
|
|
172
|
+
15_000,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it(
|
|
176
|
+
"a ReDoS edit fails as a clean per-edit validation error",
|
|
177
|
+
async () => {
|
|
178
|
+
const doc = await createTestDocument();
|
|
179
|
+
addParagraph(doc, HAYSTACK);
|
|
180
|
+
const engine = new RedlineEngine(doc);
|
|
181
|
+
|
|
182
|
+
let errors: string[] = [];
|
|
183
|
+
try {
|
|
184
|
+
engine.process_batch([
|
|
185
|
+
{ type: "modify", target_text: REDOS_PATTERN, new_text: "X", regex: true, match_mode: "first" } as any,
|
|
186
|
+
]);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
expect(e).toBeInstanceOf(BatchValidationError);
|
|
189
|
+
errors = (e as BatchValidationError).errors;
|
|
190
|
+
}
|
|
191
|
+
const joined = errors.join("\n");
|
|
192
|
+
expect(joined).toContain("Edit 1 Failed");
|
|
193
|
+
expect(joined.toLowerCase()).toContain("time");
|
|
194
|
+
},
|
|
195
|
+
15_000,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
it("a valid user regex still matches normally", async () => {
|
|
199
|
+
const doc = await createTestDocument();
|
|
200
|
+
addParagraph(doc, "The fee is 12,500 euros.");
|
|
201
|
+
const engine = new RedlineEngine(doc);
|
|
202
|
+
const res = engine.process_batch([
|
|
203
|
+
{ type: "modify", target_text: "\\d{2},\\d{3}", new_text: "13,000", regex: true, match_mode: "first" } as any,
|
|
204
|
+
]);
|
|
205
|
+
expect(res.edits_applied).toBe(1);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// F6 — duplicate-definition diagnostics must survive the unused-term prune
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
describe("F6: duplicate-definition diagnostics survive the unused prune", () => {
|
|
214
|
+
it("reports a duplicate that is never used", async () => {
|
|
215
|
+
const doc = await createTestDocument();
|
|
216
|
+
addParagraph(doc, '"Gadget" means a mechanical device.');
|
|
217
|
+
addParagraph(doc, '"Gadget" means an electronic device.');
|
|
218
|
+
addParagraph(doc, "Nothing else references that term at all.");
|
|
219
|
+
const base_text = [
|
|
220
|
+
'"Gadget" means a mechanical device.',
|
|
221
|
+
'"Gadget" means an electronic device.',
|
|
222
|
+
"Nothing else references that term at all.",
|
|
223
|
+
].join("\n\n");
|
|
224
|
+
|
|
225
|
+
const [, diagnostics] = extract_all_domain_metadata(doc, base_text);
|
|
226
|
+
expect(
|
|
227
|
+
diagnostics.some((d) => d.includes("Duplicate Definition") && d.includes("Gadget")),
|
|
228
|
+
).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("still reports a duplicate that IS used (control)", async () => {
|
|
232
|
+
const doc = await createTestDocument();
|
|
233
|
+
addParagraph(doc, '"Widget" means a mechanical device.');
|
|
234
|
+
addParagraph(doc, '"Widget" means an electronic device.');
|
|
235
|
+
addParagraph(doc, "The Widget shall be delivered on time.");
|
|
236
|
+
const base_text = [
|
|
237
|
+
'"Widget" means a mechanical device.',
|
|
238
|
+
'"Widget" means an electronic device.',
|
|
239
|
+
"The Widget shall be delivered on time.",
|
|
240
|
+
].join("\n\n");
|
|
241
|
+
|
|
242
|
+
const [, diagnostics] = extract_all_domain_metadata(doc, base_text);
|
|
243
|
+
expect(
|
|
244
|
+
diagnostics.some((d) => d.includes("Duplicate Definition") && d.includes("Widget")),
|
|
245
|
+
).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("surfaces an orphan definition as an Unused Definition warning", async () => {
|
|
249
|
+
const doc = await createTestDocument();
|
|
250
|
+
addParagraph(doc, '"Orphan Term" means something noone mentions again.');
|
|
251
|
+
addParagraph(doc, "The rest of the document is unrelated.");
|
|
252
|
+
const base_text = [
|
|
253
|
+
'"Orphan Term" means something noone mentions again.',
|
|
254
|
+
"The rest of the document is unrelated.",
|
|
255
|
+
].join("\n\n");
|
|
256
|
+
|
|
257
|
+
const [, diagnostics] = extract_all_domain_metadata(doc, base_text);
|
|
258
|
+
expect(
|
|
259
|
+
diagnostics.some((d) => d.includes("Unused Definition") && d.includes("Orphan Term")),
|
|
260
|
+
).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// F8 — parity: pinned edits pass the same string-shape validation
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
describe("F8 parity: pinned edits are shape-validated", () => {
|
|
269
|
+
it.each([
|
|
270
|
+
["{++New York++}", "CriticMarkup"],
|
|
271
|
+
["####### Deep heading", "Heading level 7"],
|
|
272
|
+
])("rejects a pinned edit whose new_text is %s", async (bad_new_text, expected) => {
|
|
273
|
+
const doc = await createTestDocument();
|
|
274
|
+
addParagraph(doc, "This is a simple contract paragraph for testing.");
|
|
275
|
+
const engine = new RedlineEngine(doc);
|
|
276
|
+
|
|
277
|
+
const edit: any = {
|
|
278
|
+
type: "modify",
|
|
279
|
+
target_text: "simple",
|
|
280
|
+
new_text: bad_new_text,
|
|
281
|
+
_match_start_index: engine.mapper.full_text.indexOf("simple"),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
expect(() => engine.process_batch([edit])).toThrow(BatchValidationError);
|
|
285
|
+
try {
|
|
286
|
+
engine.process_batch([edit]);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
expect((e as BatchValidationError).errors.join("\n").toLowerCase()).toContain(
|
|
289
|
+
expected.toLowerCase(),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// F9 — duplicate w:id values must not let one action resolve unrelated changes
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
describe("F9: duplicate w:id accept/reject is refused across authors", () => {
|
|
300
|
+
it("refuses to reject Chg:5 shared by Alice and Bob", async () => {
|
|
301
|
+
const doc = await createTestDocument();
|
|
302
|
+
const p1 = addParagraph(doc, "Clause A fee: ");
|
|
303
|
+
addTrackedInsertion(doc, p1, "ALPHA", "5", "Alice");
|
|
304
|
+
const p2 = addParagraph(doc, "Clause B fee: ");
|
|
305
|
+
addTrackedInsertion(doc, p2, "BRAVO", "5", "Bob");
|
|
306
|
+
|
|
307
|
+
const engine = new RedlineEngine(doc);
|
|
308
|
+
let errors: string[] = [];
|
|
309
|
+
try {
|
|
310
|
+
engine.process_batch([{ type: "reject", target_id: "Chg:5" } as any]);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
expect(e).toBeInstanceOf(BatchValidationError);
|
|
313
|
+
errors = (e as BatchValidationError).errors;
|
|
314
|
+
}
|
|
315
|
+
const joined = errors.join("\n");
|
|
316
|
+
expect(joined).toContain("Alice");
|
|
317
|
+
expect(joined).toContain("Bob");
|
|
318
|
+
|
|
319
|
+
// Neither revision may have been touched.
|
|
320
|
+
const xml = serializeXml(doc.element);
|
|
321
|
+
expect(xml).toContain("ALPHA");
|
|
322
|
+
expect(xml).toContain("BRAVO");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("same-author id reuse (the engine's own output) stays resolvable", async () => {
|
|
326
|
+
const doc = await createTestDocument();
|
|
327
|
+
const p1 = addParagraph(doc, "Fee clause: ");
|
|
328
|
+
addTrackedInsertion(doc, p1, "FIRST", "7", "Adeu AI");
|
|
329
|
+
// Second element of the SAME logical change: same id, same author.
|
|
330
|
+
const p2 = addParagraph(doc, "Continued: ");
|
|
331
|
+
addTrackedInsertion(doc, p2, "SECOND", "7", "Adeu AI");
|
|
332
|
+
|
|
333
|
+
const engine = new RedlineEngine(doc);
|
|
334
|
+
const res = engine.process_batch([{ type: "accept", target_id: "Chg:7" } as any]);
|
|
335
|
+
expect(res.actions_applied).toBe(1);
|
|
336
|
+
expect(res.actions_skipped).toBe(0);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// F10 — [Edit:N] indices must be 1-based to match apply's Edit N reports
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
describe("F10: markup [Edit:N] indices are 1-based", () => {
|
|
345
|
+
it("emits [Edit:1] / [Edit:2] for a two-edit batch", () => {
|
|
346
|
+
const result = apply_edits_to_markdown(
|
|
347
|
+
"Alpha beta gamma.",
|
|
348
|
+
[
|
|
349
|
+
{ type: "modify", target_text: "Alpha", new_text: "Omega" } as any,
|
|
350
|
+
{ type: "modify", target_text: "gamma", new_text: "delta" } as any,
|
|
351
|
+
],
|
|
352
|
+
true,
|
|
353
|
+
);
|
|
354
|
+
expect(result).toContain("[Edit:1]");
|
|
355
|
+
expect(result).toContain("[Edit:2]");
|
|
356
|
+
expect(result).not.toContain("[Edit:0]");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// F11 — control characters must fail as clean validation, not corrupt XML
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
describe("F11: XML-illegal control characters are rejected cleanly", () => {
|
|
365
|
+
it("rejects new_text containing \\x01 with a per-edit error", async () => {
|
|
366
|
+
const doc = await createTestDocument();
|
|
367
|
+
addParagraph(doc, "This is a simple contract.");
|
|
368
|
+
const engine = new RedlineEngine(doc);
|
|
369
|
+
|
|
370
|
+
let errors: string[] = [];
|
|
371
|
+
try {
|
|
372
|
+
engine.process_batch([
|
|
373
|
+
{ type: "modify", target_text: "simple", new_text: "bad\x01value" } as any,
|
|
374
|
+
]);
|
|
375
|
+
} catch (e) {
|
|
376
|
+
expect(e).toBeInstanceOf(BatchValidationError);
|
|
377
|
+
errors = (e as BatchValidationError).errors;
|
|
378
|
+
}
|
|
379
|
+
const joined = errors.join("\n");
|
|
380
|
+
expect(joined).toContain("Edit 1 Failed");
|
|
381
|
+
expect(joined.toLowerCase()).toContain("control character");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("rejects a comment containing \\x00", () => {
|
|
385
|
+
const errors = validate_edit_strings([
|
|
386
|
+
{ type: "modify", target_text: "a", new_text: "b", comment: "note\x00here" },
|
|
387
|
+
]);
|
|
388
|
+
expect(errors.join("\n").toLowerCase()).toContain("control character");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("without validation, xmldom would serialize the control char silently", async () => {
|
|
392
|
+
// Documents WHY the check must live in validation on Node: unlike lxml,
|
|
393
|
+
// @xmldom/xmldom writes \x01 into the XML without complaint, producing a
|
|
394
|
+
// package Word cannot open — worse than Python's loud traceback.
|
|
395
|
+
const doc = await createTestDocument();
|
|
396
|
+
const p = addParagraph(doc, "bad\x01char");
|
|
397
|
+
expect(serializeXml(p)).toContain("bad\x01char");
|
|
398
|
+
});
|
|
399
|
+
});
|
package/src/sanitize/core.ts
CHANGED
|
@@ -71,6 +71,9 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
|
|
|
71
71
|
|
|
72
72
|
const warnings = transforms.audit_hyperlinks(doc);
|
|
73
73
|
for (const w of warnings) report.warnings.push(w);
|
|
74
|
+
// Audit (non-destructive — just warnings): watermark-like VML text objects
|
|
75
|
+
// must never let the report declare the document clean (QA 2026-07-18 M3).
|
|
76
|
+
for (const w of transforms.detect_watermarks(doc)) report.warnings.push(w);
|
|
74
77
|
|
|
75
78
|
report.add_transform_lines(transforms.normalize_change_dates(doc));
|
|
76
79
|
|
|
@@ -423,10 +423,34 @@ export function scrub_doc_properties(doc: DocumentObject): string[] {
|
|
|
423
423
|
if (corePart) {
|
|
424
424
|
const creators = findDescendantsByLocalName(corePart._element, 'creator');
|
|
425
425
|
creators.forEach(c => { if (c.textContent) { lines.push(`Author: ${c.textContent}`); c.textContent = ""; }});
|
|
426
|
-
|
|
426
|
+
|
|
427
427
|
const modifiers = findDescendantsByLocalName(corePart._element, 'lastModifiedBy');
|
|
428
428
|
modifiers.forEach(c => { if (c.textContent) { lines.push(`Last modified by: ${c.textContent}`); c.textContent = ""; }});
|
|
429
|
-
|
|
429
|
+
|
|
430
|
+
// Title is often intentional, but can leak. Report it, don't strip
|
|
431
|
+
// (QA 2026-07-17 F4; mirrors Python).
|
|
432
|
+
const titles = findDescendantsByLocalName(corePart._element, 'title');
|
|
433
|
+
titles.forEach(c => { if (c.textContent) { lines.push(`Title kept (review manually): "${c.textContent}"`); }});
|
|
434
|
+
|
|
435
|
+
// Classification-style properties are textbook leak vectors ("Project
|
|
436
|
+
// Falcon", "confidential,merger,..."). Unlike title they carry no
|
|
437
|
+
// legitimate outbound formatting value, so strip them and say so.
|
|
438
|
+
const leakFields: Array<[string, string]> = [
|
|
439
|
+
['category', 'Category'],
|
|
440
|
+
['keywords', 'Keywords'],
|
|
441
|
+
['subject', 'Subject'],
|
|
442
|
+
['contentStatus', 'Content status'],
|
|
443
|
+
['description', 'Description/comments'],
|
|
444
|
+
];
|
|
445
|
+
for (const [local, label] of leakFields) {
|
|
446
|
+
findDescendantsByLocalName(corePart._element, local).forEach(c => {
|
|
447
|
+
if (c.textContent) {
|
|
448
|
+
lines.push(`${label}: ${c.textContent}`);
|
|
449
|
+
c.textContent = "";
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
430
454
|
const revisions = findDescendantsByLocalName(corePart._element, 'revision');
|
|
431
455
|
revisions.forEach(c => { if (c.textContent && parseInt(c.textContent) > 1) { lines.push(`Revision count: ${c.textContent} → 1`); c.textContent = "1"; }});
|
|
432
456
|
}
|
|
@@ -519,6 +543,46 @@ export function strip_image_alt_text(doc: DocumentObject): string[] {
|
|
|
519
543
|
return count ? [`Image alt text: ${count} auto-generated descriptions removed`] : [];
|
|
520
544
|
}
|
|
521
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Detect watermark-like embedded text objects (VML shapes with a textpath,
|
|
548
|
+
* Word's native watermark mechanism) in headers, footers and the body.
|
|
549
|
+
* Non-destructive: returns warnings so the report never declares a document
|
|
550
|
+
* fully clean while such an object silently remains (QA 2026-07-18 M3).
|
|
551
|
+
*/
|
|
552
|
+
export function detect_watermarks(doc: DocumentObject): string[] {
|
|
553
|
+
const warnings: string[] = [];
|
|
554
|
+
const seen = new Set<string>();
|
|
555
|
+
|
|
556
|
+
for (const part of doc.pkg.parts) {
|
|
557
|
+
const name = String(part.partname);
|
|
558
|
+
let location: string;
|
|
559
|
+
if (name.startsWith("/word/header")) location = "header";
|
|
560
|
+
else if (name.startsWith("/word/footer")) location = "footer";
|
|
561
|
+
else if (name === "/word/document.xml") location = "body";
|
|
562
|
+
else continue;
|
|
563
|
+
|
|
564
|
+
let textpaths: Element[];
|
|
565
|
+
try {
|
|
566
|
+
textpaths = findDescendantsByLocalName(part._element, "textpath");
|
|
567
|
+
} catch {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
for (const tp of textpaths) {
|
|
571
|
+
const text = (tp.getAttribute("string") || "").trim();
|
|
572
|
+
const key = `${location}\x1f${text}`;
|
|
573
|
+
if (seen.has(key)) continue;
|
|
574
|
+
seen.add(key);
|
|
575
|
+
const label = text ? `"${_truncate(text, 60)}"` : "(no text)";
|
|
576
|
+
warnings.push(
|
|
577
|
+
`Watermark-like text object in ${location}: ${label} — NOT removed. ` +
|
|
578
|
+
"It remains in the document package and may render in other applications; " +
|
|
579
|
+
"review and remove it in Word if it must not reach the counterparty.",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return warnings;
|
|
584
|
+
}
|
|
585
|
+
|
|
522
586
|
export function audit_hyperlinks(doc: DocumentObject): string[] {
|
|
523
587
|
const internal = ["sharepoint.com", "onedrive.com", ".internal", "intranet", "localhost", "10.", "192.168.", "172.16."];
|
|
524
588
|
const warnings: string[] = [];
|