@link-assistant/hive-mind 2.15.2 → 2.16.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.
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Bounded-memory readers for execution logs (issue #2189).
5
+ *
6
+ * A `/solve` execution log is unbounded by construction: it is the verbatim
7
+ * transcript of an AI tool that may run for a day. The incident that motivated
8
+ * this module produced a **134 MB** log, and every completion-time helper that
9
+ * wanted a single fact out of it did `await fs.readFile(logPath, 'utf8')` —
10
+ * materialising the whole transcript as one JS string, four separate times per
11
+ * monitor tick, against V8's ~2 GB old-space cap. The run died with
12
+ * `FATAL ERROR: Reached heap limit` while the machine still had 10 GB free.
13
+ *
14
+ * Every reader here is bounded by construction:
15
+ *
16
+ * - {@link readLogHeadText} / {@link readLogTailText} read at most `maxBytes`
17
+ * from one end of the file.
18
+ * - {@link readLogTextBounded} returns the whole file while it is small and
19
+ * otherwise a head + tail excerpt with an explicit truncation marker, so
20
+ * marker-style parsers (`šŸ“Š [DISK]`, kill diagnostics, …) keep working on
21
+ * both ends of the transcript without ever holding the middle.
22
+ * - {@link scanLogChunks} streams the file forward in overlapping chunks and
23
+ * stops at the first chunk that answers the caller's question, so a scan
24
+ * that must cover the *whole* log still holds only one chunk at a time.
25
+ * - {@link forEachLogLine} streams a record-per-line file (JSONL transcripts)
26
+ * one line at a time, so peak residency is one line instead of the file plus
27
+ * the array of its lines.
28
+ *
29
+ * All readers are non-throwing: a missing or unreadable log yields the caller's
30
+ * empty value, exactly like the `readFile`-based code they replace.
31
+ *
32
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
33
+ */
34
+
35
+ import fsPromises from 'node:fs/promises';
36
+ import { createReadStream as fsCreateReadStream } from 'node:fs';
37
+ import readline from 'node:readline';
38
+
39
+ /** Default ceiling for "read the log as text" helpers (head + tail combined). */
40
+ export const DEFAULT_BOUNDED_LOG_BYTES = 4 * 1024 * 1024;
41
+
42
+ /** Default size of one forward scan chunk. */
43
+ export const DEFAULT_LOG_CHUNK_BYTES = 1024 * 1024;
44
+
45
+ /**
46
+ * Overlap between adjacent scan chunks so a marker split across a chunk
47
+ * boundary is still matched exactly once by the chunk that follows it.
48
+ */
49
+ export const LOG_CHUNK_OVERLAP_BYTES = 8192;
50
+
51
+ /** Marker inserted between the head and tail excerpts of a truncated read. */
52
+ export const LOG_TRUNCATION_MARKER = '\n…[log truncated: middle omitted by bounded reader, see full log file]…\n';
53
+
54
+ const toPositiveInt = (value, fallback) => (Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback);
55
+
56
+ /**
57
+ * Read a byte range of a file as UTF-8 text using a single file handle.
58
+ *
59
+ * @param {object} fsImpl - fs.promises-compatible implementation
60
+ * @param {string} logPath
61
+ * @param {number} position - Byte offset to start at
62
+ * @param {number} length - Number of bytes to read
63
+ * @returns {Promise<string>}
64
+ */
65
+ async function readRangeText(fsImpl, logPath, position, length) {
66
+ if (length <= 0) return '';
67
+ const handle = await fsImpl.open(logPath, 'r');
68
+ try {
69
+ const buffer = Buffer.alloc(length);
70
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
71
+ return buffer.subarray(0, bytesRead).toString('utf8');
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Read at most `maxBytes` from the START of a log file.
79
+ *
80
+ * @param {string} logPath
81
+ * @param {object} [options]
82
+ * @param {object} [options.fsImpl=fsPromises]
83
+ * @param {number} [options.maxBytes=DEFAULT_BOUNDED_LOG_BYTES]
84
+ * @returns {Promise<string>} Text (empty when the log cannot be read)
85
+ */
86
+ export async function readLogHeadText(logPath, { fsImpl = fsPromises, maxBytes = DEFAULT_BOUNDED_LOG_BYTES } = {}) {
87
+ if (!logPath) return '';
88
+ try {
89
+ const { size } = await fsImpl.stat(logPath);
90
+ return await readRangeText(fsImpl, logPath, 0, Math.min(size, toPositiveInt(maxBytes, DEFAULT_BOUNDED_LOG_BYTES)));
91
+ } catch {
92
+ return '';
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Read at most `maxBytes` from the END of a log file. The excerpt is aligned to
98
+ * the first newline inside the window so a caller never sees half a line.
99
+ *
100
+ * @param {string} logPath
101
+ * @param {object} [options]
102
+ * @param {object} [options.fsImpl=fsPromises]
103
+ * @param {number} [options.maxBytes=DEFAULT_BOUNDED_LOG_BYTES]
104
+ * @returns {Promise<string>} Text (empty when the log cannot be read)
105
+ */
106
+ export async function readLogTailText(logPath, { fsImpl = fsPromises, maxBytes = DEFAULT_BOUNDED_LOG_BYTES } = {}) {
107
+ if (!logPath) return '';
108
+ try {
109
+ const { size } = await fsImpl.stat(logPath);
110
+ const limit = Math.min(size, toPositiveInt(maxBytes, DEFAULT_BOUNDED_LOG_BYTES));
111
+ const start = Math.max(0, size - limit);
112
+ const text = await readRangeText(fsImpl, logPath, start, limit);
113
+ if (start === 0) return text;
114
+ const firstNewline = text.indexOf('\n');
115
+ return firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
116
+ } catch {
117
+ return '';
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Read a log as text without ever holding more than `maxBytes` of it.
123
+ *
124
+ * Small logs are returned verbatim. Larger logs are returned as
125
+ * `head + LOG_TRUNCATION_MARKER + tail`, each half being `maxBytes / 2`. That
126
+ * shape is deliberate: the facts completion-time reporting needs (start-up
127
+ * banner, `šŸ“Š [DISK] phase=after_clone`, the exit footer, `šŸ“Š [DISK]
128
+ * phase=after_agent`, crash stacks) live at the two ends of a transcript.
129
+ *
130
+ * `readFile` stays injectable so existing unit tests can keep handing these
131
+ * helpers a synthetic reader; it is used whenever `stat` cannot size the file.
132
+ *
133
+ * @param {string} logPath
134
+ * @param {object} [options]
135
+ * @param {object} [options.fsImpl=fsPromises]
136
+ * @param {Function} [options.readFile] - Injectable whole-file reader for small logs/tests
137
+ * @param {number} [options.maxBytes=DEFAULT_BOUNDED_LOG_BYTES]
138
+ * @param {boolean} [options.verbose=false]
139
+ * @returns {Promise<string>} Text (empty when the log cannot be read)
140
+ */
141
+ export async function readLogTextBounded(logPath, { fsImpl = fsPromises, readFile = null, maxBytes = DEFAULT_BOUNDED_LOG_BYTES, verbose = false } = {}) {
142
+ if (!logPath) return '';
143
+ const limit = toPositiveInt(maxBytes, DEFAULT_BOUNDED_LOG_BYTES);
144
+ const read = readFile || fsImpl.readFile.bind(fsImpl);
145
+ let size = null;
146
+ try {
147
+ size = (await fsImpl.stat(logPath)).size;
148
+ } catch {
149
+ // A caller-injected reader may serve paths that do not exist on disk, so an
150
+ // unstattable path is not an error — `size` simply stays null and the whole
151
+ // "file" is handed to that reader below.
152
+ }
153
+ if (size === null || size <= limit) {
154
+ try {
155
+ return String(await read(logPath, 'utf8'));
156
+ } catch (error) {
157
+ if (verbose) console.log(`[VERBOSE] log-bounded-read: could not read ${logPath}: ${error?.message || error}`);
158
+ return '';
159
+ }
160
+ }
161
+ const half = Math.max(1, Math.floor(limit / 2));
162
+ const head = await readLogHeadText(logPath, { fsImpl, maxBytes: half });
163
+ const tail = await readLogTailText(logPath, { fsImpl, maxBytes: half });
164
+ if (verbose) {
165
+ console.log(`[VERBOSE] log-bounded-read: ${logPath} is ${size} bytes; using ${head.length}+${tail.length} char head/tail excerpt`);
166
+ }
167
+ return `${head}${LOG_TRUNCATION_MARKER}${tail}`;
168
+ }
169
+
170
+ /**
171
+ * Stream a log forward in overlapping chunks, stopping as soon as `onChunk`
172
+ * returns a value that is neither `undefined` nor `null`.
173
+ *
174
+ * Only one chunk (plus the overlap carried from the previous one) is resident
175
+ * at a time, so this covers a log of any size in constant memory.
176
+ *
177
+ * @param {string} logPath
178
+ * @param {Function} onChunk - `(text, {offset, isFirst, isLast}) => any`
179
+ * @param {object} [options]
180
+ * @param {object} [options.fsImpl=fsPromises]
181
+ * @param {number} [options.chunkBytes=DEFAULT_LOG_CHUNK_BYTES]
182
+ * @param {number} [options.overlapBytes=LOG_CHUNK_OVERLAP_BYTES]
183
+ * @param {boolean} [options.verbose=false]
184
+ * @returns {Promise<any|null>} The first non-nullish result, or null
185
+ */
186
+ export async function scanLogChunks(logPath, onChunk, { fsImpl = fsPromises, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, overlapBytes = LOG_CHUNK_OVERLAP_BYTES, verbose = false } = {}) {
187
+ if (!logPath || typeof onChunk !== 'function') return null;
188
+ const step = toPositiveInt(chunkBytes, DEFAULT_LOG_CHUNK_BYTES);
189
+ const overlap = Math.min(toPositiveInt(overlapBytes, LOG_CHUNK_OVERLAP_BYTES), step - 1);
190
+ let handle = null;
191
+ try {
192
+ const { size } = await fsImpl.stat(logPath);
193
+ if (!size) return null;
194
+ handle = await fsImpl.open(logPath, 'r');
195
+ const buffer = Buffer.alloc(step);
196
+ let position = 0;
197
+ let carry = '';
198
+ let chunks = 0;
199
+ while (position < size) {
200
+ const { bytesRead } = await handle.read(buffer, 0, step, position);
201
+ if (!bytesRead) break;
202
+ const text = carry + buffer.subarray(0, bytesRead).toString('utf8');
203
+ chunks += 1;
204
+ const isLast = position + bytesRead >= size;
205
+ const result = onChunk(text, { offset: position, isFirst: position === 0, isLast });
206
+ if (result !== undefined && result !== null) {
207
+ if (verbose) console.log(`[VERBOSE] log-bounded-read: ${logPath} answered after ${chunks} chunk(s)`);
208
+ return result;
209
+ }
210
+ carry = text.slice(Math.max(0, text.length - overlap));
211
+ position += bytesRead;
212
+ }
213
+ if (verbose) console.log(`[VERBOSE] log-bounded-read: scanned all ${chunks} chunk(s) of ${logPath} with no match`);
214
+ return null;
215
+ } catch (error) {
216
+ if (verbose) console.log(`[VERBOSE] log-bounded-read: could not scan ${logPath}: ${error?.message || error}`);
217
+ return null;
218
+ } finally {
219
+ if (handle) await handle.close().catch(() => {});
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Collect the lines of a log that match `pattern`, never accumulating more than
225
+ * `maxBytes` of them. Used for marker-style parsing (`šŸ“Š [DISK] …`) where the
226
+ * interesting content is a handful of lines scattered through a huge file.
227
+ *
228
+ * @param {string} logPath
229
+ * @param {RegExp} pattern - Tested per line (a global regex is reset per test)
230
+ * @param {object} [options]
231
+ * @param {object} [options.fsImpl=fsPromises]
232
+ * @param {number} [options.maxBytes=65536] - Ceiling on collected text
233
+ * @param {number} [options.chunkBytes=DEFAULT_LOG_CHUNK_BYTES]
234
+ * @param {boolean} [options.verbose=false]
235
+ * @returns {Promise<string>} Matching lines joined by newlines
236
+ */
237
+ export async function collectLogLinesMatching(logPath, pattern, { fsImpl = fsPromises, maxBytes = 65536, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, verbose = false } = {}) {
238
+ if (!logPath || !(pattern instanceof RegExp)) return '';
239
+ const limit = toPositiveInt(maxBytes, 65536);
240
+ const collected = [];
241
+ let collectedBytes = 0;
242
+ let residual = '';
243
+ await scanLogChunks(
244
+ logPath,
245
+ (text, { isLast }) => {
246
+ // `overlapBytes: 0` below means chunks never repeat content, so the only
247
+ // state to carry forward is the trailing partial line.
248
+ const source = residual ? residual + text : text;
249
+ const lines = source.split('\n');
250
+ residual = isLast ? '' : (lines.pop() ?? '');
251
+ for (const line of lines) {
252
+ pattern.lastIndex = 0;
253
+ if (!pattern.test(line)) continue;
254
+ if (collectedBytes + line.length > limit) continue;
255
+ collected.push(line);
256
+ collectedBytes += line.length + 1;
257
+ }
258
+ return undefined;
259
+ },
260
+ { fsImpl, chunkBytes, overlapBytes: 0, verbose }
261
+ );
262
+ if (residual) {
263
+ pattern.lastIndex = 0;
264
+ if (pattern.test(residual) && collectedBytes + residual.length <= limit) collected.push(residual);
265
+ }
266
+ return collected.join('\n');
267
+ }
268
+
269
+ /**
270
+ * Whole-log scan that keeps an injectable whole-file reader working.
271
+ *
272
+ * Several call sites accept a `readFile` for tests that hand them a synthetic
273
+ * transcript for a path that does not exist on disk. This helper preserves that
274
+ * contract — the injected reader is used when the path cannot be stat'ed or is
275
+ * small enough to be harmless — while a real, large log is streamed chunk by
276
+ * chunk and never materialised.
277
+ *
278
+ * @param {string} logPath
279
+ * @param {Function} onText - `(text, {isFirst, isLast, offset}) => any`; first non-nullish result wins
280
+ * @param {object} [options]
281
+ * @param {object} [options.fsImpl=fsPromises]
282
+ * @param {Function} [options.readFile] - Injectable whole-file reader for small/synthetic logs
283
+ * @param {number} [options.chunkBytes=DEFAULT_LOG_CHUNK_BYTES]
284
+ * @param {number} [options.overlapBytes=LOG_CHUNK_OVERLAP_BYTES]
285
+ * @param {boolean} [options.verbose=false]
286
+ * @returns {Promise<any|null>}
287
+ */
288
+ export async function scanLogTextChunks(logPath, onText, { fsImpl = fsPromises, readFile = null, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, overlapBytes = LOG_CHUNK_OVERLAP_BYTES, verbose = false } = {}) {
289
+ if (!logPath || typeof onText !== 'function') return null;
290
+ const step = toPositiveInt(chunkBytes, DEFAULT_LOG_CHUNK_BYTES);
291
+ let size = null;
292
+ try {
293
+ size = (await fsImpl.stat(logPath)).size;
294
+ } catch {
295
+ // Not on disk (or not readable): fall through to the injected reader.
296
+ }
297
+ if (size === null || size <= step) {
298
+ const read = readFile || fsImpl.readFile.bind(fsImpl);
299
+ try {
300
+ const text = String(await read(logPath, 'utf8'));
301
+ const result = onText(text, { offset: 0, isFirst: true, isLast: true });
302
+ return result === undefined ? null : result;
303
+ } catch (error) {
304
+ if (verbose) console.log(`[VERBOSE] log-bounded-read: could not read ${logPath}: ${error?.message || error}`);
305
+ return null;
306
+ }
307
+ }
308
+ return scanLogChunks(logPath, onText, { fsImpl, chunkBytes: step, overlapBytes, verbose });
309
+ }
310
+
311
+ /**
312
+ * Marker-line collection that keeps an injectable whole-file reader working.
313
+ *
314
+ * Same contract as {@link scanLogTextChunks}, for the `šŸ“Š [DISK]`-style parsers
315
+ * that only ever look at individual matching lines: a real log is scanned in
316
+ * chunks and only the matching lines are kept, so a 134 MB transcript costs one
317
+ * chunk plus a few kilobytes of results.
318
+ *
319
+ * @param {string} logPath
320
+ * @param {RegExp} pattern
321
+ * @param {object} [options] - As {@link collectLogLinesMatching}, plus `readFile`
322
+ * @returns {Promise<string>}
323
+ */
324
+ export async function readLogMarkerLines(logPath, pattern, { fsImpl = fsPromises, readFile = null, maxBytes = 65536, chunkBytes = DEFAULT_LOG_CHUNK_BYTES, verbose = false } = {}) {
325
+ if (!logPath || !(pattern instanceof RegExp)) return '';
326
+ let statable = true;
327
+ try {
328
+ await fsImpl.stat(logPath);
329
+ } catch {
330
+ statable = false;
331
+ }
332
+ if (statable) return collectLogLinesMatching(logPath, pattern, { fsImpl, maxBytes, chunkBytes, verbose });
333
+ const read = readFile || fsImpl.readFile.bind(fsImpl);
334
+ try {
335
+ const text = String(await read(logPath, 'utf8'));
336
+ const limit = toPositiveInt(maxBytes, 65536);
337
+ const collected = [];
338
+ let collectedBytes = 0;
339
+ for (const line of text.split('\n')) {
340
+ pattern.lastIndex = 0;
341
+ if (!pattern.test(line)) continue;
342
+ if (collectedBytes + line.length > limit) continue;
343
+ collected.push(line);
344
+ collectedBytes += line.length + 1;
345
+ }
346
+ return collected.join('\n');
347
+ } catch (error) {
348
+ if (verbose) console.log(`[VERBOSE] log-bounded-read: could not read ${logPath}: ${error?.message || error}`);
349
+ return '';
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Stream a record-per-line file, one line at a time.
355
+ *
356
+ * `fs.readFile(file, 'utf8').split('\n')` costs *two* full copies of the file
357
+ * (the string and the array of its lines) before the first record is looked at.
358
+ * Claude/Codex session transcripts are JSONL that grows with the session, so
359
+ * that shape scales with how long the AI ran — the exact class of unbounded
360
+ * buffering issue #2189 removes.
361
+ *
362
+ * The line terminator is stripped, as with `split('\n')`; `\r\n` is treated as
363
+ * one terminator. `onLine` may return `false` to stop early. Missing/unreadable
364
+ * files rethrow, so callers keep their existing error handling.
365
+ *
366
+ * @param {string} logPath
367
+ * @param {Function} onLine - `(line, index) => void|false|Promise<void|false>`
368
+ * @param {object} [options]
369
+ * @param {Function} [options.createReadStream=fs.createReadStream]
370
+ * @param {number} [options.highWaterMark=DEFAULT_LOG_CHUNK_BYTES]
371
+ * @returns {Promise<number>} Number of lines visited
372
+ */
373
+ export async function forEachLogLine(logPath, onLine, { createReadStream = fsCreateReadStream, highWaterMark = DEFAULT_LOG_CHUNK_BYTES } = {}) {
374
+ const stream = createReadStream(logPath, { highWaterMark });
375
+ const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
376
+ let index = 0;
377
+ try {
378
+ for await (const line of reader) {
379
+ const proceed = await onLine(line, index);
380
+ index += 1;
381
+ if (proceed === false) break;
382
+ }
383
+ } finally {
384
+ reader.close();
385
+ stream.destroy();
386
+ }
387
+ return index;
388
+ }
389
+
390
+ /**
391
+ * True when `filePath` ends with a newline.
392
+ *
393
+ * A rewriter that streams lines has to restore the file's original trailing
394
+ * newline explicitly: unlike `split('\n')`, a line reader does not report the
395
+ * empty final element that a trailing terminator produces.
396
+ *
397
+ * @param {string} filePath
398
+ * @param {object} [options]
399
+ * @param {object} [options.fsImpl=fsPromises]
400
+ * @returns {Promise<boolean>}
401
+ */
402
+ export async function fileEndsWithNewline(filePath, { fsImpl = fsPromises } = {}) {
403
+ try {
404
+ const { size } = await fsImpl.stat(filePath);
405
+ if (!size) return false;
406
+ const text = await readRangeText(fsImpl, filePath, size - 1, 1);
407
+ return text === '\n' || text === '\r';
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Streaming, bounded-memory sanitization of an execution log into a publishable
5
+ * file (issue #2189).
6
+ *
7
+ * The `--attach-logs` path used to do this:
8
+ *
9
+ * ```js
10
+ * const rawLogContent = await fs.readFile(logFile, 'utf8'); // 134 MB string
11
+ * let logContent = await sanitizeForPublication(rawLogContent); // + full copy
12
+ * logContent = escapeCodeBlocksInLog(logContent); // + full copy
13
+ * …
14
+ * await writeSanitizedPublicationFile(tempLogFile, rawLogContent); // + again
15
+ * ```
16
+ *
17
+ * With V8's ~2 GB old-space cap that reliably ends in
18
+ * `FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of
19
+ * memory`, and the observed crash frame — `Runtime_RegExpExecMultiple` — is the
20
+ * global-regex replace those passes run over the whole transcript at once.
21
+ *
22
+ * This module sanitizes the same content **block by block**. Peak residency is
23
+ * one block (1 MiB by default) plus whatever the sanitizer allocates for it, so
24
+ * a 1 GB log costs the same as a 1 MB log.
25
+ *
26
+ * ## Why block boundaries are safe
27
+ *
28
+ * Blocks are cut on record boundaries and a block is only released when the
29
+ * bytes after it cannot belong to a credential that started inside it:
30
+ *
31
+ * - a partial line is never released (a token split mid-line would be
32
+ * invisible to both halves);
33
+ * - an unterminated `-----BEGIN … PRIVATE KEY-----` block is held until its
34
+ * matching `-----END …-----` arrives;
35
+ * - a trailing run of base64-only lines is held until a line that cannot
36
+ * continue the blob arrives (issue #2156's wrapped-payload rule).
37
+ *
38
+ * This is exactly the hold-back contract {@link createCredentialStreamSanitizer}
39
+ * already applies to child-process output; the difference is that this module
40
+ * runs the *full* fail-closed publication sanitizer (maintained patterns +
41
+ * Secretlint + residual re-scan) on each released block rather than the
42
+ * dependency-free subset.
43
+ *
44
+ * A hold is itself capped ({@link DEFAULT_MAX_HOLD_BYTES}) so a log containing a
45
+ * stray `-----BEGIN` marker with no terminator — or one enormous line — can
46
+ * never reintroduce the unbounded growth this module exists to remove. Real PEM
47
+ * keys are a few kilobytes, so the cap is unreachable by legitimate content.
48
+ *
49
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
50
+ * @see https://github.com/link-assistant/hive-mind/issues/2156
51
+ */
52
+
53
+ import fsPromises from 'node:fs/promises';
54
+ import { StringDecoder } from 'node:string_decoder';
55
+ import { wrappedBase64HoldStart } from './encoded-credential-detection.lib.mjs';
56
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs';
57
+
58
+ /** Bytes read from the source per iteration. */
59
+ export const DEFAULT_SANITIZE_CHUNK_BYTES = 1024 * 1024;
60
+
61
+ /** Hard ceiling on text held back waiting for a terminator. */
62
+ export const DEFAULT_MAX_HOLD_BYTES = 8 * 1024 * 1024;
63
+
64
+ const PEM_BEGIN_RE = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/;
65
+
66
+ /**
67
+ * How many characters at the head of `pending` may be released now.
68
+ *
69
+ * Mirrors the hold-back rules of `createCredentialStreamSanitizer`, but returns
70
+ * the boundary instead of sanitizing, so the caller can run the asynchronous
71
+ * publication sanitizer over the released slice.
72
+ *
73
+ * @param {string} pending - Buffered, not-yet-released text
74
+ * @returns {number} Character count that is safe to release (0 = hold all)
75
+ */
76
+ export function computeReleaseBoundary(pending) {
77
+ if (!pending) return 0;
78
+ const begin = PEM_BEGIN_RE.exec(pending);
79
+ if (begin) {
80
+ const endMarker = `-----END ${begin[1]}-----`;
81
+ const endIndex = pending.indexOf(endMarker, begin.index + begin[0].length);
82
+ // Everything before the marker is releasable; the key itself waits for its
83
+ // terminator so it is always sanitized as one complete unit.
84
+ if (endIndex < 0) return begin.index;
85
+ return endIndex + endMarker.length;
86
+ }
87
+ const boundary = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
88
+ if (boundary < 0) return 0;
89
+ const releaseEnd = wrappedBase64HoldStart(pending, boundary + 1) ?? boundary + 1;
90
+ return Math.max(0, releaseEnd);
91
+ }
92
+
93
+ /**
94
+ * Walk `sourcePath` in hold-back-safe blocks without ever holding the file.
95
+ *
96
+ * Every consumer in this module (write a sanitized copy, re-scan a published
97
+ * artifact) needs the same loop: read a chunk, decide how much of the buffer can
98
+ * be released without cutting a credential in half, hand that slice over, keep
99
+ * the rest. Only the per-block action differs, so the loop lives here once.
100
+ *
101
+ * `onBlock` may return `false` to stop the walk early (used by the verifier,
102
+ * which has its answer as soon as one block comes back changed).
103
+ *
104
+ * @param {string} sourcePath - File to walk
105
+ * @param {Function} onBlock - `(text) => Promise<void|false>`
106
+ * @param {object} [options]
107
+ * @param {number} [options.chunkBytes=DEFAULT_SANITIZE_CHUNK_BYTES]
108
+ * @param {number} [options.maxHoldBytes=DEFAULT_MAX_HOLD_BYTES]
109
+ * @param {number} [options.startByte=0] - First byte to read (for slice copies)
110
+ * @param {number|null} [options.endByte=null] - Byte to stop at; defaults to EOF
111
+ * @param {object} [options.fsImpl=fsPromises]
112
+ * @param {Function} [options.onProgress] - `({bytesRead, sourceSize, blocks}) => void`
113
+ * @returns {Promise<{sourceSize: number, bytesRead: number, blocks: number, forcedReleases: number}>}
114
+ */
115
+ export async function forEachLogBlock(sourcePath, onBlock, options = {}) {
116
+ const { chunkBytes = DEFAULT_SANITIZE_CHUNK_BYTES, maxHoldBytes = DEFAULT_MAX_HOLD_BYTES, startByte = 0, endByte = null, fsImpl = fsPromises, onProgress = null } = options;
117
+ if (!sourcePath) throw new TypeError('forEachLogBlock requires a sourcePath');
118
+
119
+ const step = Number.isFinite(chunkBytes) && chunkBytes > 0 ? Math.floor(chunkBytes) : DEFAULT_SANITIZE_CHUNK_BYTES;
120
+ const holdCap = Number.isFinite(maxHoldBytes) && maxHoldBytes > 0 ? Math.floor(maxHoldBytes) : DEFAULT_MAX_HOLD_BYTES;
121
+ const stats = { sourceSize: 0, bytesRead: 0, blocks: 0, forcedReleases: 0 };
122
+
123
+ const source = await fsImpl.open(sourcePath, 'r');
124
+ try {
125
+ stats.sourceSize = (await source.stat()).size;
126
+ const limit = endByte === null || !Number.isFinite(endByte) ? stats.sourceSize : Math.max(0, Math.min(Math.floor(endByte), stats.sourceSize));
127
+ const first = Number.isFinite(startByte) && startByte > 0 ? Math.min(Math.floor(startByte), limit) : 0;
128
+
129
+ const buffer = Buffer.alloc(step);
130
+ const decoder = new StringDecoder('utf8');
131
+ let pending = '';
132
+ let position = first;
133
+ let stopped = false;
134
+
135
+ const emit = async text => {
136
+ if (!text || stopped) return;
137
+ stats.blocks += 1;
138
+ if ((await onBlock(text)) === false) stopped = true;
139
+ };
140
+
141
+ while (position < limit && !stopped) {
142
+ const { bytesRead } = await source.read(buffer, 0, Math.min(step, limit - position), position);
143
+ if (!bytesRead) break;
144
+ position += bytesRead;
145
+ stats.bytesRead = position - first;
146
+ pending += decoder.write(buffer.subarray(0, bytesRead));
147
+
148
+ let boundary = computeReleaseBoundary(pending);
149
+ if (boundary <= 0 && pending.length > holdCap) {
150
+ // Never grow without bound: fall back to the last record boundary, or
151
+ // the whole buffer when the file has no record boundary at all.
152
+ const lastRecord = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
153
+ boundary = lastRecord >= 0 ? lastRecord + 1 : pending.length;
154
+ stats.forcedReleases += 1;
155
+ }
156
+ if (boundary > 0) {
157
+ await emit(pending.slice(0, boundary));
158
+ pending = pending.slice(boundary);
159
+ }
160
+ if (onProgress) onProgress({ bytesRead: stats.bytesRead, sourceSize: stats.sourceSize, blocks: stats.blocks });
161
+ }
162
+
163
+ pending += decoder.end();
164
+ await emit(pending);
165
+ return stats;
166
+ } finally {
167
+ await source.close().catch(() => {});
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Sanitize `sourcePath` into `destPath` without ever holding the whole file.
173
+ *
174
+ * The destination is created exclusively (`wx`, mode 0600) exactly like
175
+ * {@link writeSanitizedPublicationFile}, so a pre-planted symlink in a shared
176
+ * temporary directory cannot be followed. If any block fails the fail-closed
177
+ * publication scan the partial destination is removed and the error rethrown —
178
+ * a partially-sanitized file is never left behind for a caller to upload.
179
+ *
180
+ * @param {object} options
181
+ * @param {string} options.sourcePath - Log to sanitize
182
+ * @param {string} options.destPath - File to create
183
+ * @param {number} [options.chunkBytes=DEFAULT_SANITIZE_CHUNK_BYTES]
184
+ * @param {number} [options.maxHoldBytes=DEFAULT_MAX_HOLD_BYTES]
185
+ * @param {number} [options.startByte=0] - First source byte to copy
186
+ * @param {number|null} [options.endByte=null] - Source byte to stop at; defaults to EOF
187
+ * @param {Function} [options.sanitize=sanitizeForPublication] - `(text) => Promise<string>`
188
+ * @param {Function} [options.transform] - Optional per-block post-transform (e.g. markdown escaping)
189
+ * @param {object} [options.fsImpl=fsPromises]
190
+ * @param {Function} [options.onProgress] - `({bytesRead, sourceSize, blocks}) => void`
191
+ * @returns {Promise<{sourceSize: number, bytesRead: number, charsWritten: number, blocks: number, forcedReleases: number}>}
192
+ */
193
+ export async function sanitizeLogFileToFile(options = {}) {
194
+ const { sourcePath, destPath, chunkBytes = DEFAULT_SANITIZE_CHUNK_BYTES, maxHoldBytes = DEFAULT_MAX_HOLD_BYTES, startByte = 0, endByte = null, sanitize = sanitizeForPublication, transform = null, fsImpl = fsPromises, onProgress = null } = options;
195
+ if (!sourcePath) throw new TypeError('sanitizeLogFileToFile requires a sourcePath');
196
+ if (!destPath) throw new TypeError('sanitizeLogFileToFile requires a destPath');
197
+
198
+ let dest = null;
199
+ let destCreated = false;
200
+ let charsWritten = 0;
201
+
202
+ try {
203
+ dest = await fsImpl.open(destPath, 'wx', 0o600);
204
+ destCreated = true;
205
+
206
+ const stats = await forEachLogBlock(
207
+ sourcePath,
208
+ async text => {
209
+ const sanitized = String(await sanitize(text));
210
+ const out = transform ? String(transform(sanitized)) : sanitized;
211
+ if (!out) return;
212
+ await dest.write(out, null, 'utf8');
213
+ charsWritten += out.length;
214
+ },
215
+ { chunkBytes, maxHoldBytes, startByte, endByte, fsImpl, onProgress }
216
+ );
217
+
218
+ await dest.chmod(0o600);
219
+ return { ...stats, charsWritten };
220
+ } catch (error) {
221
+ if (destCreated) {
222
+ try {
223
+ if (dest) await dest.close();
224
+ } catch {
225
+ /* closing a failed handle is best effort */
226
+ }
227
+ dest = null;
228
+ await fsImpl.unlink(destPath).catch(() => {});
229
+ }
230
+ throw error;
231
+ } finally {
232
+ if (dest) await dest.close().catch(() => {});
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Re-scan an already-published file for residual credential material.
238
+ *
239
+ * This is the streaming form of the development-log rescan, which used to read
240
+ * every artifact back into a single string (`fs.readFile(filePath, 'utf8')`) and
241
+ * sanitize that string a second time — the exact double-buffering issue #2189
242
+ * removes. The guarantee is unchanged: every byte of the file is passed through
243
+ * the fail-closed publication sanitizer, and any block the sanitizer would still
244
+ * change is reported.
245
+ *
246
+ * @param {string} filePath
247
+ * @param {object} [options] - Also accepts every {@link forEachLogBlock} option
248
+ * @param {Function} [options.sanitize=sanitizeForPublication]
249
+ * @returns {Promise<{blockIndex: number, length: number}|null>} `null` when clean
250
+ */
251
+ export async function findResidualCredentialBlock(filePath, options = {}) {
252
+ const { sanitize = sanitizeForPublication, ...walkOptions } = options;
253
+ let residual = null;
254
+ let blockIndex = 0;
255
+ await forEachLogBlock(
256
+ filePath,
257
+ async text => {
258
+ blockIndex += 1;
259
+ const sanitized = String(await sanitize(text));
260
+ if (sanitized === text) return;
261
+ residual = { blockIndex, length: text.length };
262
+ return false;
263
+ },
264
+ walkOptions
265
+ );
266
+ return residual;
267
+ }