@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.
- package/CHANGELOG.md +162 -2
- package/README.md +252 -19
- package/dist/doc.d.ts +68 -1
- package/dist/doc.js +447 -62
- package/dist/dom/docx.d.ts +135 -7
- package/dist/dom/docx.js +747 -134
- package/dist/dom/drawingml.d.ts +59 -0
- package/dist/dom/drawingml.js +176 -0
- package/dist/dom/errors.d.ts +35 -0
- package/dist/dom/errors.js +24 -0
- package/dist/dom/index.d.ts +3 -0
- package/dist/dom/index.js +6 -0
- package/dist/dom/office.d.ts +56 -0
- package/dist/dom/office.js +76 -23
- package/dist/dom/opc.d.ts +141 -7
- package/dist/dom/opc.js +424 -18
- package/dist/dom/package-policy.d.ts +137 -0
- package/dist/dom/package-policy.js +355 -0
- package/dist/dom/xlsx.d.ts +64 -6
- package/dist/dom/xlsx.js +75 -20
- package/dist/engine.d.ts +18 -0
- package/dist/engine.js +37 -13
- package/dist/env.d.ts +21 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +10 -5
- package/dist/model-loader.d.ts +61 -6
- package/dist/model-loader.js +122 -17
- package/dist/pipeline.d.ts +87 -8
- package/dist/pipeline.js +249 -50
- package/dist/providers/bardsai.d.ts +8 -1
- package/dist/providers/bardsai.js +22 -2
- package/dist/providers/gliner-base.d.ts +8 -1
- package/dist/providers/gliner-base.js +22 -2
- package/dist/providers/gliner.d.ts +8 -1
- package/dist/providers/gliner.js +22 -2
- package/dist/regex/loader.d.ts +2 -0
- package/dist/regex/loader.js +2 -0
- package/dist/regex/rules.data.js +15 -14
- package/dist/regex/types.d.ts +6 -0
- package/dist/session.d.ts +137 -4
- package/dist/session.js +591 -62
- package/dist/surrogates.js +57 -45
- package/dist/worker-protocol.d.ts +13 -1
- package/dist/worker-protocol.js +164 -12
- package/package.json +1 -1
- package/rules/at.json +1 -1
- package/rules/be.json +1 -1
- package/rules/ch.json +1 -1
- package/rules/de.json +1 -1
- package/rules/dk.json +1 -1
- package/rules/es.json +1 -1
- package/rules/fi.json +1 -1
- package/rules/fr.json +1 -1
- package/rules/it.json +1 -1
- package/rules/no.json +1 -1
- package/rules/pl.json +1 -1
- package/rules/pt.json +1 -1
- package/rules/schema.json +4 -0
- package/rules/se.json +1 -1
- package/rules/universal.json +2 -1
package/dist/session.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { buildTolerantTokenIndex, resolveMangledToken } from "./restore-tokens.js";
|
|
2
|
-
import { generateSessionSalt,
|
|
2
|
+
import { generateSessionSalt, generateSurrogate } from "./surrogates.js";
|
|
3
3
|
export * from "./restore-tokens.js";
|
|
4
4
|
// T099: credentials never get realistic stand-ins - a same-shape fake key
|
|
5
5
|
// still looks like a live credential to anyone (or any scanner) reading the
|
|
6
6
|
// prompt. Surrogate mode falls back to typed placeholders for these types.
|
|
7
7
|
const PLACEHOLDER_ONLY_TYPES = new Set(['SECRET', 'API_KEY']);
|
|
8
|
+
/**
|
|
9
|
+
* Edge punctuation stripped by personTokens. The run is BOUNDED (R14): an
|
|
10
|
+
* unbounded `[...]+$` backtracks from every position of a long punctuation
|
|
11
|
+
* run, which is quadratic on a hostile 100 KB span; {1,64} keeps it linear
|
|
12
|
+
* and 64 marks is more than any real name carries.
|
|
13
|
+
*/
|
|
14
|
+
const EDGE_PUNCTUATION_RE = /^[.,;:!?()"'\u201E\u201C\u201D]{1,64}|[.,;:!?()"'\u201E\u201C\u201D]{1,64}$/g;
|
|
8
15
|
/**
|
|
9
16
|
* Person-variant tokenization (T057): lowercase word tokens with edge
|
|
10
17
|
* punctuation stripped. EVERY token participates in the subset test -
|
|
@@ -14,7 +21,7 @@ function personTokens(value) {
|
|
|
14
21
|
return value
|
|
15
22
|
.toLowerCase()
|
|
16
23
|
.split(/\s+/)
|
|
17
|
-
.map((t) => t.replace(
|
|
24
|
+
.map((t) => t.replace(EDGE_PUNCTUATION_RE, ''))
|
|
18
25
|
.filter((t) => t.length > 0);
|
|
19
26
|
}
|
|
20
27
|
/**
|
|
@@ -31,8 +38,81 @@ function isPersonVariant(a, b) {
|
|
|
31
38
|
const tb = personTokens(b);
|
|
32
39
|
if (ta.length === 0 || tb.length === 0)
|
|
33
40
|
return false;
|
|
34
|
-
const
|
|
35
|
-
|
|
41
|
+
const fits = (small, big) => small.every((t) => hasToken(big, t)) && small.some((t) => t.length >= 3);
|
|
42
|
+
if (ta.length < tb.length)
|
|
43
|
+
return fits(ta, tb);
|
|
44
|
+
if (tb.length < ta.length)
|
|
45
|
+
return fits(tb, ta);
|
|
46
|
+
// Equal counts: either side may carry the hyphen-extended surname.
|
|
47
|
+
return fits(ta, tb) || fits(tb, ta);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether `token` occurs among `tokens`, exactly or as one part of a
|
|
51
|
+
* hyphenated token: "nowak" is found in "Anna Nowak-Kowalska", so the
|
|
52
|
+
* double-surname form still counts as the same person once matching is
|
|
53
|
+
* restricted to group references (R5) and can no longer ride on a
|
|
54
|
+
* shorter variant. A hyphenated token itself only matches exactly.
|
|
55
|
+
*/
|
|
56
|
+
function hasToken(tokens, token) {
|
|
57
|
+
return tokens.some((t) => t === token || (t.includes('-') && t.split('-').includes(token)));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Variant suffix (T171): where a person variant sits inside the reference
|
|
61
|
+
* name. A single token at the reference's first/last position is a first
|
|
62
|
+
* name / surname; a single token elsewhere is a middle name; several tokens
|
|
63
|
+
* are a shortened form; a variant with MORE tokens than the reference is the
|
|
64
|
+
* fuller form. Suffixes are stable uppercase ASCII so the resulting token
|
|
65
|
+
* ([PERSON_2_LAST]) stays inside the tolerant-restore candidate shape.
|
|
66
|
+
*/
|
|
67
|
+
function variantSuffix(variant, reference) {
|
|
68
|
+
const vt = personTokens(variant);
|
|
69
|
+
const rt = personTokens(reference);
|
|
70
|
+
if (vt.length > rt.length)
|
|
71
|
+
return 'FULL';
|
|
72
|
+
// Same token count but the reference fits inside a longer variant: a
|
|
73
|
+
// double surname extending the reference ("Nowak" -> "Nowak-Kowalska").
|
|
74
|
+
if (vt.length === rt.length && variant.length > reference.length && isTokenSubset(reference, variant)) {
|
|
75
|
+
return 'FULL';
|
|
76
|
+
}
|
|
77
|
+
if (vt.length === rt.length)
|
|
78
|
+
return 'ALT';
|
|
79
|
+
if (vt.length === 1) {
|
|
80
|
+
const idx = rt.indexOf(vt[0]);
|
|
81
|
+
if (idx === 0)
|
|
82
|
+
return 'FIRST';
|
|
83
|
+
if (idx === rt.length - 1)
|
|
84
|
+
return 'LAST';
|
|
85
|
+
return 'MIDDLE';
|
|
86
|
+
}
|
|
87
|
+
return 'SHORT';
|
|
88
|
+
}
|
|
89
|
+
/** Whether every person token of `a` occurs in `b`. */
|
|
90
|
+
function isTokenSubset(a, b) {
|
|
91
|
+
const tb = personTokens(b);
|
|
92
|
+
const ta = personTokens(a);
|
|
93
|
+
return ta.length > 0 && ta.every((t) => hasToken(tb, t));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A variant token derived from its base token: the suffix goes inside the
|
|
97
|
+
* closing bracket ("[PERSON_2]" -> "[PERSON_2_LAST]"); a renamed label
|
|
98
|
+
* without brackets gets a plain underscore suffix.
|
|
99
|
+
*/
|
|
100
|
+
function withVariantSuffix(base, suffix) {
|
|
101
|
+
return base.endsWith(']') ? `${base.slice(0, -1)}_${suffix}]` : `${base}_${suffix}`;
|
|
102
|
+
}
|
|
103
|
+
/** Variant token shape, for re-linking deserialized maps (T171). */
|
|
104
|
+
const VARIANT_TOKEN_RE = /^(\[.+?)_(FIRST|LAST|MIDDLE|SHORT|FULL|ALT)(?:_(\d+))?\]$/;
|
|
105
|
+
/**
|
|
106
|
+
* Apply the case pattern of `sample` to `word` (T171 surrogate variants):
|
|
107
|
+
* an all-lowercase "smith" maps to "nowak", an all-uppercase "SMITH" to
|
|
108
|
+
* "NOWAK"; anything else keeps the surrogate's own casing.
|
|
109
|
+
*/
|
|
110
|
+
function matchCase(word, sample) {
|
|
111
|
+
if (sample === sample.toLowerCase() && sample !== sample.toUpperCase())
|
|
112
|
+
return word.toLowerCase();
|
|
113
|
+
if (sample === sample.toUpperCase() && sample !== sample.toLowerCase())
|
|
114
|
+
return word.toUpperCase();
|
|
115
|
+
return word;
|
|
36
116
|
}
|
|
37
117
|
/**
|
|
38
118
|
* Typed placeholder token, e.g. "[PERSON_1]" or "[CREDIT_CARD_2]".
|
|
@@ -46,12 +126,130 @@ const TYPED_PLACEHOLDER_RE = /^\[([A-Z_]+)_(\d+)\]$/;
|
|
|
46
126
|
* safe - replacement still requires an unambiguous index hit.
|
|
47
127
|
*/
|
|
48
128
|
const MANGLED_CANDIDATE_RE = /\[[^[\]\n]+\]|<<[^<>\n]+>>|(?<![\p{L}\p{N}_[<])[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*_\d+(?![\p{L}\p{N}_])/gu;
|
|
129
|
+
/**
|
|
130
|
+
* Literal typed placeholders already present in USER text (R9): a
|
|
131
|
+
* re-processed document or a template carrying "[PERSON_1]" must not
|
|
132
|
+
* collide with the placeholders this session issues.
|
|
133
|
+
*/
|
|
134
|
+
const LITERAL_PLACEHOLDER_RE = /\[([A-Z_]+)_(\d+)\]/g;
|
|
135
|
+
/** Counter numbers beyond this many digits are ignored (never issued). */
|
|
136
|
+
const MAX_COUNTER_DIGITS = 9;
|
|
137
|
+
/** Upper bound for deserialize/importEntries (R11). */
|
|
138
|
+
const MAX_IMPORT_ENTRIES = 10000;
|
|
139
|
+
/**
|
|
140
|
+
* Surrogate re-derivations tried before the numeric-suffix fallback (R3).
|
|
141
|
+
* Every attempt is a fresh deterministic draw from the locale pools; with
|
|
142
|
+
* 40 x 40 names per locale and gender, 24 draws leave a starved pool (a
|
|
143
|
+
* document naming most of a pool) as the only way to reach the fallback.
|
|
144
|
+
*/
|
|
145
|
+
const MAX_SURROGATE_ATTEMPTS = 24;
|
|
146
|
+
/**
|
|
147
|
+
* Shortest document name token that the surrogate containment rule (R3)
|
|
148
|
+
* considers: a PERSON surrogate must not CONTAIN a document name of this
|
|
149
|
+
* length or more ("Johnson" for a document that names "John", "Marianna"
|
|
150
|
+
* for "Anna"). Same threshold as isPersonVariant's substantive token.
|
|
151
|
+
*/
|
|
152
|
+
const MIN_NAME_TOKEN_LENGTH = 3;
|
|
153
|
+
/** A token made of letters only (names); digits, emails and codes are not indexed for containment. */
|
|
154
|
+
const LETTERS_ONLY_RE = /^\p{L}+$/u;
|
|
155
|
+
/** Only regex syntax characters are escaped: valid under the `u` flag. */
|
|
156
|
+
function escapeRegExp(s) {
|
|
157
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Restore keys that are tokens rather than natural text: bracket
|
|
161
|
+
* placeholders (generated or renamed) and legacy angle tokens. They carry
|
|
162
|
+
* their own delimiters, so they match anywhere ("[PERSON_1]'s", glued
|
|
163
|
+
* "[PERSON_1][EMAIL_1]").
|
|
164
|
+
*/
|
|
165
|
+
const TOKEN_SHAPED_KEY_RE = /^(?:\[[^[\]\n]+\]|<<[^<>\n]+>>)$/;
|
|
166
|
+
/**
|
|
167
|
+
* Edge characters that ask for a word boundary on their side of a natural
|
|
168
|
+
* key: digits and letters of space-delimited scripts (Latin, Cyrillic,
|
|
169
|
+
* Greek). CJK keys stay unguarded because those scripts write words
|
|
170
|
+
* without separators (王伟说 must restore 王伟).
|
|
171
|
+
*/
|
|
172
|
+
const BOUNDED_EDGE_RE = /^[\p{N}\p{Script=Latin}\p{Script=Cyrillic}\p{Script=Greek}]$/u;
|
|
173
|
+
/**
|
|
174
|
+
* One alternation branch per restore key (R4). A natural key such as a
|
|
175
|
+
* surrogate name gets `(?<![\p{L}\p{N}])` only when it STARTS with a
|
|
176
|
+
* bounded-edge character and `(?![\p{L}\p{N}])` only when it ENDS with
|
|
177
|
+
* one, so "Bo" never fires inside "Bob" or "Boston" while "+48 601..."
|
|
178
|
+
* (starting with '+') and "$1,234" still match after any character.
|
|
179
|
+
*/
|
|
180
|
+
function restoreKeyPattern(key) {
|
|
181
|
+
const body = escapeRegExp(key);
|
|
182
|
+
if (TOKEN_SHAPED_KEY_RE.test(key))
|
|
183
|
+
return body;
|
|
184
|
+
const chars = [...key];
|
|
185
|
+
const first = chars[0] ?? '';
|
|
186
|
+
const last = chars[chars.length - 1] ?? '';
|
|
187
|
+
const lead = BOUNDED_EDGE_RE.test(first) ? '(?<![\\p{L}\\p{N}])' : '';
|
|
188
|
+
const trail = BOUNDED_EDGE_RE.test(last) ? '(?![\\p{L}\\p{N}])' : '';
|
|
189
|
+
return `${lead}${body}${trail}`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Shape check for one imported entry (R11): plain object, string fields,
|
|
193
|
+
* a non-blank replacement (an empty or whitespace key would match between
|
|
194
|
+
* every character and explode the restored text) and a non-empty original.
|
|
195
|
+
* Throws a descriptive Error naming the entry index.
|
|
196
|
+
*/
|
|
197
|
+
function validateImportEntry(entry, index) {
|
|
198
|
+
const where = `Invalid session map entry #${index}`;
|
|
199
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
200
|
+
throw new Error(`${where}: expected an object`);
|
|
201
|
+
}
|
|
202
|
+
const { original, replacement, entityType } = entry;
|
|
203
|
+
if (typeof original !== 'string' || original.length === 0) {
|
|
204
|
+
throw new Error(`${where}: "original" must be a non-empty string`);
|
|
205
|
+
}
|
|
206
|
+
if (typeof replacement !== 'string' || replacement.trim().length === 0) {
|
|
207
|
+
throw new Error(`${where}: "replacement" must be a non-blank string`);
|
|
208
|
+
}
|
|
209
|
+
if (typeof entityType !== 'string' || entityType.length === 0) {
|
|
210
|
+
throw new Error(`${where}: "entityType" must be a non-empty string`);
|
|
211
|
+
}
|
|
212
|
+
return { original, replacement, entityType: entityType };
|
|
213
|
+
}
|
|
49
214
|
export class AnonymizationSession {
|
|
50
215
|
constructor(options) {
|
|
51
216
|
this.forwardMap = new Map();
|
|
52
217
|
this.reverseMap = new Map();
|
|
53
218
|
this.entityTypeMap = new Map();
|
|
54
219
|
this.typeCounters = new Map();
|
|
220
|
+
/**
|
|
221
|
+
* Variant token -> its group's base token and suffix (T171), so renaming
|
|
222
|
+
* the base re-derives its variants and deserialized maps re-link.
|
|
223
|
+
*/
|
|
224
|
+
this.variantOf = new Map();
|
|
225
|
+
/**
|
|
226
|
+
* Compiled exact-restore alternation (R4), rebuilt lazily; null after
|
|
227
|
+
* every reverse-map mutation (see invalidateRestore).
|
|
228
|
+
*/
|
|
229
|
+
this.restoreRe = null;
|
|
230
|
+
/**
|
|
231
|
+
* Every value known to occur in the session's documents (R3), lowercased:
|
|
232
|
+
* whole entity values plus their person tokens (and hyphen parts). Fed by
|
|
233
|
+
* anonymizeText, anonymize, importEntries and registerDocumentValues;
|
|
234
|
+
* cumulative until clear(). A surrogate is never one of these. No
|
|
235
|
+
* capitalised-word heuristic: it would starve the pools in German and
|
|
236
|
+
* Polish prose (plan v2 section 9).
|
|
237
|
+
*/
|
|
238
|
+
this.documentValues = new Set();
|
|
239
|
+
/**
|
|
240
|
+
* Letter-only document tokens of MIN_NAME_TOKEN_LENGTH or more, bucketed
|
|
241
|
+
* by their first MIN_NAME_TOKEN_LENGTH characters, for the containment
|
|
242
|
+
* rule in isDocumentValue: linear in the candidate's length instead of
|
|
243
|
+
* a scan over every document token.
|
|
244
|
+
*/
|
|
245
|
+
this.documentNameIndex = new Map();
|
|
246
|
+
/**
|
|
247
|
+
* PERSON values that matched two or more mapped people equally well and
|
|
248
|
+
* therefore got a fresh identity instead of a guessed one (R5).
|
|
249
|
+
*/
|
|
250
|
+
this.ambiguousValues = new Set();
|
|
251
|
+
/** Whether the most recent anonymize() call refused an ambiguous unification. */
|
|
252
|
+
this.lastAmbiguousFlag = false;
|
|
55
253
|
this.mode = options?.mode ?? 'labeled';
|
|
56
254
|
this.salt = options?.salt ?? generateSessionSalt();
|
|
57
255
|
}
|
|
@@ -89,54 +287,193 @@ export class AnonymizationSession {
|
|
|
89
287
|
* deanonymize restores it via the same exact-literal lookup.
|
|
90
288
|
*/
|
|
91
289
|
nextSurrogate(original, entityType) {
|
|
92
|
-
|
|
290
|
+
// R3: besides the maps, a candidate is refused when it is (or, for a
|
|
291
|
+
// PERSON, contains) a value the documents carry. That rule cannot be
|
|
292
|
+
// satisfied by generateUniqueSurrogate's digit suffix (it only changes
|
|
293
|
+
// the last token), so the session runs its own attempt loop and the
|
|
294
|
+
// suffix fallback keeps the exact checks only.
|
|
295
|
+
const ctx = { salt: this.salt, entries: this.getEntries() };
|
|
296
|
+
const exactlyTaken = (candidate) => candidate === original
|
|
297
|
+
|| this.forwardMap.has(candidate)
|
|
298
|
+
|| this.reverseMap.has(candidate)
|
|
299
|
+
|| this.documentValues.has(candidate.toLowerCase());
|
|
300
|
+
let candidate = '';
|
|
301
|
+
for (let attempt = 0; attempt < MAX_SURROGATE_ATTEMPTS; attempt++) {
|
|
302
|
+
candidate = generateSurrogate(original, entityType, ctx, attempt);
|
|
303
|
+
if (!exactlyTaken(candidate) && !this.containsDocumentName(candidate, entityType)) {
|
|
304
|
+
return candidate;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (let n = 2;; n++) {
|
|
308
|
+
const suffixed = `${candidate}${n}`;
|
|
309
|
+
if (!exactlyTaken(suffixed))
|
|
310
|
+
return suffixed;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Whether a PERSON surrogate candidate carries a document name (R3):
|
|
315
|
+
* any of its tokens is a document value, or contains one of at least
|
|
316
|
+
* MIN_NAME_TOKEN_LENGTH letters ("Johnson" with "John" in the document,
|
|
317
|
+
* "Nowakowski" with "Nowak"). Other types are covered by the exact
|
|
318
|
+
* whole-value check alone: their tokens ("+48", "ul.", "GmbH") recur in
|
|
319
|
+
* every surrogate of the same shape and would starve the pools.
|
|
320
|
+
*/
|
|
321
|
+
containsDocumentName(candidate, entityType) {
|
|
322
|
+
if (entityType !== 'PERSON')
|
|
323
|
+
return false;
|
|
324
|
+
for (const token of personTokens(candidate)) {
|
|
325
|
+
if (this.documentValues.has(token))
|
|
326
|
+
return true;
|
|
327
|
+
for (let i = 0; i + MIN_NAME_TOKEN_LENGTH <= token.length; i++) {
|
|
328
|
+
const bucket = this.documentNameIndex.get(token.slice(i, i + MIN_NAME_TOKEN_LENGTH));
|
|
329
|
+
if (bucket && bucket.some((name) => token.startsWith(name, i)))
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
/** Record one document value: whole (lowercased), its tokens and hyphen parts. */
|
|
336
|
+
addDocumentValue(value) {
|
|
337
|
+
const whole = value.toLowerCase();
|
|
338
|
+
if (whole.length === 0)
|
|
339
|
+
return;
|
|
340
|
+
this.documentValues.add(whole);
|
|
341
|
+
for (const token of personTokens(value)) {
|
|
342
|
+
const parts = token.includes('-') ? [token, ...token.split('-').filter(Boolean)] : [token];
|
|
343
|
+
for (const part of parts) {
|
|
344
|
+
this.documentValues.add(part);
|
|
345
|
+
if (part.length >= MIN_NAME_TOKEN_LENGTH && LETTERS_ONLY_RE.test(part)) {
|
|
346
|
+
const key = part.slice(0, MIN_NAME_TOKEN_LENGTH);
|
|
347
|
+
const bucket = this.documentNameIndex.get(key);
|
|
348
|
+
if (bucket === undefined)
|
|
349
|
+
this.documentNameIndex.set(key, [part]);
|
|
350
|
+
else if (!bucket.includes(part))
|
|
351
|
+
bucket.push(part);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Declare values that occur in the session's documents (R3) so no later
|
|
358
|
+
* surrogate equals or, for people, contains one of them. anonymizeText
|
|
359
|
+
* registers its entities itself; hosts that call anonymize() value by
|
|
360
|
+
* value should register the whole entity list first, otherwise a person
|
|
361
|
+
* mapped early can still receive the name of a person mapped later.
|
|
362
|
+
* Accepts plain strings or objects with a `value` (DetectedEntity,
|
|
363
|
+
* ReplacementEntry-like `{ value }` shapes). Cumulative until clear().
|
|
364
|
+
*/
|
|
365
|
+
registerDocumentValues(input) {
|
|
366
|
+
for (const item of input) {
|
|
367
|
+
const value = typeof item === 'string' ? item : item.value;
|
|
368
|
+
if (typeof value === 'string')
|
|
369
|
+
this.addDocumentValue(value);
|
|
370
|
+
}
|
|
93
371
|
}
|
|
94
372
|
/**
|
|
95
|
-
*
|
|
96
|
-
* (
|
|
97
|
-
*
|
|
373
|
+
* Whether the most recent anonymize() call met an ambiguous PERSON value
|
|
374
|
+
* (R5): a bare name that fits two or more mapped people equally well.
|
|
375
|
+
* Such a value is NOT unified; it gets its own number or surrogate. For
|
|
376
|
+
* anonymizeText, which maps many values, use getAmbiguousValues().
|
|
377
|
+
*/
|
|
378
|
+
lastAmbiguous() {
|
|
379
|
+
return this.lastAmbiguousFlag;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Every PERSON value this session refused to unify because it matched
|
|
383
|
+
* two or more people equally well (R5), in mapping order. Cumulative
|
|
384
|
+
* until clear(); hosts can flag them for the user to resolve.
|
|
385
|
+
*/
|
|
386
|
+
getAmbiguousValues() {
|
|
387
|
+
return [...this.ambiguousValues];
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* One reference name per person group (R5): the fullest mapped original
|
|
391
|
+
* of each group (most tokens; the first mapped on ties). Variants never
|
|
392
|
+
* act as match targets on their own, so "Anna" cannot pull "Anna
|
|
393
|
+
* Kowalska" into Anna Nowak's group, while a group whose fuller form
|
|
394
|
+
* arrived later ("John" then "John Smith") still matches "Smith".
|
|
395
|
+
*/
|
|
396
|
+
personReferences() {
|
|
397
|
+
const byGroup = new Map();
|
|
398
|
+
for (const [original, token] of this.forwardMap) {
|
|
399
|
+
if (this.entityTypeMap.get(original) !== 'PERSON')
|
|
400
|
+
continue;
|
|
401
|
+
const base = this.variantOf.get(token)?.base ?? token;
|
|
402
|
+
const size = personTokens(original).length;
|
|
403
|
+
const current = byGroup.get(base);
|
|
404
|
+
if (current === undefined || size > current.size)
|
|
405
|
+
byGroup.set(base, { original, size });
|
|
406
|
+
}
|
|
407
|
+
return [...byGroup.values()].map((group) => group.original);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The mapped person that a new PERSON value is a variant of (T057):
|
|
411
|
+
* only group references are considered (see personReferences); most
|
|
412
|
+
* shared tokens wins. A tie between two or more people is AMBIGUOUS
|
|
413
|
+
* (R5): no guess is made, the value gets a fresh identity and the
|
|
414
|
+
* session records it (lastAmbiguous / getAmbiguousValues).
|
|
415
|
+
* Deterministic; match is null when nothing (or too much) matches.
|
|
98
416
|
*/
|
|
99
417
|
findPersonVariant(value) {
|
|
100
418
|
const tokens = personTokens(value);
|
|
101
419
|
if (tokens.length === 0)
|
|
102
|
-
return null;
|
|
420
|
+
return { match: null, ambiguous: false };
|
|
103
421
|
let best = null;
|
|
104
422
|
let bestShared = 0;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (!isPersonVariant(value, original))
|
|
423
|
+
let tied = false;
|
|
424
|
+
for (const reference of this.personReferences()) {
|
|
425
|
+
if (!isPersonVariant(value, reference))
|
|
109
426
|
continue;
|
|
110
|
-
const shared = personTokens(
|
|
427
|
+
const shared = personTokens(reference).filter((t) => hasToken(tokens, t)).length;
|
|
111
428
|
if (shared > bestShared) {
|
|
112
429
|
bestShared = shared;
|
|
113
|
-
best =
|
|
430
|
+
best = reference;
|
|
431
|
+
tied = false;
|
|
432
|
+
}
|
|
433
|
+
else if (shared === bestShared) {
|
|
434
|
+
tied = true;
|
|
114
435
|
}
|
|
115
436
|
}
|
|
116
|
-
|
|
437
|
+
if (tied)
|
|
438
|
+
return { match: null, ambiguous: true };
|
|
439
|
+
return { match: best, ambiguous: false };
|
|
117
440
|
}
|
|
118
441
|
anonymize(original, entityType) {
|
|
442
|
+
this.lastAmbiguousFlag = false;
|
|
119
443
|
if (this.forwardMap.has(original)) {
|
|
120
444
|
return this.forwardMap.get(original);
|
|
121
445
|
}
|
|
446
|
+
// R3: the value itself is by definition a document value.
|
|
447
|
+
this.addDocumentValue(original);
|
|
122
448
|
// T057 person-variant unification: "John Smith", "John" and "Smith"
|
|
123
|
-
// in one session are the same person and share one
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
449
|
+
// in one session are the same person and share one identity. T171
|
|
450
|
+
// makes restore exact: every distinct variant gets its OWN token that
|
|
451
|
+
// keeps the group's number ([PERSON_2] / [PERSON_2_LAST]) or, in
|
|
452
|
+
// surrogate mode, the matching part of the group's surrogate ("Nowak"
|
|
453
|
+
// for "Smith" when "John Smith" became "Adam Nowak"), so the reverse map
|
|
454
|
+
// stays one-to-one and restore writes back exactly what was there.
|
|
455
|
+
// Blanked mode has no identities to share (every value is '________'),
|
|
456
|
+
// so it skips the lookup and never reports ambiguity.
|
|
457
|
+
if (entityType === 'PERSON' && this.mode !== 'blanked') {
|
|
458
|
+
const { match, ambiguous } = this.findPersonVariant(original);
|
|
459
|
+
if (ambiguous) {
|
|
460
|
+
this.lastAmbiguousFlag = true;
|
|
461
|
+
this.ambiguousValues.add(original);
|
|
462
|
+
}
|
|
129
463
|
if (match) {
|
|
130
|
-
const
|
|
131
|
-
|
|
464
|
+
const token = this.mode === 'surrogate'
|
|
465
|
+
? this.variantSurrogate(original, match)
|
|
466
|
+
: this.variantPlaceholder(original, match);
|
|
467
|
+
this.forwardMap.set(original, token);
|
|
132
468
|
this.entityTypeMap.set(original, entityType);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
469
|
+
// Token-mapping can legitimately fall back to reusing the group's
|
|
470
|
+
// surrogate; then the longest original stays canonical (T057).
|
|
471
|
+
const canonical = this.reverseMap.get(token);
|
|
472
|
+
if (canonical === undefined || original.length > canonical.length) {
|
|
473
|
+
this.reverseMap.set(token, original);
|
|
138
474
|
}
|
|
139
|
-
|
|
475
|
+
this.invalidateRestore();
|
|
476
|
+
return token;
|
|
140
477
|
}
|
|
141
478
|
}
|
|
142
479
|
const placeholder = this.mode === 'blanked'
|
|
@@ -149,17 +486,102 @@ export class AnonymizationSession {
|
|
|
149
486
|
// reversible, so keep them out of the restore map.
|
|
150
487
|
if (this.mode !== 'blanked') {
|
|
151
488
|
this.reverseMap.set(placeholder, original);
|
|
489
|
+
this.invalidateRestore();
|
|
152
490
|
}
|
|
153
491
|
this.entityTypeMap.set(original, entityType);
|
|
154
492
|
return placeholder;
|
|
155
493
|
}
|
|
494
|
+
/** Drop the cached restore regex; called on every reverse-map change. */
|
|
495
|
+
invalidateRestore() {
|
|
496
|
+
this.restoreRe = null;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The exact-restore regex (R4): ONE alternation of every reverse-map
|
|
500
|
+
* key, longest first so a longer key always wins at a given position
|
|
501
|
+
* ("[PERSON_11]" before "[PERSON_1]", "Adam Nowak" before "Adam"), each
|
|
502
|
+
* key escaped and guarded per restoreKeyPattern. Compiled once per map
|
|
503
|
+
* state and reused across replies. Null when nothing is restorable.
|
|
504
|
+
*/
|
|
505
|
+
restoreRegex() {
|
|
506
|
+
if (this.restoreRe)
|
|
507
|
+
return this.restoreRe;
|
|
508
|
+
if (this.reverseMap.size === 0)
|
|
509
|
+
return null;
|
|
510
|
+
const keys = [...this.reverseMap.keys()].sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0));
|
|
511
|
+
this.restoreRe = new RegExp(keys.map(restoreKeyPattern).join('|'), 'gu');
|
|
512
|
+
return this.restoreRe;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The group a mapped value belongs to (T171): its base token and the
|
|
516
|
+
* canonical original that token restores to.
|
|
517
|
+
*/
|
|
518
|
+
variantGroup(match) {
|
|
519
|
+
const matchedToken = this.forwardMap.get(match);
|
|
520
|
+
const base = this.variantOf.get(matchedToken)?.base ?? matchedToken;
|
|
521
|
+
return { base, canonical: this.reverseMap.get(base) ?? match };
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Labeled-mode variant token (T171): the group's base token plus a
|
|
525
|
+
* positional suffix, computed against the group's canonical name when
|
|
526
|
+
* the variant is part of it, else against the value it matched. Distinct
|
|
527
|
+
* originals never share a token: a suffix already in use gets a counter
|
|
528
|
+
* ("Smith" -> [PERSON_2_LAST], "smith" -> [PERSON_2_LAST_2]).
|
|
529
|
+
*/
|
|
530
|
+
variantPlaceholder(original, match) {
|
|
531
|
+
const { base, canonical } = this.variantGroup(match);
|
|
532
|
+
const reference = isTokenSubset(original, canonical) ? canonical : match;
|
|
533
|
+
const suffix = variantSuffix(original, reference);
|
|
534
|
+
let token = withVariantSuffix(base, suffix);
|
|
535
|
+
for (let n = 2; this.reverseMap.has(token); n++) {
|
|
536
|
+
token = withVariantSuffix(base, `${suffix}_${n}`);
|
|
537
|
+
}
|
|
538
|
+
this.variantOf.set(token, { base, suffix });
|
|
539
|
+
return token;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Surrogate-mode variant (T171): when the variant is a strict part of the
|
|
543
|
+
* group's canonical name and the surrogate has the same word count, take
|
|
544
|
+
* the surrogate words at the same positions with the variant's casing.
|
|
545
|
+
* Anything else (fuller forms, reshaped surrogates, a collision with an
|
|
546
|
+
* existing value) falls back to the group's surrogate as before.
|
|
547
|
+
*/
|
|
548
|
+
variantSurrogate(original, match) {
|
|
549
|
+
const { base, canonical } = this.variantGroup(match);
|
|
550
|
+
const canonicalWords = canonical.split(/\s+/).filter(Boolean);
|
|
551
|
+
const surrogateWords = base.split(/\s+/).filter(Boolean);
|
|
552
|
+
const canonicalTokens = personTokens(canonical);
|
|
553
|
+
const variantWords = original.split(/\s+/).filter(Boolean);
|
|
554
|
+
const variantTokens = personTokens(original);
|
|
555
|
+
const shapesAlign = canonicalWords.length === surrogateWords.length
|
|
556
|
+
&& canonicalTokens.length === canonicalWords.length
|
|
557
|
+
&& variantTokens.length === variantWords.length
|
|
558
|
+
&& variantTokens.length < canonicalTokens.length
|
|
559
|
+
&& variantTokens.every((t) => canonicalTokens.includes(t));
|
|
560
|
+
if (!shapesAlign)
|
|
561
|
+
return base;
|
|
562
|
+
const mapped = variantWords
|
|
563
|
+
.map((word, i) => matchCase(surrogateWords[canonicalTokens.indexOf(variantTokens[i])], word))
|
|
564
|
+
.join(' ');
|
|
565
|
+
const taken = mapped === original
|
|
566
|
+
|| this.forwardMap.has(mapped)
|
|
567
|
+
|| (this.reverseMap.has(mapped) && this.reverseMap.get(mapped) !== original);
|
|
568
|
+
if (taken)
|
|
569
|
+
return base;
|
|
570
|
+
this.variantOf.set(mapped, { base, suffix: '' });
|
|
571
|
+
return mapped;
|
|
572
|
+
}
|
|
156
573
|
deanonymize(text) {
|
|
574
|
+
// R4: a SINGLE pass over the input. Every key is matched against the
|
|
575
|
+
// original reply only, never against text an earlier replacement just
|
|
576
|
+
// wrote, so a restored "Anna Kowalska" is never rewritten by a key
|
|
577
|
+
// "Anna" and prose that merely contains a key ("Bob" vs "Bo") is left
|
|
578
|
+
// alone by the boundaries in restoreKeyPattern. The replacer function
|
|
579
|
+
// keeps '$' sequences in the original value inert.
|
|
580
|
+
const re = this.restoreRegex();
|
|
157
581
|
let result = text;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// Replacer function keeps '$' sequences in the original value inert
|
|
162
|
-
result = result.replaceAll(placeholder, () => original);
|
|
582
|
+
if (re) {
|
|
583
|
+
re.lastIndex = 0;
|
|
584
|
+
result = text.replace(re, (m) => this.reverseMap.get(m) ?? m);
|
|
163
585
|
}
|
|
164
586
|
// T098: exact-literal replacement above is the unchanged fast path;
|
|
165
587
|
// tokens an LLM mangled (case, spacing, markdown, dropped brackets)
|
|
@@ -200,18 +622,44 @@ export class AnonymizationSession {
|
|
|
200
622
|
return text;
|
|
201
623
|
return out + text.slice(last);
|
|
202
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* R9: placeholders written LITERALLY in the user's text ("[PERSON_1]" in
|
|
627
|
+
* a template or a previously anonymized document) raise the per-type
|
|
628
|
+
* counter above them, so a freshly issued placeholder never restores to
|
|
629
|
+
* a value the text never carried under that token.
|
|
630
|
+
*/
|
|
631
|
+
reserveLiteralPlaceholders(text) {
|
|
632
|
+
LITERAL_PLACEHOLDER_RE.lastIndex = 0;
|
|
633
|
+
let match;
|
|
634
|
+
while ((match = LITERAL_PLACEHOLDER_RE.exec(text)) !== null) {
|
|
635
|
+
const [, type, digits] = match;
|
|
636
|
+
if (digits.length > MAX_COUNTER_DIGITS)
|
|
637
|
+
continue;
|
|
638
|
+
const current = this.typeCounters.get(type) ?? 0;
|
|
639
|
+
this.typeCounters.set(type, Math.max(current, Number(digits)));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
203
642
|
anonymizeText(text, entities) {
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
643
|
+
this.reserveLiteralPlaceholders(text);
|
|
644
|
+
// R3: every entity value is a document value BEFORE the first surrogate
|
|
645
|
+
// is drawn, so "Jan Kowalski" can never become "Adam Nowak" when Adam
|
|
646
|
+
// Nowak is mapped a few entities later.
|
|
647
|
+
this.registerDocumentValues(entities);
|
|
648
|
+
// PERSON pre-pass: map people before anything else so that (T171) each
|
|
649
|
+
// variant group forms around its FULLEST name - "John Smith" is the
|
|
650
|
+
// base and "Smith" / "smith" hang off it, whatever order they appear in
|
|
651
|
+
// - and so that (T043) an email appearing before/after its owner can
|
|
652
|
+
// derive its local part from the person's surrogate (jan.kowalski maps
|
|
653
|
+
// to adam.nowak style). Fullest name first, then reading order.
|
|
654
|
+
// anonymize() is idempotent per value, so the replacement pass below
|
|
655
|
+
// reuses these mappings; other types keep the historical end-to-start
|
|
656
|
+
// issuing order (placeholder numbering).
|
|
657
|
+
if (this.mode !== 'blanked') {
|
|
658
|
+
const people = entities
|
|
659
|
+
.filter((e) => e.type === 'PERSON')
|
|
660
|
+
.sort((a, b) => personTokens(b.value).length - personTokens(a.value).length || a.start - b.start);
|
|
661
|
+
for (const entity of people)
|
|
662
|
+
this.anonymize(entity.value, entity.type);
|
|
215
663
|
}
|
|
216
664
|
// Sort entities by position (end to start) to preserve indices during replacement
|
|
217
665
|
const sorted = [...entities].sort((a, b) => b.start - a.start);
|
|
@@ -238,19 +686,57 @@ export class AnonymizationSession {
|
|
|
238
686
|
getForward(original) {
|
|
239
687
|
return this.forwardMap.get(original);
|
|
240
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Rename the label of a mapped value. Renaming a group's base token
|
|
691
|
+
* (T171) re-derives every variant token from the new label
|
|
692
|
+
* ([PERSON_1] -> [CLIENT] takes [PERSON_1_LAST] to [CLIENT_LAST]);
|
|
693
|
+
* renaming a variant token renames only that token and detaches it from
|
|
694
|
+
* the group, so a later base rename does not overwrite the user's choice.
|
|
695
|
+
* Returns every [oldLabel, newLabel] pair applied, so hosts can update
|
|
696
|
+
* already-rendered text without re-anonymizing.
|
|
697
|
+
*/
|
|
241
698
|
renameLabel(original, newLabel) {
|
|
242
699
|
const oldLabel = this.forwardMap.get(original);
|
|
243
|
-
if (!oldLabel)
|
|
244
|
-
return;
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
700
|
+
if (!oldLabel || oldLabel === newLabel)
|
|
701
|
+
return [];
|
|
702
|
+
// R6: a blank label would match between every character of a reply
|
|
703
|
+
// and a label another value already restores to would hijack it.
|
|
704
|
+
// Both are refused without touching the maps.
|
|
705
|
+
if (newLabel.trim() === '' || this.reverseMap.has(newLabel))
|
|
706
|
+
return [];
|
|
707
|
+
const pairs = [];
|
|
708
|
+
const rename = (from, to) => {
|
|
709
|
+
// Every original sharing the label (legacy many-to-one maps) moves
|
|
710
|
+
// with it, and the canonical restore value carries over.
|
|
711
|
+
const canonical = this.reverseMap.get(from);
|
|
712
|
+
for (const [orig, label] of this.forwardMap) {
|
|
713
|
+
if (label === from)
|
|
714
|
+
this.forwardMap.set(orig, to);
|
|
715
|
+
}
|
|
716
|
+
this.reverseMap.delete(from);
|
|
717
|
+
if (canonical !== undefined)
|
|
718
|
+
this.reverseMap.set(to, canonical);
|
|
719
|
+
this.invalidateRestore();
|
|
720
|
+
pairs.push([from, to]);
|
|
721
|
+
};
|
|
722
|
+
if (this.variantOf.has(oldLabel)) {
|
|
723
|
+
this.variantOf.delete(oldLabel);
|
|
724
|
+
rename(oldLabel, newLabel);
|
|
725
|
+
return pairs;
|
|
251
726
|
}
|
|
252
|
-
|
|
253
|
-
|
|
727
|
+
rename(oldLabel, newLabel);
|
|
728
|
+
for (const [token, info] of [...this.variantOf]) {
|
|
729
|
+
if (info.base !== oldLabel)
|
|
730
|
+
continue;
|
|
731
|
+
let next = withVariantSuffix(newLabel, info.suffix);
|
|
732
|
+
for (let n = 2; this.reverseMap.has(next) || next === newLabel; n++) {
|
|
733
|
+
next = withVariantSuffix(newLabel, `${info.suffix}_${n}`);
|
|
734
|
+
}
|
|
735
|
+
this.variantOf.delete(token);
|
|
736
|
+
this.variantOf.set(next, { base: newLabel, suffix: info.suffix });
|
|
737
|
+
rename(token, next);
|
|
738
|
+
}
|
|
739
|
+
return pairs;
|
|
254
740
|
}
|
|
255
741
|
/**
|
|
256
742
|
* Serialize the session map to JSON.
|
|
@@ -321,24 +807,53 @@ export class AnonymizationSession {
|
|
|
321
807
|
* forward map but excluded from the reverse map, preserving their
|
|
322
808
|
* irreversibility. Entries merge into whatever the session already
|
|
323
809
|
* holds; call clear() first for a fresh seeded session.
|
|
810
|
+
*
|
|
811
|
+
* R11: the input is validated BEFORE anything is applied (an array of
|
|
812
|
+
* at most 10 000 well-formed entries, see validateImportEntry); a
|
|
813
|
+
* violation throws and leaves the session untouched.
|
|
324
814
|
*/
|
|
325
815
|
importEntries(entries) {
|
|
326
|
-
|
|
816
|
+
if (!Array.isArray(entries)) {
|
|
817
|
+
throw new Error('Invalid session map: expected an array of entries');
|
|
818
|
+
}
|
|
819
|
+
if (entries.length > MAX_IMPORT_ENTRIES) {
|
|
820
|
+
throw new Error(`Invalid session map: too many entries (${entries.length} > ${MAX_IMPORT_ENTRIES})`);
|
|
821
|
+
}
|
|
822
|
+
const valid = entries.map((entry, i) => validateImportEntry(entry, i));
|
|
823
|
+
for (const entry of valid) {
|
|
824
|
+
// R3: imported originals came from a document of this matter.
|
|
825
|
+
this.addDocumentValue(entry.original);
|
|
327
826
|
this.forwardMap.set(entry.original, entry.replacement);
|
|
328
827
|
if (entry.replacement !== '________') {
|
|
329
828
|
const canonical = this.reverseMap.get(entry.replacement);
|
|
330
829
|
if (canonical === undefined || entry.original.length > canonical.length) {
|
|
331
830
|
this.reverseMap.set(entry.replacement, entry.original);
|
|
332
831
|
}
|
|
832
|
+
this.invalidateRestore();
|
|
333
833
|
}
|
|
334
834
|
this.entityTypeMap.set(entry.original, entry.entityType);
|
|
835
|
+
// T171 variant tokens ([PERSON_1_LAST], [PERSON_1_LAST_2]) carry no
|
|
836
|
+
// counter of their own; they re-link to their base below.
|
|
837
|
+
if (VARIANT_TOKEN_RE.test(entry.replacement))
|
|
838
|
+
continue;
|
|
335
839
|
const match = TYPED_PLACEHOLDER_RE.exec(entry.replacement);
|
|
336
840
|
if (match) {
|
|
337
841
|
const [, type, num] = match;
|
|
842
|
+
if (num.length > MAX_COUNTER_DIGITS)
|
|
843
|
+
continue;
|
|
338
844
|
const current = this.typeCounters.get(type) ?? 0;
|
|
339
845
|
this.typeCounters.set(type, Math.max(current, Number(num)));
|
|
340
846
|
}
|
|
341
847
|
}
|
|
848
|
+
for (const entry of valid) {
|
|
849
|
+
const variant = VARIANT_TOKEN_RE.exec(entry.replacement);
|
|
850
|
+
if (!variant)
|
|
851
|
+
continue;
|
|
852
|
+
const base = `${variant[1]}]`;
|
|
853
|
+
if (this.reverseMap.has(base)) {
|
|
854
|
+
this.variantOf.set(entry.replacement, { base, suffix: variant[2] });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
342
857
|
}
|
|
343
858
|
/**
|
|
344
859
|
* Rebuild a session from JSON produced by serialize() or by the Python
|
|
@@ -370,11 +885,19 @@ export class AnonymizationSession {
|
|
|
370
885
|
else {
|
|
371
886
|
throw new Error('Invalid session map JSON: expected a top-level array');
|
|
372
887
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
888
|
+
if (entries.length > MAX_IMPORT_ENTRIES) {
|
|
889
|
+
throw new Error(`Invalid session map: too many entries (${entries.length} > ${MAX_IMPORT_ENTRIES})`);
|
|
890
|
+
}
|
|
891
|
+
// Wire entries are only re-keyed here (entity_type -> entityType);
|
|
892
|
+
// importEntries validates every field. A non-object entry (e.g. null)
|
|
893
|
+
// is passed through as-is so the validator names its index.
|
|
894
|
+
session.importEntries(entries.map((entry) => typeof entry === 'object' && entry !== null && !Array.isArray(entry)
|
|
895
|
+
? {
|
|
896
|
+
original: entry.original,
|
|
897
|
+
replacement: entry.replacement,
|
|
898
|
+
entityType: entry.entity_type,
|
|
899
|
+
}
|
|
900
|
+
: entry));
|
|
378
901
|
return session;
|
|
379
902
|
}
|
|
380
903
|
clear() {
|
|
@@ -382,5 +905,11 @@ export class AnonymizationSession {
|
|
|
382
905
|
this.reverseMap.clear();
|
|
383
906
|
this.entityTypeMap.clear();
|
|
384
907
|
this.typeCounters.clear();
|
|
908
|
+
this.variantOf.clear();
|
|
909
|
+
this.documentValues.clear();
|
|
910
|
+
this.documentNameIndex.clear();
|
|
911
|
+
this.ambiguousValues.clear();
|
|
912
|
+
this.lastAmbiguousFlag = false;
|
|
913
|
+
this.invalidateRestore();
|
|
385
914
|
}
|
|
386
915
|
}
|