@doccloak/core 0.11.0 → 0.12.1

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 +148 -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 +752 -125
  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 +135 -7
  16. package/dist/dom/opc.js +407 -14
  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 +130 -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 +101 -3
  41. package/dist/session.js +397 -43
  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
@@ -17,7 +17,12 @@
17
17
  * - Verifies downloads against a pinned SHA-256 when the caller provides one
18
18
  * (supply-chain hardening, T116): mismatches evict the cache entry and
19
19
  * reject with ModelIntegrityError; verified cache entries carry a marker
20
- * so they are not re-hashed on every startup.
20
+ * so they are not re-hashed on every startup. The marker records the
21
+ * verified byte size (T185): a cache hit whose blob size no longer matches
22
+ * the marker is re-hashed instead of trusted.
23
+ * - Fetches the pinned tokenizer files (tokenizer.json, tokenizer_config.json)
24
+ * through the same path (loadPinnedTokenizer, T185) so tokenizers get the
25
+ * same resume, retry, cache and integrity treatment as the model.
21
26
  *
22
27
  * Environment-agnostic: all host capabilities (blob cache, fetch, persistent
23
28
  * storage request) are injected via ModelLoaderEnv. This module never touches
@@ -62,6 +67,26 @@ export function verificationMarkerKey(url, sha256) {
62
67
  const sep = url.includes('?') ? '&' : '?';
63
68
  return `${url}${sep}doccloak-sha256-verified=${sha256}`;
64
69
  }
70
+ /**
71
+ * Value stored in a verification marker: `${sha256}:${size}`, binding the
72
+ * marker to the byte size of the blob that was actually hashed (T185, S4).
73
+ * A cache hit is only trusted without re-hashing when the cached blob's
74
+ * size equals the recorded one. Markers written by 0.11.0 hold the single
75
+ * byte '1' (size unknown): they are upgraded by hashing once and rewriting.
76
+ */
77
+ export function verificationMarkerValue(sha256, size) {
78
+ return `${sha256.toLowerCase()}:${size}`;
79
+ }
80
+ /**
81
+ * Size recorded in a marker value for the given hash, or null when the
82
+ * marker is the legacy 0.11.0 '1' or otherwise unreadable (size unknown).
83
+ */
84
+ export function parseVerificationMarker(value, sha256) {
85
+ const match = /^([0-9a-f]{64}):(\d{1,15})$/.exec(value.trim());
86
+ if (!match || match[1] !== sha256.toLowerCase())
87
+ return null;
88
+ return Number(match[2]);
89
+ }
65
90
  /**
66
91
  * SHA-256 of a blob as lowercase hex. WebCrypto has no streaming digest,
67
92
  * so the blob is hashed as one contiguous buffer: peak cost is one extra
@@ -73,13 +98,25 @@ async function sha256Hex(blob) {
73
98
  const digest = await crypto.subtle.digest('SHA-256', buffer);
74
99
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
75
100
  }
76
- /** Best-effort write of the verification marker (a 1-byte blob). */
77
- async function markVerified(cache, url, sha256) {
101
+ /** Best-effort write of the verification marker (`${sha256}:${size}`). */
102
+ async function markVerified(cache, url, sha256, size) {
78
103
  try {
79
- await cache.put(verificationMarkerKey(url, sha256), new Blob(['1']));
104
+ await cache.put(verificationMarkerKey(url, sha256), new Blob([verificationMarkerValue(sha256, size)]));
80
105
  }
81
106
  catch { /* marker is an optimisation - next startup just re-hashes */ }
82
107
  }
108
+ /** Size recorded by the marker for this url + hash; null if absent, legacy or unreadable. */
109
+ async function readMarkerSize(cache, url, sha256) {
110
+ const marker = await readFromCache(cache, verificationMarkerKey(url, sha256));
111
+ if (!marker)
112
+ return { present: false, size: null };
113
+ try {
114
+ return { present: true, size: parseVerificationMarker(await marker.text(), sha256) };
115
+ }
116
+ catch {
117
+ return { present: true, size: null };
118
+ }
119
+ }
83
120
  function isRetryable(err) {
84
121
  if (err instanceof HttpError) {
85
122
  return err.status === 408 || err.status === 429 || err.status >= 500;
@@ -203,6 +240,7 @@ export async function fetchModelBlob(env, url, onProgress, options) {
203
240
  const maxAttempts = options?.maxAttempts ?? MAX_ATTEMPTS;
204
241
  const retryBaseDelayMs = options?.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS;
205
242
  const expectedSha256 = options?.sha256?.toLowerCase();
243
+ const pinnedSize = options?.size;
206
244
  const cache = env.cache;
207
245
  const cachedBlob = await readFromCache(cache, url);
208
246
  if (cachedBlob) {
@@ -210,18 +248,24 @@ export async function fetchModelBlob(env, url, onProgress, options) {
210
248
  onProgress?.(cachedBlob.size, cachedBlob.size);
211
249
  return cachedBlob;
212
250
  }
213
- // Previously verified against this exact pin: skip the re-hash so
251
+ // Previously verified against this exact pin AND the cached blob still
252
+ // has the size recorded at verification time: skip the re-hash so
214
253
  // startup cost is unchanged.
215
- const marker = await readFromCache(cache, verificationMarkerKey(url, expectedSha256));
216
- if (marker) {
254
+ const marker = await readMarkerSize(cache, url, expectedSha256);
255
+ if (marker.present && marker.size === cachedBlob.size) {
217
256
  onProgress?.(cachedBlob.size, cachedBlob.size);
218
257
  options?.onVerified?.({ url, sha256: expectedSha256 });
219
258
  return cachedBlob;
220
259
  }
221
- // No marker (cache predates pinning, or the pin changed): hash once.
260
+ if (marker.present && marker.size !== null) {
261
+ console.warn(`[DocCloak] Cached model size ${cachedBlob.size} differs from its verification marker (${marker.size}), re-hashing`);
262
+ }
263
+ // No marker (cache predates pinning, or the pin changed), a legacy
264
+ // 0.11.0 marker without a size, or a size mismatch: hash once and
265
+ // (re)write the marker with the verified size.
222
266
  const actualSha256 = await sha256Hex(cachedBlob);
223
267
  if (actualSha256 === expectedSha256) {
224
- await markVerified(cache, url, expectedSha256);
268
+ await markVerified(cache, url, expectedSha256, cachedBlob.size);
225
269
  onProgress?.(cachedBlob.size, cachedBlob.size);
226
270
  options?.onVerified?.({ url, sha256: expectedSha256 });
227
271
  return cachedBlob;
@@ -237,7 +281,8 @@ export async function fetchModelBlob(env, url, onProgress, options) {
237
281
  if (!force && now - lastProgressAt < PROGRESS_INTERVAL_MS)
238
282
  return;
239
283
  lastProgressAt = now;
240
- onProgress?.(downloaded, total);
284
+ // The pinned size stands in for the total when the server sends none.
285
+ onProgress?.(downloaded, total || pinnedSize || 0);
241
286
  };
242
287
  let chunks = [];
243
288
  let downloaded = 0;
@@ -289,16 +334,17 @@ export async function fetchModelBlob(env, url, onProgress, options) {
289
334
  chunks = [];
290
335
  if (expectedSha256) {
291
336
  // Verify the final assembled blob (covers both fresh and resumed
292
- // downloads) BEFORE it is cached or handed to the runtime.
337
+ // downloads) BEFORE it is cached or handed to the runtime. A pinned
338
+ // size that disagrees is an integrity failure too.
293
339
  const actualSha256 = await sha256Hex(blob);
294
- if (actualSha256 !== expectedSha256) {
340
+ if (actualSha256 !== expectedSha256 || (pinnedSize !== undefined && blob.size !== pinnedSize)) {
295
341
  await evictModelFromCache(env, url, expectedSha256);
296
342
  throw new ModelIntegrityError(url, expectedSha256, actualSha256);
297
343
  }
298
344
  }
299
345
  const stored = await tryCachePut(cache, url, blob);
300
346
  if (expectedSha256 && stored)
301
- await markVerified(cache, url, expectedSha256);
347
+ await markVerified(cache, url, expectedSha256, blob.size);
302
348
  // Prefer the cached (disk-backed) copy so the in-memory chunks can be
303
349
  // collected before ONNX Runtime allocates its own copy of the model.
304
350
  const diskBlob = await readFromCache(cache, url);
@@ -325,10 +371,10 @@ export async function evictModelFromCache(env, url, sha256) {
325
371
  }
326
372
  }
327
373
  /**
328
- * Retry an async operation with exponential backoff. Used for the smaller
329
- * companion downloads (tokenizer files) that go through libraries without
330
- * their own retry handling - one dropped request on a flaky connection
331
- * should not abort the whole model load.
374
+ * Retry an async operation with exponential backoff. Kept for the
375
+ * deprecated CoreEnv.loadTokenizer path (a host library without its own
376
+ * retry handling) and for hosts composing their own downloads; the pinned
377
+ * tokenizer files now go through fetchModelBlob via loadPinnedTokenizer.
332
378
  */
333
379
  export async function retryAsync(operation, label, options) {
334
380
  const maxAttempts = options?.maxAttempts ?? MAX_ATTEMPTS;
@@ -345,3 +391,62 @@ export async function retryAsync(operation, label, options) {
345
391
  }
346
392
  }
347
393
  }
394
+ /**
395
+ * Download (or serve from cache) one pinned JSON file through fetchModelBlob
396
+ * with its sha256 and size, then parse it. Integrity failures surface as
397
+ * ModelIntegrityError with the entry evicted, like the model itself.
398
+ */
399
+ export async function fetchPinnedJson(env, file, options) {
400
+ const blob = await fetchModelBlob(env, file.url, undefined, {
401
+ ...options,
402
+ sha256: file.sha256,
403
+ size: file.size,
404
+ });
405
+ const text = await blob.text();
406
+ try {
407
+ return JSON.parse(text);
408
+ }
409
+ catch (err) {
410
+ throw new Error(`Pinned file is not valid JSON (${file.url}): ${err instanceof Error ? err.message : String(err)}`);
411
+ }
412
+ }
413
+ let warnedDeprecatedLoadTokenizer = false;
414
+ /**
415
+ * Resolve a provider's tokenizer through the host env.
416
+ *
417
+ * Preferred path: fetch both pinned files via fetchPinnedJson (resume,
418
+ * retry, blob cache, verification marker, ModelIntegrityError) and hand the
419
+ * parsed objects to CoreEnv.buildTokenizer. No library probes mutable
420
+ * resolve/main, no second cache bucket, and a warm cache works offline.
421
+ *
422
+ * Fallback (deprecated, removed in 0.13.0): hosts that only implement
423
+ * CoreEnv.loadTokenizer(hfModelId) are still served through it with the
424
+ * previous retry wrapper.
425
+ */
426
+ export async function loadPinnedTokenizer(env, files, legacyHfModelId, options) {
427
+ if (typeof env.buildTokenizer === 'function') {
428
+ const loaderEnv = {
429
+ cache: env.modelCache,
430
+ fetch: env.fetch,
431
+ persistStorage: env.persistStorage,
432
+ };
433
+ const [tokenizerJson, tokenizerConfig] = await Promise.all([
434
+ fetchPinnedJson(loaderEnv, files[0], options),
435
+ fetchPinnedJson(loaderEnv, files[1], options),
436
+ ]);
437
+ const tokenizer = await env.buildTokenizer(tokenizerJson, tokenizerConfig);
438
+ if (tokenizer === null || tokenizer === undefined) {
439
+ throw new Error('CoreEnv.buildTokenizer returned no tokenizer');
440
+ }
441
+ return tokenizer;
442
+ }
443
+ if (typeof env.loadTokenizer === 'function') {
444
+ if (!warnedDeprecatedLoadTokenizer) {
445
+ warnedDeprecatedLoadTokenizer = true;
446
+ console.warn('[DocCloak] CoreEnv.loadTokenizer is deprecated and will be removed in @doccloak/core 0.13.0; implement buildTokenizer(tokenizerJson, tokenizerConfig) instead');
447
+ }
448
+ // Call through env so host implementations keep their receiver.
449
+ return retryAsync(() => env.loadTokenizer(legacyHfModelId), 'Tokenizer download', options);
450
+ }
451
+ throw new Error('CoreEnv must implement buildTokenizer(tokenizerJson, tokenizerConfig); loadTokenizer(hfModelId) is deprecated');
452
+ }
@@ -1,19 +1,98 @@
1
1
  /**
2
2
  * Pure entity pipeline (T005).
3
3
  *
4
- * Extracted verbatim from the web app's detection.worker.ts: text and
5
- * entities in, entities out. No worker messaging, no providers, no
6
- * environment access. The worker (and any other host) composes these
7
- * functions around its own ML provider call.
4
+ * Extracted from the web app's detection.worker.ts: text and entities in,
5
+ * entities out. No worker messaging, no providers, no environment access.
6
+ * The worker (and any other host) composes these functions around its own
7
+ * ML provider call.
8
8
  *
9
- * Function bodies are byte-identical to the originals by design; only the
10
- * signatures of detectWithRegex (regexRegion becomes a parameter instead of
11
- * worker module state) and detectEntities (ML results are passed in instead
12
- * of fetched from a provider) differ.
9
+ * Security pass 2026-09:
10
+ * - T180: detectWithRegex runs every rule line by line (rules flagged
11
+ * `multiline` see the whole text), so quadratic patterns cost O(line^2)
12
+ * instead of O(document^2).
13
+ * - T181: filterFalsePositives is script-aware (CJK/Thai and PERSON values
14
+ * of two characters survive) and propagateEntities uses Unicode letter/
15
+ * digit boundaries, exact offsets on the original text and a common-word
16
+ * guard for lowercase PERSON matches.
13
17
  */
14
18
  import type { DetectedEntity } from './types.js';
19
+ /** One newline-free slice of the input plus its offset in the original text. */
20
+ interface TextSegment {
21
+ text: string;
22
+ offset: number;
23
+ }
24
+ /**
25
+ * Split on '\n' keeping offsets. '\r' stays inside the line (a CRLF line then
26
+ * ends with '\r', exactly as it did when the whole text was matched at once).
27
+ * Empty lines are skipped: no rule can match the empty string.
28
+ */
29
+ export declare function segmentLines(text: string): TextSegment[];
30
+ /**
31
+ * Run the regex rules of a region over the text.
32
+ *
33
+ * Rules without `multiline` run on each line separately (offsets are added
34
+ * back, so spans are document-absolute); rules with `multiline: true` run on
35
+ * the whole text. Output order is rule-major, then position, as before.
36
+ *
37
+ * Complexity note (T180): several street/company rules are quadratic in the
38
+ * length of their input. Per-line matching bounds that to the longest line,
39
+ * so 100 KB of ordinary prose is fast; a single 100 KB line without newlines
40
+ * is still slow until the patterns themselves are bounded (T198).
41
+ */
15
42
  export declare function detectWithRegex(text: string, regexRegion?: string): DetectedEntity[];
16
43
  export declare function resolveOverlaps(entities: DetectedEntity[]): DetectedEntity[];
44
+ /**
45
+ * Word-level providers (BardS.ai splits on whitespace) return spans that
46
+ * carry the punctuation glued to the last word: "Anna Nowak," or
47
+ * "jan@example.com.". The redacted text then loses that punctuation and the
48
+ * mapping stores it as part of the value. Trim a fixed set of opening and
49
+ * closing punctuation at both ends of every span (T204); a span that would
50
+ * become empty is dropped. Regex spans are not passed through this: their
51
+ * patterns already stop at the identifier.
52
+ */
53
+ export declare function trimSpanPunctuation(entities: DetectedEntity[]): DetectedEntity[];
54
+ /**
55
+ * Two-character PERSON values that are titles, suffixes or abbreviations,
56
+ * never names. Compared against the lowercased trimmed value (T181, R2).
57
+ */
58
+ export declare const PERSON_SHORT_STOPLIST: ReadonlySet<string>;
59
+ /**
60
+ * Drop ML spans that are too short to be real entities (R2):
61
+ * - values containing CJK or Thai characters: at least 2 code points, any type
62
+ * - PERSON: at least 2 characters, unless the value is a known 2-letter
63
+ * title/suffix (PERSON_SHORT_STOPLIST); initials like "J." pass
64
+ * - every other type: at least 3 characters (unchanged)
65
+ */
17
66
  export declare function filterFalsePositives(entities: DetectedEntity[]): DetectedEntity[];
67
+ /**
68
+ * Words that are also given names or surnames, per language (T181, R15).
69
+ * A PERSON term found in the text with DIFFERENT casing than the detected
70
+ * entity, and NOT starting with an uppercase letter, is skipped when its
71
+ * lowercase form is listed here: "Bill" the person never turns "the bill is
72
+ * due" into "[PERSON_1] is due". Capitalised and ALL-CAPS mentions always
73
+ * propagate, and lowercase mentions of ordinary names ("thanks john") are
74
+ * still caught (golden prop-03-case-insensitive).
75
+ *
76
+ * Only words that appear in lowercase in running prose belong here. German
77
+ * nouns are always capitalised in prose, so the DE list holds adjectives and
78
+ * month names that double as surnames or first names; the PL list holds
79
+ * nouns (animals, months, plants) that are frequent Polish surnames and
80
+ * first names.
81
+ */
82
+ export declare const COMMON_WORD_NAMES: Readonly<Record<'en' | 'pl' | 'de', readonly string[]>>;
83
+ /**
84
+ * Find every further occurrence of each detected value (and of the words of
85
+ * multi-word values) in the text and emit it as a 'propagated' entity.
86
+ *
87
+ * Terms: whole values of at least 2 characters; words of multi-word values
88
+ * of at least 4 characters (trailing punctuation stripped). Terms of at most
89
+ * 2 characters match with exact case; longer terms match case-insensitively
90
+ * (Unicode case folding via the 'iu' flags) on the ORIGINAL text, so offsets
91
+ * are exact even after characters whose lowercase form has a different
92
+ * length (Turkish dotted İ). A hit needs a non-letter, non-digit neighbour
93
+ * on both sides ([\p{L}\p{N}] complement), except on a side where the term
94
+ * itself ends in an unspaced script (CJK, Thai).
95
+ */
18
96
  export declare function propagateEntities(text: string, entities: DetectedEntity[]): DetectedEntity[];
19
97
  export declare function detectEntities(text: string, mlEntities: DetectedEntity[], regexEntities: DetectedEntity[]): DetectedEntity[];
98
+ export {};