@doccloak/core 0.10.0 → 0.12.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +162 -2
  2. package/README.md +252 -19
  3. package/dist/doc.d.ts +68 -1
  4. package/dist/doc.js +447 -62
  5. package/dist/dom/docx.d.ts +135 -7
  6. package/dist/dom/docx.js +747 -134
  7. package/dist/dom/drawingml.d.ts +59 -0
  8. package/dist/dom/drawingml.js +176 -0
  9. package/dist/dom/errors.d.ts +35 -0
  10. package/dist/dom/errors.js +24 -0
  11. package/dist/dom/index.d.ts +3 -0
  12. package/dist/dom/index.js +6 -0
  13. package/dist/dom/office.d.ts +56 -0
  14. package/dist/dom/office.js +76 -23
  15. package/dist/dom/opc.d.ts +141 -7
  16. package/dist/dom/opc.js +424 -18
  17. package/dist/dom/package-policy.d.ts +137 -0
  18. package/dist/dom/package-policy.js +355 -0
  19. package/dist/dom/xlsx.d.ts +64 -6
  20. package/dist/dom/xlsx.js +75 -20
  21. package/dist/engine.d.ts +18 -0
  22. package/dist/engine.js +37 -13
  23. package/dist/env.d.ts +21 -1
  24. package/dist/index.d.ts +8 -6
  25. package/dist/index.js +10 -5
  26. package/dist/model-loader.d.ts +61 -6
  27. package/dist/model-loader.js +122 -17
  28. package/dist/pipeline.d.ts +87 -8
  29. package/dist/pipeline.js +249 -50
  30. package/dist/providers/bardsai.d.ts +8 -1
  31. package/dist/providers/bardsai.js +22 -2
  32. package/dist/providers/gliner-base.d.ts +8 -1
  33. package/dist/providers/gliner-base.js +22 -2
  34. package/dist/providers/gliner.d.ts +8 -1
  35. package/dist/providers/gliner.js +22 -2
  36. package/dist/regex/loader.d.ts +2 -0
  37. package/dist/regex/loader.js +2 -0
  38. package/dist/regex/rules.data.js +15 -14
  39. package/dist/regex/types.d.ts +6 -0
  40. package/dist/session.d.ts +137 -4
  41. package/dist/session.js +591 -62
  42. package/dist/surrogates.js +57 -45
  43. package/dist/worker-protocol.d.ts +13 -1
  44. package/dist/worker-protocol.js +164 -12
  45. package/package.json +1 -1
  46. package/rules/at.json +1 -1
  47. package/rules/be.json +1 -1
  48. package/rules/ch.json +1 -1
  49. package/rules/de.json +1 -1
  50. package/rules/dk.json +1 -1
  51. package/rules/es.json +1 -1
  52. package/rules/fi.json +1 -1
  53. package/rules/fr.json +1 -1
  54. package/rules/it.json +1 -1
  55. package/rules/no.json +1 -1
  56. package/rules/pl.json +1 -1
  57. package/rules/pt.json +1 -1
  58. package/rules/schema.json +4 -0
  59. package/rules/se.json +1 -1
  60. package/rules/universal.json +2 -1
package/dist/doc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import CFB from 'cfb';
2
2
  import { normalizeReplacements } from "./docx.js";
3
+ import { UnsupportedDocumentError } from "./dom/errors.js";
3
4
  // ─── Helpers ─────────────────────────────────────────────────────────────────
4
5
  function toUint8Array(content) {
5
6
  return content instanceof Uint8Array
@@ -182,6 +183,107 @@ function readPlcBlob(tableStream, fc, lcb) {
182
183
  return new Uint8Array(0);
183
184
  return tableStream.slice(fc, fc + lcb);
184
185
  }
186
+ // ─── FIB base flags (MS-DOC 2.5.1 FibBase) ───────────────────────────────────
187
+ /** FibBase.nFib (offset 0x02): file format version; superseded by nFibNew. */
188
+ const FIB_NFIB = 0x0002;
189
+ /** FibBase flags word A (offset 0x0A). Bit layout, low to high:
190
+ * fDot 0x0001, fGlsy 0x0002, fComplex 0x0004, fHasPic 0x0008,
191
+ * cQuickSaves 0x00F0, fEncrypted 0x0100, fWhichTblStm 0x0200,
192
+ * fReadOnlyRecommended 0x0400, fWriteReservation 0x0800, fExtChar 0x1000,
193
+ * fLoadOverride 0x2000, fFarEast 0x4000, fObfuscated 0x8000. */
194
+ const FIB_FLAGS = 0x000a;
195
+ const FIB_F_COMPLEX = 0x0004;
196
+ const FIB_CQUICKSAVES_MASK = 0x00f0;
197
+ const FIB_CQUICKSAVES_SHIFT = 4;
198
+ const FIB_F_ENCRYPTED = 0x0100;
199
+ const FIB_F_WHICH_TBL_STM = 0x0200;
200
+ /** fObfuscated: XOR obfuscation (only meaningful when fEncrypted is set, but
201
+ * a file with either bit set is unreadable for us). */
202
+ const FIB_F_OBFUSCATED = 0x8000;
203
+ /** FibRgFcLcbBlob length prefix cbRgFcLcb (offset 0x98), in 8-byte units.
204
+ * FibRgCswNew follows it: cswNew (2 bytes) then nFibNew (2 bytes). */
205
+ const FIB_CB_RG_FC_LCB = 0x0098;
206
+ const FIB_RG_FC_LCB = 0x009a;
207
+ /** nFib of Word 2000. From this version on, cQuickSaves MUST be 0xF and no
208
+ * longer counts incremental saves (MS-DOC 2.5.1). */
209
+ const NFIB_WORD_2000 = 0x00d9;
210
+ function readFibBase(view) {
211
+ const flags = view.getUint16(FIB_FLAGS, true);
212
+ let nFib = view.getUint16(FIB_NFIB, true);
213
+ // nFibNew supersedes nFib when FibRgCswNew is present.
214
+ if (view.byteLength >= FIB_RG_FC_LCB) {
215
+ const cbRgFcLcb = view.getUint16(FIB_CB_RG_FC_LCB, true);
216
+ const cswNewOff = FIB_RG_FC_LCB + cbRgFcLcb * 8;
217
+ if (cswNewOff + 4 <= view.byteLength && view.getUint16(cswNewOff, true) > 0) {
218
+ nFib = view.getUint16(cswNewOff + 2, true);
219
+ }
220
+ }
221
+ const cQuickSaves = (flags & FIB_CQUICKSAVES_MASK) >> FIB_CQUICKSAVES_SHIFT;
222
+ // fComplex: the last save was incremental. cQuickSaves counts those saves
223
+ // only for Word 97 files; Word 2000+ always writes 0xF there.
224
+ const fastSaved = (flags & FIB_F_COMPLEX) !== 0
225
+ || (nFib < NFIB_WORD_2000 && cQuickSaves > 0);
226
+ const encrypted = (flags & (FIB_F_ENCRYPTED | FIB_F_OBFUSCATED)) !== 0;
227
+ return {
228
+ flags,
229
+ nFib,
230
+ whichTable: (flags & FIB_F_WHICH_TBL_STM) ? 1 : 0,
231
+ encrypted,
232
+ fastSaved,
233
+ };
234
+ }
235
+ /**
236
+ * Fail closed on files this module cannot redact completely:
237
+ * - encrypted / XOR-obfuscated: the text is ciphertext, detection sees noise
238
+ * and the export would be the untouched ciphertext (L2);
239
+ * - fast-saved (incremental save): deleted text lives in WordDocument regions
240
+ * outside the piece table, so it never reaches the detector and cannot be
241
+ * located for scrubbing (H5). The user has to resave the file in Word.
242
+ */
243
+ function assertSupportedFib(fib) {
244
+ if (fib.encrypted) {
245
+ throw new UnsupportedDocumentError('encrypted', 'This .doc file is encrypted or obfuscated. Remove the password in Word and save it again, or save as .docx.');
246
+ }
247
+ if (fib.fastSaved) {
248
+ throw new UnsupportedDocumentError('fast-saved', 'This .doc file was fast-saved (incremental save) and may hold deleted text outside the visible document. Open it in Word and Save As, or save as .docx.');
249
+ }
250
+ }
251
+ function containerHasPath(container, name) {
252
+ const needle = name.toLowerCase();
253
+ return container.FullPaths.some((p) => {
254
+ const rel = p.replace(/^Root Entry\//i, '').replace(/\/$/, '').toLowerCase();
255
+ return rel === needle || rel.startsWith(needle + '/');
256
+ });
257
+ }
258
+ /**
259
+ * Cheap look at a legacy .doc without parsing the piece table: which refusal
260
+ * flags are set and which non-text streams the file carries. Hosts use it to
261
+ * show a warning next to the export (ObjectPool / Macros are copied through
262
+ * unchanged, see writeAnonymizedDoc) or to explain a refusal up front.
263
+ * Throws only when the file is not a .doc at all.
264
+ */
265
+ export function inspectDoc(buffer) {
266
+ const data = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
267
+ const container = CFB.parse(data);
268
+ const wordDocEntry = CFB.find(container, '/WordDocument') ?? CFB.find(container, 'WordDocument');
269
+ if (!wordDocEntry?.content)
270
+ throw new Error('Invalid .doc file: missing WordDocument stream');
271
+ const wordDoc = toUint8Array(wordDocEntry.content);
272
+ const view = new DataView(wordDoc.buffer, wordDoc.byteOffset, wordDoc.byteLength);
273
+ if (view.byteLength < 0x20 || view.getUint16(0, true) !== 0xa5ec) {
274
+ throw new Error('Invalid .doc file: bad magic number');
275
+ }
276
+ const fib = readFibBase(view);
277
+ return {
278
+ encrypted: fib.encrypted,
279
+ fastSaved: fib.fastSaved,
280
+ streams: {
281
+ data: containerHasPath(container, 'Data'),
282
+ objectPool: containerHasPath(container, 'ObjectPool'),
283
+ macros: containerHasPath(container, 'Macros'),
284
+ },
285
+ };
286
+ }
185
287
  // ─── Shared parsing ──────────────────────────────────────────────────────────
186
288
  function parseDocStreams(buffer) {
187
289
  const data = new Uint8Array(buffer);
@@ -195,9 +297,9 @@ function parseDocStreams(buffer) {
195
297
  if (wIdent !== 0xa5ec) {
196
298
  throw new Error('Invalid .doc file: bad magic number');
197
299
  }
198
- const flags = wordView.getUint16(0x000a, true);
199
- const whichTbl = (flags >> 9) & 1;
200
- const tblName = whichTbl ? '1Table' : '0Table';
300
+ const fib = readFibBase(wordView);
301
+ assertSupportedFib(fib);
302
+ const tblName = fib.whichTable ? '1Table' : '0Table';
201
303
  const tableEntry = CFB.find(container, '/' + tblName) ?? CFB.find(container, tblName);
202
304
  if (!tableEntry?.content)
203
305
  throw new Error(`Invalid .doc file: missing ${tblName} stream`);
@@ -284,44 +386,70 @@ function extractDocContent(parsed) {
284
386
  return { text, cpMap };
285
387
  }
286
388
  // ─── Main: write anonymized .doc ─────────────────────────────────────────────
287
- export async function writeAnonymizedDoc(buffer, replacements) {
389
+ /**
390
+ * Write a redacted copy of a legacy .doc.
391
+ *
392
+ * `replacements` are offsets into the text returned by readDocText. Main-text
393
+ * ranges are swapped for the replacement string through a rewritten piece
394
+ * table; ranges in subdocuments (footnotes, headers, comments) are overwritten
395
+ * in place. `valueReplacements` (same shape as writeAnonymizedDocx) add
396
+ * original values that were not located by offset, e.g. session entries.
397
+ *
398
+ * Every original value (from both sources) is then destroyed wherever else it
399
+ * still occurs as bytes, without moving any structure (see scrubStreamBytes).
400
+ *
401
+ * Refuses (UnsupportedDocumentError) fast-saved and encrypted files, see
402
+ * assertSupportedFib.
403
+ *
404
+ * Copied through untouched: ObjectPool (embedded OLE objects), Macros (VBA)
405
+ * and \x01CompObj. TODO(T196, T199): delete them with CFB.utils.cfb_del once
406
+ * the open-in-Word / LibreOffice experiment proves the result still opens.
407
+ * Until then hosts should call inspectDoc and warn when they are present.
408
+ */
409
+ export async function writeAnonymizedDoc(buffer, replacements, valueReplacements = []) {
288
410
  const parsed = parseDocStreams(buffer);
289
411
  const { container, wordDocEntry, tableEntry, wordDoc: origWordDoc, wordView: wv, tableDoc: origTable, ccpText, pieces } = parsed;
290
412
  // Author/title/company metadata lives outside the text streams; scrub it always
291
413
  scrubOleMetadataStreams(container);
292
- if (replacements.length === 0) {
293
- // Still scrub the associated-strings table (author, template paths) in place
294
- scrubSttbfAssoc(origTable, wv);
295
- wordDocEntry.content = origWordDoc;
296
- tableEntry.content = origTable;
297
- const bytes = CFB.write(container, { type: 'array' });
298
- return new Blob([new Uint8Array(bytes)], { type: 'application/msword' });
299
- }
300
- // ── Read remaining FIB pointers ──
301
- const fcPlcfBteChpx = wv.getInt32(0x00fa, true);
302
- const lcbPlcfBteChpx = wv.getInt32(0x00fe, true);
303
- const fcPlcfBtePapx = wv.getInt32(0x0102, true);
304
- const lcbPlcfBtePapx = wv.getInt32(0x0106, true);
305
- const fcPlcfSed = wv.getInt32(0x00ca, true);
306
- const lcbPlcfSed = wv.getInt32(0x00ce, true);
307
414
  // ── Map replacement offsets (normalized text space) to CP space ──
308
415
  // The offsets the caller passes are positions in the text produced by
309
416
  // readDocText. Normalization drops/collapses control characters, so those
310
417
  // offsets shift against CPs; apply the recorded map to land exactly.
311
418
  const { text, cpMap } = extractDocContent(parsed);
312
419
  const cpReplacements = [];
420
+ const originals = valueReplacements.map((v) => v.value);
313
421
  for (const repl of normalizeReplacements(replacements)) {
314
422
  if (repl.start < 0 || repl.end > text.length || repl.start >= repl.end) {
315
423
  // Offsets that do not match the extracted text indicate a caller bug.
316
424
  // Fail closed: never export a file whose redaction we cannot place.
317
425
  throw new Error('Replacement offsets do not match the document text');
318
426
  }
427
+ originals.push(text.slice(repl.start, repl.end));
319
428
  cpReplacements.push({
320
429
  start: cpMap[repl.start],
321
430
  end: cpMap[repl.end - 1] + 1,
322
431
  replacement: repl.replacement,
323
432
  });
324
433
  }
434
+ const needles = buildScrubNeedles(originals, [
435
+ ...valueReplacements.map((v) => v.replacement),
436
+ ...replacements.map((r) => r.replacement),
437
+ ]);
438
+ if (replacements.length === 0) {
439
+ // No structural rewrite: scrub the string tables and remnant bytes in place
440
+ finalizeDocStreams(container, origWordDoc, origWordDoc.length, origTable, wv, needles);
441
+ wordDocEntry.content = origWordDoc;
442
+ tableEntry.content = origTable;
443
+ const bytes = CFB.write(container, { type: 'array' });
444
+ return new Blob([new Uint8Array(bytes)], { type: 'application/msword' });
445
+ }
446
+ // ── Read remaining FIB pointers ──
447
+ const fcPlcfBteChpx = wv.getInt32(0x00fa, true);
448
+ const lcbPlcfBteChpx = wv.getInt32(0x00fe, true);
449
+ const fcPlcfBtePapx = wv.getInt32(0x0102, true);
450
+ const lcbPlcfBtePapx = wv.getInt32(0x0106, true);
451
+ const fcPlcfSed = wv.getInt32(0x00ca, true);
452
+ const lcbPlcfSed = wv.getInt32(0x00ce, true);
325
453
  // Main-text replacements go through the piece table (labeled placeholders).
326
454
  // Replacements in subdocuments (footnotes, headers, comments) are redacted
327
455
  // in place with length-preserving overwrites: shifting CPs there would
@@ -462,8 +590,10 @@ export async function writeAnonymizedDoc(buffer, replacements) {
462
590
  fv.setInt32(0x00ca, newFcPlcfSed, true);
463
591
  fv.setInt32(0x00ce, newPlcfSed.length, true);
464
592
  }
465
- // ── Scrub associated strings (author, last-saved-by, template/data paths) ──
466
- scrubSttbfAssoc(newTable, fv);
593
+ // ── String tables, Data stream and remnant bytes ──
594
+ // The appended region of newWordDoc holds only replacement text and the
595
+ // FKPs built above, so the byte scrub covers the copied original part.
596
+ finalizeDocStreams(container, newWordDoc, origWordDoc.length, newTable, fv, needles);
467
597
  // ── Write back to OLE2 container ──
468
598
  // CFB.write sizes streams from entry.size, not content length; keep in sync
469
599
  wordDocEntry.content = newWordDoc;
@@ -519,57 +649,312 @@ function overwritePieceBytes(target, pieces, cpStart, cpEnd, fillChar) {
519
649
  }
520
650
  }
521
651
  // ─── Metadata scrubbing ──────────────────────────────────────────────────────
652
+ // FibRgFcLcb97 (MS-DOC 2.5.5): the fc/lcb pairs start at 0x9A, 8 bytes each,
653
+ // so entry i has fc at 0x9A + 8*i and lcb at 0x9E + 8*i.
654
+ /** Entry 21: SttbfBkmk, bookmark names. */
655
+ const FC_STTBF_BKMK = 0x0142;
656
+ const LCB_STTBF_BKMK = 0x0146;
657
+ /** Entry 32: SttbfAssoc, author, last-saved-by, template and data-source paths. */
522
658
  const FC_STTBF_ASSOC = 0x019a;
523
659
  const LCB_STTBF_ASSOC = 0x019e;
660
+ /** Entry 35: AutosaveSource. MS-DOC says lcb MUST be 0; older Word versions
661
+ * wrote the autosave path here (an Xst). */
662
+ const FC_AUTOSAVE_SOURCE = 0x01b2;
663
+ const LCB_AUTOSAVE_SOURCE = 0x01b6;
664
+ /** Entry 36: GrpXstAtnOwners, comment author names (array of Xst). */
665
+ const FC_GRP_XST_ATN_OWNERS = 0x01ba;
666
+ const LCB_GRP_XST_ATN_OWNERS = 0x01be;
667
+ /** Entry 51: SttbfRMark, revision (tracked change) author names. */
668
+ const FC_STTBF_RMARK = 0x0232;
669
+ const LCB_STTBF_RMARK = 0x0236;
670
+ /** Entry 71: SttbSavedBy, alternating user names and file paths of past saves. */
671
+ const FC_STTB_SAVED_BY = 0x02d2;
672
+ const LCB_STTB_SAVED_BY = 0x02d6;
524
673
  /**
525
- * Scrub the associated-strings table (SttbfAssoc) in the Table stream. It
526
- * holds the document author, last-saved-by name, attached template path and
527
- * mail-merge data source path. Strings are space-filled in place so no
528
- * structure moves; if the table cannot be parsed, it is blanked wholesale and
529
- * disconnected via the FIB.
674
+ * Space-fill every string of an STTB (MS-DOC 2.9.271) in place. Handles the
675
+ * extended (0xFFFF marker, UTF-16) and the 8-bit layouts; cbExtra bytes after
676
+ * each string are kept. Throws when the layout does not add up so the caller
677
+ * can blank the region wholesale instead of trusting it.
530
678
  */
531
- function scrubSttbfAssoc(table, fibView) {
532
- const fc = fibView.getInt32(FC_STTBF_ASSOC, true);
533
- const lcb = fibView.getInt32(LCB_STTBF_ASSOC, true);
534
- if (lcb <= 0 || fc < 0 || fc + lcb > table.length)
535
- return;
536
- try {
537
- const v = new DataView(table.buffer, table.byteOffset + fc, lcb);
538
- let offset = 0;
539
- const extended = v.getUint16(0, true) === 0xffff;
540
- if (extended)
541
- offset = 2;
542
- const count = v.getUint16(offset, true);
543
- const cbExtra = v.getUint16(offset + 2, true);
544
- offset += 4;
545
- if (count > 4096)
546
- throw new Error('bad sttbf');
547
- for (let i = 0; i < count; i++) {
548
- let cch;
549
- if (extended) {
550
- cch = v.getUint16(offset, true);
551
- offset += 2;
552
- for (let j = 0; j < cch; j++) {
553
- v.setUint16(offset + j * 2, 0x0020, true);
554
- }
555
- offset += cch * 2 + cbExtra;
679
+ function scrubSttb(table, fc, lcb) {
680
+ const v = new DataView(table.buffer, table.byteOffset + fc, lcb);
681
+ let offset = 0;
682
+ const extended = v.getUint16(0, true) === 0xffff;
683
+ if (extended)
684
+ offset = 2;
685
+ const count = v.getUint16(offset, true);
686
+ const cbExtra = v.getUint16(offset + 2, true);
687
+ offset += 4;
688
+ if (count > 4096)
689
+ throw new Error('bad sttb');
690
+ for (let i = 0; i < count; i++) {
691
+ let cch;
692
+ if (extended) {
693
+ cch = v.getUint16(offset, true);
694
+ offset += 2;
695
+ if (offset + cch * 2 > lcb)
696
+ throw new Error('bad sttb');
697
+ for (let j = 0; j < cch; j++) {
698
+ v.setUint16(offset + j * 2, 0x0020, true);
556
699
  }
557
- else {
558
- cch = v.getUint8(offset);
559
- offset += 1;
560
- for (let j = 0; j < cch; j++) {
561
- v.setUint8(offset + j, 0x20);
562
- }
563
- offset += cch + cbExtra;
700
+ offset += cch * 2 + cbExtra;
701
+ }
702
+ else {
703
+ cch = v.getUint8(offset);
704
+ offset += 1;
705
+ if (offset + cch > lcb)
706
+ throw new Error('bad sttb');
707
+ for (let j = 0; j < cch; j++) {
708
+ v.setUint8(offset + j, 0x20);
564
709
  }
565
- if (offset > lcb)
566
- throw new Error('bad sttbf');
710
+ offset += cch + cbExtra;
567
711
  }
712
+ if (offset > lcb)
713
+ throw new Error('bad sttb');
714
+ }
715
+ }
716
+ /**
717
+ * Space-fill every Xst (cch + UTF-16 chars, MS-DOC 2.9.352) of a packed group
718
+ * such as GrpXstAtnOwners. Throws on a layout mismatch.
719
+ */
720
+ function scrubXstGroup(table, fc, lcb) {
721
+ const v = new DataView(table.buffer, table.byteOffset + fc, lcb);
722
+ let offset = 0;
723
+ while (offset + 2 <= lcb) {
724
+ const cch = v.getUint16(offset, true);
725
+ offset += 2;
726
+ if (offset + cch * 2 > lcb)
727
+ throw new Error('bad xst group');
728
+ for (let j = 0; j < cch; j++)
729
+ v.setUint16(offset + j * 2, 0x0020, true);
730
+ offset += cch * 2;
731
+ }
732
+ if (offset !== lcb)
733
+ throw new Error('bad xst group');
734
+ }
735
+ /**
736
+ * Read the fc/lcb pair, run `scrub` over the region and, if the structure
737
+ * cannot be parsed, zero the region and disconnect it via the FIB. Offsets
738
+ * of everything else in the Table stream are untouched either way.
739
+ */
740
+ function scrubTableRegion(table, fibView, fcOffset, lcbOffset, scrub) {
741
+ if (lcbOffset + 4 > fibView.byteLength)
742
+ return;
743
+ const fc = fibView.getInt32(fcOffset, true);
744
+ const lcb = fibView.getInt32(lcbOffset, true);
745
+ if (lcb <= 0 || fc < 0 || fc + lcb > table.length)
746
+ return;
747
+ try {
748
+ scrub(table, fc, lcb);
568
749
  }
569
750
  catch {
570
- // Unknown layout: blank the whole range and drop the FIB reference
571
751
  table.fill(0, fc, fc + lcb);
572
- fibView.setInt32(LCB_STTBF_ASSOC, 0, true);
752
+ fibView.setInt32(lcbOffset, 0, true);
753
+ }
754
+ }
755
+ /**
756
+ * Zero the region and set its lcb to 0. Used for AutosaveSource, whose lcb
757
+ * MS-DOC requires to be 0 anyway; nothing references the bytes afterwards.
758
+ */
759
+ function blankTableRegion(table, fibView, fcOffset, lcbOffset) {
760
+ if (lcbOffset + 4 > fibView.byteLength)
761
+ return;
762
+ const fc = fibView.getInt32(fcOffset, true);
763
+ const lcb = fibView.getInt32(lcbOffset, true);
764
+ if (lcb <= 0 || fc < 0 || fc + lcb > table.length)
765
+ return;
766
+ table.fill(0, fc, fc + lcb);
767
+ fibView.setInt32(lcbOffset, 0, true);
768
+ }
769
+ /**
770
+ * Scrub every Table-stream string table that carries people or paths:
771
+ * SttbfAssoc (author, last-saved-by, template), SttbfRMark (revision
772
+ * authors), GrpXstAtnOwners (comment authors), SttbfBkmk (bookmark names),
773
+ * SttbSavedBy (save history) and the AutosaveSource path. Strings are
774
+ * space-filled in place so indices referenced from CHPX / PLCs stay valid.
775
+ */
776
+ function scrubTableStringTables(table, fibView) {
777
+ scrubTableRegion(table, fibView, FC_STTBF_ASSOC, LCB_STTBF_ASSOC, scrubSttb);
778
+ scrubTableRegion(table, fibView, FC_STTBF_RMARK, LCB_STTBF_RMARK, scrubSttb);
779
+ scrubTableRegion(table, fibView, FC_STTBF_BKMK, LCB_STTBF_BKMK, scrubSttb);
780
+ scrubTableRegion(table, fibView, FC_STTB_SAVED_BY, LCB_STTB_SAVED_BY, scrubSttb);
781
+ scrubTableRegion(table, fibView, FC_GRP_XST_ATN_OWNERS, LCB_GRP_XST_ATN_OWNERS, scrubXstGroup);
782
+ blankTableRegion(table, fibView, FC_AUTOSAVE_SOURCE, LCB_AUTOSAVE_SOURCE);
783
+ }
784
+ // ─── Length-preserving byte scrub ────────────────────────────────────────────
785
+ /** Values shorter than this are not searched for: too many accidental hits. */
786
+ const MIN_SCRUB_NEEDLE = 4;
787
+ /**
788
+ * Derive the byte-scrub needles from the original values: each value itself,
789
+ * each whitespace-separated token and each token stripped of surrounding
790
+ * punctuation, keeping those of at least MIN_SCRUB_NEEDLE characters.
791
+ * Matching is case-insensitive (see scrubStreamBytes), which covers every
792
+ * case variant (as written, lower, UPPER, Capitalised) in one pass.
793
+ *
794
+ * A needle that occurs (case-insensitively) inside one of the `replacements`
795
+ * is dropped: a token such as "Person" from "Leaky Person" would otherwise
796
+ * blank the "[PERSON_1]" placeholder, and a surrogate that keeps a word of
797
+ * the original ("Smith & Co" -> "Nowak & Co") would lose it. The full
798
+ * original value is never dropped.
799
+ */
800
+ export function buildScrubNeedles(originals, replacements = []) {
801
+ const protectedText = replacements.filter((r) => typeof r === 'string').map((r) => r.toLowerCase());
802
+ const values = new Set();
803
+ const out = new Set();
804
+ const add = (s, isValue) => {
805
+ const t = s.trim();
806
+ if (t.length < MIN_SCRUB_NEEDLE)
807
+ return;
808
+ if (isValue)
809
+ values.add(t);
810
+ else if (values.has(t))
811
+ return;
812
+ else {
813
+ const lower = t.toLowerCase();
814
+ if (protectedText.some((r) => r.includes(lower)))
815
+ return;
816
+ }
817
+ out.add(t);
818
+ };
819
+ for (const original of originals) {
820
+ if (typeof original !== 'string')
821
+ continue;
822
+ add(original, true);
823
+ }
824
+ for (const original of originals) {
825
+ if (typeof original !== 'string')
826
+ continue;
827
+ for (const token of original.split(/\s+/)) {
828
+ add(token, false);
829
+ add(token.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ''), false);
830
+ }
831
+ }
832
+ // Longest first so a full value is blanked before its tokens are considered
833
+ return [...out].sort((a, b) => b.length - a.length);
834
+ }
835
+ let cp1252Reverse;
836
+ /** Reverse of CP1252_MAP (declared below), built on first use. */
837
+ function cp1252ReverseMap() {
838
+ if (!cp1252Reverse) {
839
+ cp1252Reverse = new Map(Object.entries(CP1252_MAP).map(([byte, code]) => [code, Number(byte)]));
840
+ }
841
+ return cp1252Reverse;
842
+ }
843
+ /** Encode to cp1252 bytes, or null when a character has no cp1252 form. */
844
+ function encodeCp1252(text) {
845
+ const out = new Uint8Array(text.length);
846
+ for (let i = 0; i < text.length; i++) {
847
+ const code = text.charCodeAt(i);
848
+ if (code < 0x80 || (code >= 0xa0 && code <= 0xff)) {
849
+ out[i] = code;
850
+ }
851
+ else {
852
+ const mapped = cp1252ReverseMap().get(code);
853
+ if (mapped === undefined)
854
+ return null;
855
+ out[i] = mapped;
856
+ }
857
+ }
858
+ return out;
859
+ }
860
+ function foldCodeUnit(code) {
861
+ if (code >= 0x41 && code <= 0x5a)
862
+ return code + 0x20;
863
+ if (code < 0x80)
864
+ return code;
865
+ return String.fromCharCode(code).toLowerCase().charCodeAt(0);
866
+ }
867
+ function foldCp1252Byte(byte) {
868
+ if (byte >= 0x41 && byte <= 0x5a)
869
+ return byte + 0x20;
870
+ if (byte < 0x80)
871
+ return byte;
872
+ return foldCodeUnit(cp1252ToChar(byte).charCodeAt(0) || byte);
873
+ }
874
+ /**
875
+ * Overwrite every case-insensitive occurrence of `needle` in `bytes` with
876
+ * spaces of the same encoding. UTF-16LE: units are read at every byte
877
+ * offset (strings in the Table stream are not always 2-byte aligned) and
878
+ * replaced by 0x20 0x00; cp1252: bytes replaced by 0x20. Returns the number
879
+ * of hits. Stream length never changes.
880
+ */
881
+ export function scrubStreamBytes(bytes, needle, encoding) {
882
+ const folded = [];
883
+ if (encoding === 'cp1252') {
884
+ const encoded = encodeCp1252(needle);
885
+ if (!encoded)
886
+ return 0;
887
+ for (const b of encoded)
888
+ folded.push(foldCp1252Byte(b));
889
+ }
890
+ else {
891
+ for (let i = 0; i < needle.length; i++)
892
+ folded.push(foldCodeUnit(needle.charCodeAt(i)));
893
+ }
894
+ const n = folded.length;
895
+ if (n === 0)
896
+ return 0;
897
+ const unit = encoding === 'utf16le' ? 2 : 1;
898
+ const span = n * unit;
899
+ const first = folded[0];
900
+ let hits = 0;
901
+ for (let i = 0; i + span <= bytes.length; i++) {
902
+ // Fast reject on the first unit before folding the rest
903
+ const u0 = unit === 2 ? bytes[i] | (bytes[i + 1] << 8) : bytes[i];
904
+ if ((unit === 2 ? foldCodeUnit(u0) : foldCp1252Byte(u0)) !== first)
905
+ continue;
906
+ let match = true;
907
+ for (let j = 1; j < n; j++) {
908
+ const pos = i + j * unit;
909
+ const u = unit === 2 ? bytes[pos] | (bytes[pos + 1] << 8) : bytes[pos];
910
+ if ((unit === 2 ? foldCodeUnit(u) : foldCp1252Byte(u)) !== folded[j]) {
911
+ match = false;
912
+ break;
913
+ }
914
+ }
915
+ if (!match)
916
+ continue;
917
+ for (let j = 0; j < n; j++) {
918
+ bytes[i + j * unit] = 0x20;
919
+ if (unit === 2)
920
+ bytes[i + j * unit + 1] = 0x00;
921
+ }
922
+ hits++;
923
+ i += span - 1;
924
+ }
925
+ return hits;
926
+ }
927
+ /**
928
+ * Last pass over the three streams before the container is written:
929
+ * 1. string tables in Table (scrubTableStringTables);
930
+ * 2. remnant bytes of every original value (buildScrubNeedles):
931
+ * - WordDocument, first `wordDocScanLength` bytes (the copied original;
932
+ * the appended replacement text is excluded), UTF-16LE only;
933
+ * - Table, UTF-16LE and cp1252 (STTB remnants, 8-bit layouts);
934
+ * - Data (hyperlink field data, OLE previews), UTF-16LE only.
935
+ * cp1252 is deliberately NOT searched in Data or WordDocument: a 4-letter
936
+ * name has a real chance (about 2e-4 per name per MB) of matching bytes
937
+ * inside embedded JPEG/PNG data, and overwriting those would corrupt the
938
+ * picture. UTF-16 patterns (letter, 0x00, letter, 0x00) do not occur in
939
+ * compressed image data. Every overwrite is length-preserving.
940
+ */
941
+ function finalizeDocStreams(container, wordDoc, wordDocScanLength, table, fibView, needles) {
942
+ scrubTableStringTables(table, fibView);
943
+ if (needles.length === 0)
944
+ return;
945
+ const wordDocOriginal = wordDoc.subarray(0, Math.min(wordDocScanLength, wordDoc.length));
946
+ for (const needle of needles) {
947
+ scrubStreamBytes(wordDocOriginal, needle, 'utf16le');
948
+ scrubStreamBytes(table, needle, 'utf16le');
949
+ scrubStreamBytes(table, needle, 'cp1252');
950
+ }
951
+ const dataEntry = CFB.find(container, '/Data') ?? CFB.find(container, 'Data');
952
+ if (dataEntry?.content) {
953
+ const data = toUint8Array(dataEntry.content);
954
+ for (const needle of needles)
955
+ scrubStreamBytes(data, needle, 'utf16le');
956
+ dataEntry.content = data;
957
+ dataEntry.size = data.length;
573
958
  }
574
959
  }
575
960
  const OLE_META_STREAMS = ['SummaryInformation', 'DocumentSummaryInformation'];