agent-sanitizer 2.26.0 → 2.26.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.
- package/package.json +1 -1
- package/src/rehydrate.mjs +12 -7
- package/src/view-map.mjs +154 -74
- package/types/view-map.d.mts +75 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.26.
|
|
3
|
+
"version": "2.26.1",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/rehydrate.mjs
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
resolveSpan,
|
|
64
64
|
rehydrateNewString,
|
|
65
65
|
makeFileView,
|
|
66
|
+
toUtf16View,
|
|
66
67
|
pairDiskSpans,
|
|
67
68
|
} from "./view-map.mjs";
|
|
68
69
|
|
|
@@ -148,7 +149,7 @@ function exposureDeny(count) {
|
|
|
148
149
|
* @param {{file_path: string, old_string: string, new_string: string, replace_all?: boolean}} ti
|
|
149
150
|
* @param {string} content disk bytes
|
|
150
151
|
* @param {string} cleaned Layer-1 view of `content`
|
|
151
|
-
* @param {import("./view-map.mjs").FileView} view
|
|
152
|
+
* @param {import("./view-map.mjs").FileView<"utf16">} view
|
|
152
153
|
* @param {{start: number, deleted: string}[]} deletions
|
|
153
154
|
* @param {RehydrateIo} io
|
|
154
155
|
* @param {boolean} hinted the input itself carries placeholders
|
|
@@ -398,7 +399,7 @@ function foreignPlaceholders(out, hint, viewText, secretSpans) {
|
|
|
398
399
|
|
|
399
400
|
/**
|
|
400
401
|
* @param {{file_path: string, content: string}} ti
|
|
401
|
-
* @param {import("./view-map.mjs").FileView} view
|
|
402
|
+
* @param {import("./view-map.mjs").FileView<"utf16">} view
|
|
402
403
|
* @param {RehydrateIo} io
|
|
403
404
|
* @param {string} hint placeholder prefix
|
|
404
405
|
*/
|
|
@@ -672,12 +673,16 @@ export async function rehydrateRedacted(
|
|
|
672
673
|
};
|
|
673
674
|
}
|
|
674
675
|
// The redactor emits code-point offsets; the offset machinery below works in
|
|
675
|
-
// UTF-16.
|
|
676
|
+
// UTF-16. Convert once, here, into a fresh frozen UTF-16-space carrier so an
|
|
676
677
|
// astral char before a placeholder can't mis-anchor the edit (a no-op for
|
|
677
|
-
// BMP-only files) AND the redactor's own object is never written through —
|
|
678
|
-
//
|
|
679
|
-
// already-converted object and get converted twice
|
|
680
|
-
|
|
678
|
+
// BMP-only files) AND the redactor's own object is never written through — a
|
|
679
|
+
// redactor that memoizes its map result would otherwise hand back an
|
|
680
|
+
// already-converted object and get converted twice, so the same input would
|
|
681
|
+
// yield a different verdict on the second call. The space brand is what makes
|
|
682
|
+
// that second conversion throw rather than silently shift; see toUtf16View.
|
|
683
|
+
const view = toUtf16View(
|
|
684
|
+
makeFileView(mapped.text, mapped.pairs, "codePoint"),
|
|
685
|
+
);
|
|
681
686
|
// View identical to disk: any placeholders in an Edit's old_string are
|
|
682
687
|
// literal text, so there is nothing to re-anchor. `cleaned === content` also
|
|
683
688
|
// rules out a lone-surrogate-only divergence (view.pairs/deletions alone
|
package/src/view-map.mjs
CHANGED
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
* placeholder (`pairs` from the injected redactor’s map mode)
|
|
13
13
|
*
|
|
14
14
|
* The view is carried by {@link makeFileView}, the ONLY constructor the
|
|
15
|
-
* consumers of this module may use: it
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* consumers of this module may use: it brands the result, and the brand carries
|
|
16
|
+
* the coordinate SPACE the pair offsets live in — `"codePoint"` as the injected
|
|
17
|
+
* redactor emits them, `"utf16"` as every function below indexes by. Conversion
|
|
18
|
+
* is a separate, one-way door ({@link toUtf16View}) that accepts only a
|
|
19
|
+
* `"codePoint"` view and returns a new `"utf16"` one, so converting twice throws
|
|
20
|
+
* instead of shifting every astral-preceded offset a second time. Each consumer
|
|
21
|
+
* asserts the space it needs, so a wrongly-spaced view fails at the boundary
|
|
22
|
+
* rather than resolving onto the wrong bytes.
|
|
23
|
+
*
|
|
24
|
+
* The space is part of the value rather than a convention in a comment because
|
|
25
|
+
* the two spaces are otherwise indistinguishable — bare numbers in a bare
|
|
26
|
+
* object — which is exactly why the original double-conversion bug was silently
|
|
27
|
+
* accepted.
|
|
18
28
|
*/
|
|
19
29
|
|
|
20
30
|
/**
|
|
@@ -28,57 +38,123 @@ const FILE_VIEW = Symbol("agent-sanitizer:file-view");
|
|
|
28
38
|
|
|
29
39
|
/**
|
|
30
40
|
* @typedef {{ placeholder: string, original: string, start: number }} RedactionPair
|
|
31
|
-
* @typedef {
|
|
32
|
-
*
|
|
33
|
-
*
|
|
41
|
+
* @typedef {"codePoint" | "utf16"} OffsetSpace
|
|
42
|
+
* Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
|
|
43
|
+
* what the redactor's map mode emits (Python indexes strings by code point);
|
|
44
|
+
* `utf16` is what every function in this module indexes by (JS
|
|
45
|
+
* `indexOf`/`slice`/`.length` count UTF-16 code units).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A branded, frozen carrier from {@link makeFileView}, tagged with the space its
|
|
50
|
+
* `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
|
|
51
|
+
* typedef in the comment it sits in, so sharing one with the two above would
|
|
52
|
+
* make `RedactionPair` and `OffsetSpace` generic too.
|
|
53
|
+
* @template {OffsetSpace} S
|
|
54
|
+
* @typedef {{ readonly space: S, readonly text: string,
|
|
55
|
+
* readonly pairs: readonly RedactionPair[] }} FileView
|
|
34
56
|
*/
|
|
35
57
|
|
|
36
58
|
/**
|
|
37
|
-
*
|
|
59
|
+
* Wrap a redactor's map-mode result in a frozen, branded view tagged with the
|
|
60
|
+
* space its offsets are in.
|
|
38
61
|
*
|
|
39
62
|
* The redactor's own object is never touched. It used to be: the caller did
|
|
40
63
|
* `view.pairs = pairsToUtf16(view.text, view.pairs)`, an in-place mutation of a
|
|
41
64
|
* value returned from an INJECTED seam. A redactor that memoizes its map result
|
|
42
65
|
* (a reasonable thing for a caller to build) hands back the same object on the
|
|
43
|
-
* second identical call, which then
|
|
66
|
+
* second identical call, which then got converted a SECOND time — every
|
|
44
67
|
* placeholder preceded by an astral character shifts again and the same input
|
|
45
|
-
* yields a different verdict.
|
|
46
|
-
* that: the conversion is part of construction, the redactor's value is left
|
|
47
|
-
* alone, and every consumer asserts the brand rather than accepting a
|
|
48
|
-
* hand-assembled `{text, pairs}` whose offsets may or may not be converted.
|
|
68
|
+
* yields a different verdict.
|
|
49
69
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* the
|
|
70
|
+
* Construction no longer converts. It brands and records the space, and
|
|
71
|
+
* {@link toUtf16View} does the conversion behind a check that the input is
|
|
72
|
+
* still in code-point space — so `toUtf16View(alreadyConverted)` throws where
|
|
73
|
+
* `makeFileView(v.text, v.pairs)` on an existing view used to silently convert
|
|
74
|
+
* a second time. That was the one door this carrier left open.
|
|
55
75
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
76
|
+
* Both the array AND each pair object are copied before freezing, so nothing
|
|
77
|
+
* here reaches back into the seam's (possibly memoized) value, and a caller that
|
|
78
|
+
* later mutates its own pairs cannot change what this view resolves.
|
|
79
|
+
* @template {OffsetSpace} S
|
|
59
80
|
* @param {string} text redacted view text
|
|
60
|
-
* @param {RedactionPair[]} pairs redactor pairs, in
|
|
61
|
-
* @
|
|
81
|
+
* @param {readonly RedactionPair[]} pairs redactor pairs, offsets in `space`
|
|
82
|
+
* @param {S} space
|
|
83
|
+
* @returns {FileView<S>}
|
|
62
84
|
*/
|
|
63
|
-
export function makeFileView(text, pairs) {
|
|
85
|
+
export function makeFileView(text, pairs, space) {
|
|
86
|
+
assertPairsOrdered(text, pairs, space);
|
|
64
87
|
return Object.freeze({
|
|
65
88
|
[FILE_VIEW]: true,
|
|
89
|
+
space,
|
|
66
90
|
text,
|
|
67
|
-
pairs: Object.freeze(
|
|
91
|
+
pairs: Object.freeze(pairs.map((pair) => Object.freeze({ ...pair }))),
|
|
68
92
|
});
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
96
|
+
* Offsets counted the way `space` counts them: UTF-16 code units, or code
|
|
97
|
+
* points as the Python redactor emits them.
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @param {OffsetSpace} space
|
|
100
|
+
* @returns {number}
|
|
101
|
+
*/
|
|
102
|
+
function unitLength(text, space) {
|
|
103
|
+
return space === "utf16" ? text.length : Array.from(text).length;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Throw unless `pairs` is in range, sorted by `start` and non-overlapping, with
|
|
108
|
+
* every offset read in `space`.
|
|
109
|
+
*
|
|
110
|
+
* Every consumer here — `mapViewOffset`'s `else break`, `pairDiskSpans`'s
|
|
111
|
+
* sequential walk — assumes exactly this. An out-of-range start indexes past
|
|
112
|
+
* the text and silently yields `undefined`, which then poisons every downstream
|
|
113
|
+
* offset comparison (`undefined < n` is always false); an out-of-order or
|
|
114
|
+
* overlapping pair stops the scan early and mis-maps an offset onto the wrong
|
|
115
|
+
* bytes. Enforced at CONSTRUCTION so no view can exist in that state, rather
|
|
116
|
+
* than at each read.
|
|
117
|
+
* @param {string} text the view text the offsets index into
|
|
118
|
+
* @param {readonly RedactionPair[]} pairs
|
|
119
|
+
* @param {OffsetSpace} space
|
|
120
|
+
* @returns {void}
|
|
121
|
+
*/
|
|
122
|
+
function assertPairsOrdered(text, pairs, space) {
|
|
123
|
+
// The no-secrets rehydration is the common case, and the loop below has
|
|
124
|
+
// nothing to check there. Return before `unitLength`, which for "codePoint"
|
|
125
|
+
// materializes a code-point array over the whole file on every Edit/Write.
|
|
126
|
+
if (pairs.length === 0) return;
|
|
127
|
+
const total = unitLength(text, space);
|
|
128
|
+
// The previous pair's placeholder end. `start < prevEnd` catches an
|
|
129
|
+
// out-of-order start and an overlap in one comparison.
|
|
130
|
+
let prevEnd = 0;
|
|
131
|
+
for (const pair of pairs) {
|
|
132
|
+
if (!Number.isInteger(pair.start) || pair.start < 0 || pair.start > total)
|
|
133
|
+
throw new Error(
|
|
134
|
+
`redaction pair start ${pair.start} is out of range [0, ${total}]`,
|
|
135
|
+
);
|
|
136
|
+
if (pair.start < prevEnd)
|
|
137
|
+
throw new Error(
|
|
138
|
+
`redaction pairs must be sorted and non-overlapping: pair start ${pair.start} precedes previous pair end ${prevEnd}`,
|
|
139
|
+
);
|
|
140
|
+
prevEnd = pair.start + unitLength(pair.placeholder, space);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Throw unless `view` came from {@link makeFileView} AND carries `space`.
|
|
146
|
+
*
|
|
147
|
+
* Two failures, one gate. A hand-rolled `{text, pairs}` has offsets that may or
|
|
148
|
+
* may not have been converted; a real view in the WRONG space has offsets that
|
|
149
|
+
* definitely have not. Either mis-anchors an edit onto the wrong bytes whenever
|
|
150
|
+
* an astral character precedes a placeholder — silently, and only for
|
|
76
151
|
* emoji-bearing files. Fail loudly at the boundary instead.
|
|
77
152
|
* @param {unknown} view
|
|
153
|
+
* @param {OffsetSpace} space the space the calling function indexes by
|
|
78
154
|
* @param {string} fn name of the calling function, for the error
|
|
79
155
|
* @returns {void}
|
|
80
156
|
*/
|
|
81
|
-
function assertFileView(view, fn) {
|
|
157
|
+
function assertFileView(view, space, fn) {
|
|
82
158
|
if (
|
|
83
159
|
view === null ||
|
|
84
160
|
typeof view !== "object" ||
|
|
@@ -88,6 +164,11 @@ function assertFileView(view, fn) {
|
|
|
88
164
|
`${fn} requires a view built by makeFileView(); got a raw object whose ` +
|
|
89
165
|
`pair offsets have not been normalized to UTF-16`,
|
|
90
166
|
);
|
|
167
|
+
const actual = /** @type {FileView<OffsetSpace>} */ (view).space;
|
|
168
|
+
if (actual !== space)
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${fn} requires a view with ${space} pair offsets, got ${actual}`,
|
|
171
|
+
);
|
|
91
172
|
}
|
|
92
173
|
|
|
93
174
|
/**
|
|
@@ -195,53 +276,52 @@ function diskOffset(deletions, cleanedOffset, isEnd) {
|
|
|
195
276
|
* placeholder mis-anchors the edit onto the wrong bytes.
|
|
196
277
|
*
|
|
197
278
|
* Exactly once, though: applying it to its own output shifts every
|
|
198
|
-
* astral-preceded placeholder a second time
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* the
|
|
279
|
+
* astral-preceded placeholder a second time, and bare arrays of numbers give
|
|
280
|
+
* nothing to check that against. Prefer {@link toUtf16View}, which runs this
|
|
281
|
+
* behind a space check so the second application throws instead. This stays
|
|
282
|
+
* exported — it is public API on the `./view-map` subpath, and removing it
|
|
283
|
+
* would be a breaking change the release workflow cannot express (it caps
|
|
284
|
+
* automated bumps at minor) — for callers doing their own offset bookkeeping,
|
|
285
|
+
* who own the once-only discipline themselves.
|
|
203
286
|
* @param {string} text the redacted view text the offsets index into
|
|
204
|
-
* @param {
|
|
205
|
-
* @returns {
|
|
287
|
+
* @param {RedactionPair[]} pairs
|
|
288
|
+
* @returns {RedactionPair[]}
|
|
206
289
|
*/
|
|
207
290
|
export function pairsToUtf16(text, pairs) {
|
|
208
291
|
if (pairs.length === 0) return pairs;
|
|
292
|
+
assertPairsOrdered(text, pairs, "codePoint");
|
|
209
293
|
const codePoints = Array.from(text);
|
|
210
294
|
// prefix[i] = UTF-16 length of the first i code points of `text`.
|
|
211
295
|
const prefix = new Array(codePoints.length + 1);
|
|
212
296
|
prefix[0] = 0;
|
|
213
297
|
for (let i = 0; i < codePoints.length; i++)
|
|
214
298
|
prefix[i + 1] = prefix[i] + codePoints[i].length;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
);
|
|
242
|
-
prevEnd = pair.start + Array.from(pair.placeholder).length;
|
|
243
|
-
return { ...pair, start: prefix[pair.start] };
|
|
244
|
-
});
|
|
299
|
+
return pairs.map((pair) => ({ ...pair, start: prefix[pair.start] }));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The same view with its pair offsets re-expressed in UTF-16 code units — a NEW
|
|
304
|
+
* frozen carrier; `view` is untouched.
|
|
305
|
+
*
|
|
306
|
+
* The one-way door. Only a `"codePoint"` view is accepted, so converting an
|
|
307
|
+
* already-converted view throws rather than shifting every astral-preceded
|
|
308
|
+
* offset a second time — which either mis-anchors the edit or rejects it as
|
|
309
|
+
* cutting a placeholder, both from an input that was fine the first time it was
|
|
310
|
+
* seen. Offset range/sort/overlap validity is not this door's job — every
|
|
311
|
+
* carrier is checked at construction (see {@link assertPairsOrdered}), in
|
|
312
|
+
* whichever space it declares.
|
|
313
|
+
* @param {FileView<"codePoint">} view
|
|
314
|
+
* @returns {FileView<"utf16">}
|
|
315
|
+
*/
|
|
316
|
+
export function toUtf16View(view) {
|
|
317
|
+
assertFileView(view, "codePoint", "toUtf16View");
|
|
318
|
+
// Copied because `view.pairs` is readonly and `pairsToUtf16` keeps the
|
|
319
|
+
// published mutable signature; makeFileView copies again on the way in.
|
|
320
|
+
return makeFileView(
|
|
321
|
+
view.text,
|
|
322
|
+
pairsToUtf16(view.text, [...view.pairs]),
|
|
323
|
+
"utf16",
|
|
324
|
+
);
|
|
245
325
|
}
|
|
246
326
|
|
|
247
327
|
/**
|
|
@@ -275,7 +355,7 @@ function mapViewOffset(pairs, offset) {
|
|
|
275
355
|
* and a mis-attributed run would mis-anchor the edit.
|
|
276
356
|
* @param {string} content disk file content
|
|
277
357
|
* @param {string} cleaned Layer-1 view of `content`
|
|
278
|
-
* @param {FileView} view
|
|
358
|
+
* @param {FileView<"utf16">} view
|
|
279
359
|
* @param {{start: number, deleted: string}[]} deletions
|
|
280
360
|
* @param {number} viewStart
|
|
281
361
|
* @param {number} viewEnd
|
|
@@ -288,7 +368,7 @@ export function resolveSpan(
|
|
|
288
368
|
viewStart,
|
|
289
369
|
viewEnd,
|
|
290
370
|
) {
|
|
291
|
-
assertFileView(view, "resolveSpan");
|
|
371
|
+
assertFileView(view, "utf16", "resolveSpan");
|
|
292
372
|
const cleanedStart = mapViewOffset(view.pairs, viewStart);
|
|
293
373
|
const cleanedEnd = mapViewOffset(view.pairs, viewEnd);
|
|
294
374
|
if (cleanedStart === null || cleanedEnd === null) return null;
|
|
@@ -378,15 +458,15 @@ export function spliceOrdered(text, matches, replacementFor) {
|
|
|
378
458
|
* was never part of the secret); interior runs are included. Callers use these
|
|
379
459
|
* to detect an edit whose on-disk footprint intrudes into bytes the model was
|
|
380
460
|
* never shown.
|
|
381
|
-
* @param {FileView} view
|
|
461
|
+
* @param {FileView<"utf16">} view
|
|
382
462
|
* @param {{start: number, deleted: string}[]} deletions
|
|
383
463
|
* @returns {{start: number, end: number}[]}
|
|
384
464
|
*/
|
|
385
465
|
export function pairDiskSpans(view, deletions) {
|
|
386
|
-
assertFileView(view, "pairDiskSpans");
|
|
466
|
+
assertFileView(view, "utf16", "pairDiskSpans");
|
|
387
467
|
return view.pairs.map((pair) => {
|
|
388
468
|
// pair.start is a placeholder boundary, and makeFileView rejected any pair
|
|
389
|
-
// set
|
|
469
|
+
// set out of order or overlapping (see assertPairsOrdered), so it is never
|
|
390
470
|
// strictly interior to another placeholder: mapViewOffset always resolves.
|
|
391
471
|
// The throw is kept anyway, and is NOT dead weight — it is the difference
|
|
392
472
|
// between crashing and corrupting. `null + pair.original.length` is a
|
|
@@ -395,9 +475,9 @@ export function pairDiskSpans(view, deletions) {
|
|
|
395
475
|
// i.e. an edit footprint pointing at the wrong bytes.
|
|
396
476
|
const cleanedStart = mapViewOffset(view.pairs, pair.start);
|
|
397
477
|
/* c8 ignore start -- unreachable through makeFileView, which rejects the
|
|
398
|
-
overlapping pair set that is the only way to produce null here (see
|
|
399
|
-
constructor test in test/view-map.test.mjs);
|
|
400
|
-
against a future regression in that
|
|
478
|
+
overlapping pair set that is the only way to produce null here (see
|
|
479
|
+
assertPairsOrdered and the constructor test in test/view-map.test.mjs);
|
|
480
|
+
kept as a fail-loud guard against a future regression in that check. `ignore next N` does
|
|
401
481
|
NOT suppress the branch here — only the statement — so the range form is
|
|
402
482
|
required to keep the src branch floor at 100%. */
|
|
403
483
|
if (cleanedStart === null)
|
package/types/view-map.d.mts
CHANGED
|
@@ -1,37 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {{ placeholder: string, original: string, start: number }} RedactionPair
|
|
3
|
-
* @typedef {
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* @typedef {"codePoint" | "utf16"} OffsetSpace
|
|
4
|
+
* Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
|
|
5
|
+
* what the redactor's map mode emits (Python indexes strings by code point);
|
|
6
|
+
* `utf16` is what every function in this module indexes by (JS
|
|
7
|
+
* `indexOf`/`slice`/`.length` count UTF-16 code units).
|
|
6
8
|
*/
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
10
|
+
* A branded, frozen carrier from {@link makeFileView}, tagged with the space its
|
|
11
|
+
* `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
|
|
12
|
+
* typedef in the comment it sits in, so sharing one with the two above would
|
|
13
|
+
* make `RedactionPair` and `OffsetSpace` generic too.
|
|
14
|
+
* @template {OffsetSpace} S
|
|
15
|
+
* @typedef {{ readonly space: S, readonly text: string,
|
|
16
|
+
* readonly pairs: readonly RedactionPair[] }} FileView
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Wrap a redactor's map-mode result in a frozen, branded view tagged with the
|
|
20
|
+
* space its offsets are in.
|
|
9
21
|
*
|
|
10
22
|
* The redactor's own object is never touched. It used to be: the caller did
|
|
11
23
|
* `view.pairs = pairsToUtf16(view.text, view.pairs)`, an in-place mutation of a
|
|
12
24
|
* value returned from an INJECTED seam. A redactor that memoizes its map result
|
|
13
25
|
* (a reasonable thing for a caller to build) hands back the same object on the
|
|
14
|
-
* second identical call, which then
|
|
26
|
+
* second identical call, which then got converted a SECOND time — every
|
|
15
27
|
* placeholder preceded by an astral character shifts again and the same input
|
|
16
|
-
* yields a different verdict.
|
|
17
|
-
* that: the conversion is part of construction, the redactor's value is left
|
|
18
|
-
* alone, and every consumer asserts the brand rather than accepting a
|
|
19
|
-
* hand-assembled `{text, pairs}` whose offsets may or may not be converted.
|
|
28
|
+
* yields a different verdict.
|
|
20
29
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the
|
|
30
|
+
* Construction no longer converts. It brands and records the space, and
|
|
31
|
+
* {@link toUtf16View} does the conversion behind a check that the input is
|
|
32
|
+
* still in code-point space — so `toUtf16View(alreadyConverted)` throws where
|
|
33
|
+
* `makeFileView(v.text, v.pairs)` on an existing view used to silently convert
|
|
34
|
+
* a second time. That was the one door this carrier left open.
|
|
26
35
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
36
|
+
* Both the array AND each pair object are copied before freezing, so nothing
|
|
37
|
+
* here reaches back into the seam's (possibly memoized) value, and a caller that
|
|
38
|
+
* later mutates its own pairs cannot change what this view resolves.
|
|
39
|
+
* @template {OffsetSpace} S
|
|
30
40
|
* @param {string} text redacted view text
|
|
31
|
-
* @param {RedactionPair[]} pairs redactor pairs, in
|
|
32
|
-
* @
|
|
41
|
+
* @param {readonly RedactionPair[]} pairs redactor pairs, offsets in `space`
|
|
42
|
+
* @param {S} space
|
|
43
|
+
* @returns {FileView<S>}
|
|
33
44
|
*/
|
|
34
|
-
export function makeFileView(text: string, pairs: RedactionPair[]): FileView
|
|
45
|
+
export function makeFileView<S extends OffsetSpace>(text: string, pairs: readonly RedactionPair[], space: S): FileView<S>;
|
|
35
46
|
/**
|
|
36
47
|
* Non-overlapping occurrence indices of `needle` in `haystack`.
|
|
37
48
|
* @param {string} haystack
|
|
@@ -77,24 +88,33 @@ export function alignDeletions(content: string, cleaned: string): {
|
|
|
77
88
|
* placeholder mis-anchors the edit onto the wrong bytes.
|
|
78
89
|
*
|
|
79
90
|
* Exactly once, though: applying it to its own output shifts every
|
|
80
|
-
* astral-preceded placeholder a second time
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* the
|
|
91
|
+
* astral-preceded placeholder a second time, and bare arrays of numbers give
|
|
92
|
+
* nothing to check that against. Prefer {@link toUtf16View}, which runs this
|
|
93
|
+
* behind a space check so the second application throws instead. This stays
|
|
94
|
+
* exported — it is public API on the `./view-map` subpath, and removing it
|
|
95
|
+
* would be a breaking change the release workflow cannot express (it caps
|
|
96
|
+
* automated bumps at minor) — for callers doing their own offset bookkeeping,
|
|
97
|
+
* who own the once-only discipline themselves.
|
|
85
98
|
* @param {string} text the redacted view text the offsets index into
|
|
86
|
-
* @param {
|
|
87
|
-
* @returns {
|
|
99
|
+
* @param {RedactionPair[]} pairs
|
|
100
|
+
* @returns {RedactionPair[]}
|
|
88
101
|
*/
|
|
89
|
-
export function pairsToUtf16(text: string, pairs:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
export function pairsToUtf16(text: string, pairs: RedactionPair[]): RedactionPair[];
|
|
103
|
+
/**
|
|
104
|
+
* The same view with its pair offsets re-expressed in UTF-16 code units — a NEW
|
|
105
|
+
* frozen carrier; `view` is untouched.
|
|
106
|
+
*
|
|
107
|
+
* The one-way door. Only a `"codePoint"` view is accepted, so converting an
|
|
108
|
+
* already-converted view throws rather than shifting every astral-preceded
|
|
109
|
+
* offset a second time — which either mis-anchors the edit or rejects it as
|
|
110
|
+
* cutting a placeholder, both from an input that was fine the first time it was
|
|
111
|
+
* seen. Offset range/sort/overlap validity is not this door's job — every
|
|
112
|
+
* carrier is checked at construction (see {@link assertPairsOrdered}), in
|
|
113
|
+
* whichever space it declares.
|
|
114
|
+
* @param {FileView<"codePoint">} view
|
|
115
|
+
* @returns {FileView<"utf16">}
|
|
116
|
+
*/
|
|
117
|
+
export function toUtf16View(view: FileView<"codePoint">): FileView<"utf16">;
|
|
98
118
|
/**
|
|
99
119
|
* Resolve view span [viewStart, viewEnd) to its on-disk text and the redaction
|
|
100
120
|
* pairs it wholly contains, mapping across placeholder expansion (view →
|
|
@@ -108,12 +128,12 @@ export function pairsToUtf16(text: string, pairs: {
|
|
|
108
128
|
* and a mis-attributed run would mis-anchor the edit.
|
|
109
129
|
* @param {string} content disk file content
|
|
110
130
|
* @param {string} cleaned Layer-1 view of `content`
|
|
111
|
-
* @param {FileView} view
|
|
131
|
+
* @param {FileView<"utf16">} view
|
|
112
132
|
* @param {{start: number, deleted: string}[]} deletions
|
|
113
133
|
* @param {number} viewStart
|
|
114
134
|
* @param {number} viewEnd
|
|
115
135
|
*/
|
|
116
|
-
export function resolveSpan(content: string, cleaned: string, view: FileView
|
|
136
|
+
export function resolveSpan(content: string, cleaned: string, view: FileView<"utf16">, deletions: {
|
|
117
137
|
start: number;
|
|
118
138
|
deleted: string;
|
|
119
139
|
}[], viewStart: number, viewEnd: number): {
|
|
@@ -185,11 +205,11 @@ export function spliceOrdered(text: string, matches: {
|
|
|
185
205
|
* was never part of the secret); interior runs are included. Callers use these
|
|
186
206
|
* to detect an edit whose on-disk footprint intrudes into bytes the model was
|
|
187
207
|
* never shown.
|
|
188
|
-
* @param {FileView} view
|
|
208
|
+
* @param {FileView<"utf16">} view
|
|
189
209
|
* @param {{start: number, deleted: string}[]} deletions
|
|
190
210
|
* @returns {{start: number, end: number}[]}
|
|
191
211
|
*/
|
|
192
|
-
export function pairDiskSpans(view: FileView
|
|
212
|
+
export function pairDiskSpans(view: FileView<"utf16">, deletions: {
|
|
193
213
|
start: number;
|
|
194
214
|
deleted: string;
|
|
195
215
|
}[]): {
|
|
@@ -221,10 +241,20 @@ export type RedactionPair = {
|
|
|
221
241
|
start: number;
|
|
222
242
|
};
|
|
223
243
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
244
|
+
* Units a {@link FileView}'s pair offsets are expressed in. `codePoint` is
|
|
245
|
+
* what the redactor's map mode emits (Python indexes strings by code point);
|
|
246
|
+
* `utf16` is what every function in this module indexes by (JS
|
|
247
|
+
* `indexOf`/`slice`/`.length` count UTF-16 code units).
|
|
226
248
|
*/
|
|
227
|
-
export type
|
|
228
|
-
|
|
229
|
-
|
|
249
|
+
export type OffsetSpace = "codePoint" | "utf16";
|
|
250
|
+
/**
|
|
251
|
+
* A branded, frozen carrier from {@link makeFileView}, tagged with the space its
|
|
252
|
+
* `pairs` offsets live in. Its own JSDoc block: a `@template` applies to every
|
|
253
|
+
* typedef in the comment it sits in, so sharing one with the two above would
|
|
254
|
+
* make `RedactionPair` and `OffsetSpace` generic too.
|
|
255
|
+
*/
|
|
256
|
+
export type FileView<S extends OffsetSpace> = {
|
|
257
|
+
readonly space: S;
|
|
258
|
+
readonly text: string;
|
|
259
|
+
readonly pairs: readonly RedactionPair[];
|
|
230
260
|
};
|