meta-sovereign 0.49.6 → 0.49.8

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.
@@ -43,6 +43,7 @@ import {
43
43
  createBackup,
44
44
  pruneBackups,
45
45
  restoreBackup,
46
+ restoreBackupToDir,
46
47
  } from '../storage/backup.js';
47
48
  import { writeEncryptedExport } from '../storage/export-encrypted.js';
48
49
  import { CURRENT_SCHEMA } from '../storage/migrations/index.js';
@@ -80,7 +81,7 @@ Commands:
80
81
  import --source=<name> --file=<path> --store=<dir>
81
82
  export --file=<path> --store=<dir>
82
83
  backup --store=<dir> --archive=<dir> [--keep=<n>]
83
- restore --file=<path> --store=<dir>
84
+ restore --file=<path> --store=<dir> [--replace]
84
85
  passcode --clear [--store=<dir>] (remove a forgotten local passcode)
85
86
  serve [--port=<n>] [--store=<dir>] [--allow-origin=<origins|none>] [--ui-url=<url>] [--local-ui=false] [--tls=false] [--quiet-logs]
86
87
  verbose logs are written to <store>/logs/meta-sovereign.log by default
@@ -171,6 +172,18 @@ const passcodeCmd = async (args, log) => {
171
172
  };
172
173
 
173
174
  const restoreCmd = async (args, log) => {
175
+ if (args.replace) {
176
+ // Swap the whole log in one step; the store must not be in use (R-Z16).
177
+ const n = await restoreBackupToDir(
178
+ args.file,
179
+ args.store ?? '.meta-sovereign',
180
+ {
181
+ replace: true,
182
+ }
183
+ );
184
+ log(`replaced the store with ${n} records from ${args.file}`);
185
+ return 0;
186
+ }
174
187
  const store = await openStore(args.store ?? '.meta-sovereign');
175
188
  const n = await restoreBackup(store, args.file);
176
189
  log(`restored ${n} links from ${args.file}`);
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The automatic backup, started once the port is ours (R-A4, R-Z7).
3
3
  *
4
- * OFF by default (the streaming backups of R-Z16 switch it on).
5
- * `MS_BACKUP_INTERVAL_HOURS` (or the `backups.intervalHours` option) sets
6
- * the interval in hours, `0` or unset means off; `MS_BACKUP_KEEP` (or
7
- * `backups.keep`) sets how many archives are kept (default 7, `0` keeps
4
+ * ON by default (R-Z16): a streaming backup every 24 hours, the newest 3
5
+ * kept. `MS_BACKUP_INTERVAL_HOURS` (or the `backups.intervalHours` option)
6
+ * sets the interval in hours, `0` turns it off; `MS_BACKUP_KEEP` (or
7
+ * `backups.keep`) sets how many archives are kept (default 3, `0` keeps
8
8
  * all). A value that is not a number >= 0 is ignored for the default and
9
9
  * logged once.
10
10
  * The archive is encrypted, and holds the secrets, only when the server has
@@ -15,8 +15,8 @@
15
15
  import { createBackupScheduler } from '../storage/backup.js';
16
16
 
17
17
  const HOUR_MS = 3_600_000;
18
- export const DEFAULT_INTERVAL_HOURS = 0;
19
- export const DEFAULT_KEEP = 7;
18
+ export const DEFAULT_INTERVAL_HOURS = 24;
19
+ export const DEFAULT_KEEP = 3;
20
20
 
21
21
  // A number >= 0, or the fallback; `bad` collects the names that were wrong.
22
22
  const number = (value, fallback, name, bad) => {
@@ -18,17 +18,16 @@ import { promises as fs } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { json, readBody } from './util.js';
20
20
  import { safeMessage } from './redact.js';
21
- import { makeBackup, restoreBackup, pruneBackups } from '../storage/backup.js';
21
+ import {
22
+ isBackupFile,
23
+ makeBackup,
24
+ restoreBackup,
25
+ pruneBackups,
26
+ } from '../storage/backup.js';
22
27
 
23
28
  const listFiles = async (archiveDir) => {
24
29
  const entries = await fs.readdir(archiveDir).catch(() => []);
25
- const ours = entries
26
- .filter(
27
- (f) =>
28
- f.startsWith('meta-sovereign-') &&
29
- (f.endsWith('.json') || f.endsWith('.json.enc'))
30
- )
31
- .sort();
30
+ const ours = entries.filter(isBackupFile).sort();
32
31
  const out = [];
33
32
  for (const f of ours) {
34
33
  const stat = await fs.stat(path.join(archiveDir, f)).catch(() => null);
@@ -0,0 +1,571 @@
1
+ /**
2
+ * The streaming backup archive, format 2 (R-Z16).
3
+ *
4
+ * A real store is gigabytes: the old archive (one JSON string holding every
5
+ * link) cannot even be built past V8's string limit. Format 2 is read and
6
+ * written a piece at a time, so memory stays flat whatever the store size.
7
+ *
8
+ * Plain content (what is gzipped), line by line:
9
+ *
10
+ * {"format":"meta-sovereign-backup","version":2,"createdAt":...,"secrets":...}
11
+ * <one Links Notation record per line, the store's log as it was>
12
+ * #end {"lines":N,"secretsExcluded":K,"bytes":B,"sha256":"<of every line above>"}
13
+ *
14
+ * File on disk: a 28-byte header, then frames of the gzip stream:
15
+ *
16
+ * header = "MSBK2\n" | mode (0 plain, 1 AES-256-GCM) | 0 | salt(16) | nonce prefix(4)
17
+ * frame = final flag (1) | length (4, BE) | data (length) | tag (16)
18
+ *
19
+ * Every frame (up to 1 MB) is sealed on its own: AES-256-GCM with the
20
+ * nonce `prefix | frame index`, or (no passphrase) a SHA-256 check tag. The
21
+ * additional data is `header | index | final flag`, so a flipped byte, a
22
+ * reordered or dropped frame, and a file cut short are all detected, and
23
+ * a frame is checked before any of it is used. The key is scrypt of the
24
+ * passphrase and the salt, as before.
25
+ */
26
+
27
+ import { createReadStream, promises as fs } from 'node:fs';
28
+ import { createGunzip, createGzip } from 'node:zlib';
29
+ import { Readable } from 'node:stream';
30
+ import { StringDecoder } from 'node:string_decoder';
31
+ import {
32
+ createCipheriv,
33
+ createDecipheriv,
34
+ createHash,
35
+ randomBytes,
36
+ scrypt,
37
+ } from 'node:crypto';
38
+ import { decodeLine } from 'lino-objects-codec';
39
+
40
+ export const FRAME_BYTES = 1024 * 1024;
41
+ const MAGIC = Buffer.from('MSBK2\n');
42
+ const HEADER_BYTES = 28;
43
+ const TAG_BYTES = 16;
44
+ const KDF_N = 1 << 14;
45
+ export const FORMAT = 'meta-sovereign-backup';
46
+ const TRAILER = '#end ';
47
+
48
+ /** An error a person can act on, with a stable `code`. */
49
+ export const archiveError = (code, message) =>
50
+ Object.assign(new Error(message), { code });
51
+
52
+ const deriveKey = (passphrase, salt) =>
53
+ new Promise((resolve, reject) =>
54
+ scrypt(passphrase, salt, 32, { N: KDF_N }, (error, key) =>
55
+ error ? reject(error) : resolve(key)
56
+ )
57
+ );
58
+
59
+ const aadOf = (header, index, final) => {
60
+ const tail = Buffer.alloc(9);
61
+ tail.writeBigUInt64BE(BigInt(index), 0);
62
+ tail[8] = final ? 1 : 0;
63
+ return Buffer.concat([header, tail]);
64
+ };
65
+
66
+ const nonceOf = (header, index) => {
67
+ const nonce = Buffer.alloc(12);
68
+ header.copy(nonce, 0, 24, 28);
69
+ nonce.writeBigUInt64BE(BigInt(index), 4);
70
+ return nonce;
71
+ };
72
+
73
+ // The check tag of a plain frame: it catches damage, not a forger (nobody
74
+ // can promise that without a key).
75
+ const plainTag = (aad, data) =>
76
+ createHash('sha256').update(aad).update(data).digest().subarray(0, TAG_BYTES);
77
+
78
+ const sealFrame = ({ header, key }, index, final, data) => {
79
+ const aad = aadOf(header, index, final);
80
+ let body = data;
81
+ let tag;
82
+ if (key) {
83
+ const cipher = createCipheriv('aes-256-gcm', key, nonceOf(header, index));
84
+ cipher.setAAD(aad);
85
+ body = Buffer.concat([cipher.update(data), cipher.final()]);
86
+ tag = cipher.getAuthTag();
87
+ } else {
88
+ tag = plainTag(aad, data);
89
+ }
90
+ const head = Buffer.alloc(5);
91
+ head[0] = final ? 1 : 0;
92
+ head.writeUInt32BE(body.length, 1);
93
+ return Buffer.concat([head, body, tag]);
94
+ };
95
+
96
+ /** A pipeline stage: gzip chunks in, sealed frames (header first) out. */
97
+ export const sealStage = (passphrase) =>
98
+ async function* seal(source) {
99
+ const salt = passphrase ? randomBytes(16) : Buffer.alloc(16);
100
+ const header = Buffer.concat([
101
+ MAGIC,
102
+ Buffer.from([passphrase ? 1 : 0, 0]),
103
+ salt,
104
+ randomBytes(4),
105
+ ]);
106
+ const key = passphrase ? await deriveKey(passphrase, salt) : null;
107
+ const ctx = { header, key };
108
+ yield header;
109
+ let index = 0;
110
+ let pending = [];
111
+ let size = 0;
112
+ for await (const chunk of source) {
113
+ pending.push(chunk);
114
+ size += chunk.length;
115
+ if (size > FRAME_BYTES) {
116
+ const all = Buffer.concat(pending);
117
+ let at = 0;
118
+ while (all.length - at > FRAME_BYTES) {
119
+ yield sealFrame(
120
+ ctx,
121
+ index,
122
+ false,
123
+ all.subarray(at, at + FRAME_BYTES)
124
+ );
125
+ index += 1;
126
+ at += FRAME_BYTES;
127
+ }
128
+ pending = [all.subarray(at)];
129
+ size = all.length - at;
130
+ }
131
+ }
132
+ // The last frame is always sealed as such, even when empty.
133
+ yield sealFrame(ctx, index, true, Buffer.concat(pending));
134
+ };
135
+
136
+ // A reader that hands out exact byte counts from a stream of chunks.
137
+ const createTaker = (source) => {
138
+ const iterator = source[Symbol.asyncIterator]();
139
+ let list = [];
140
+ let have = 0;
141
+ let done = false;
142
+ return async (count) => {
143
+ while (have < count && !done) {
144
+ const next = await iterator.next();
145
+ if (next.done) {
146
+ done = true;
147
+ } else {
148
+ list.push(next.value);
149
+ have += next.value.length;
150
+ }
151
+ }
152
+ if (have < count) {
153
+ return null;
154
+ }
155
+ const all = Buffer.concat(list);
156
+ list = all.length > count ? [all.subarray(count)] : [];
157
+ have = all.length - count;
158
+ return all.subarray(0, count);
159
+ };
160
+ };
161
+
162
+ const truncated = () =>
163
+ archiveError(
164
+ 'BACKUP_TRUNCATED',
165
+ 'the backup file is cut short (incomplete write or copy)'
166
+ );
167
+
168
+ const openFrame = ({ header, key }, index, frame, tag) => {
169
+ const final = frame.final;
170
+ const aad = aadOf(header, index, final);
171
+ if (key) {
172
+ const decipher = createDecipheriv(
173
+ 'aes-256-gcm',
174
+ key,
175
+ nonceOf(header, index)
176
+ );
177
+ decipher.setAAD(aad);
178
+ decipher.setAuthTag(tag);
179
+ return Buffer.concat([decipher.update(frame.data), decipher.final()]);
180
+ }
181
+ if (!plainTag(aad, frame.data).equals(tag)) {
182
+ throw new Error('frame check failed');
183
+ }
184
+ return frame.data;
185
+ };
186
+
187
+ const readHeader = async (take, passphrase) => {
188
+ const header = await take(HEADER_BYTES);
189
+ if (!header || !header.subarray(0, MAGIC.length).equals(MAGIC)) {
190
+ throw archiveError('BACKUP_INVALID', 'not a meta-sovereign backup file');
191
+ }
192
+ const encrypted = header[6] === 1;
193
+ if (encrypted && !passphrase) {
194
+ throw archiveError(
195
+ 'BACKUP_PASSPHRASE_REQUIRED',
196
+ 'encrypted backup requires a passphrase'
197
+ );
198
+ }
199
+ const key = encrypted
200
+ ? await deriveKey(passphrase, header.subarray(8, 24))
201
+ : null;
202
+ return { header, key, encrypted };
203
+ };
204
+
205
+ // A frame that does not open: the first of an encrypted file is most
206
+ // likely a wrong passphrase, any other a changed or reordered file.
207
+ const failedFrame = (index, encrypted) =>
208
+ index === 0 && encrypted
209
+ ? archiveError(
210
+ 'BACKUP_BAD_PASSPHRASE',
211
+ 'wrong passphrase, or the backup file is damaged'
212
+ )
213
+ : archiveError(
214
+ 'BACKUP_TAMPERED',
215
+ `the backup file was changed or reordered (frame ${index})`
216
+ );
217
+
218
+ /**
219
+ * The gzip bytes of an archive file, frame by frame, each checked before
220
+ * it is handed on. Throws `BACKUP_*` errors: PASSPHRASE_REQUIRED, INVALID
221
+ * (not an archive), TRUNCATED, BAD_PASSPHRASE (the first frame does not
222
+ * open), TAMPERED (a later frame does not).
223
+ */
224
+ export async function* unframe(source, { passphrase = null } = {}) {
225
+ const take = createTaker(source);
226
+ const { header, key, encrypted } = await readHeader(take, passphrase);
227
+ for (let index = 0; ; index += 1) {
228
+ const head = await take(5);
229
+ const length = head?.readUInt32BE(1);
230
+ if (length > FRAME_BYTES) {
231
+ throw archiveError('BACKUP_INVALID', 'the backup file is damaged');
232
+ }
233
+ const body = head && (await take(length + TAG_BYTES));
234
+ if (!body) {
235
+ throw truncated();
236
+ }
237
+ const frame = { final: head[0] === 1, data: body.subarray(0, length) };
238
+ let data;
239
+ try {
240
+ data = openFrame({ header, key }, index, frame, body.subarray(length));
241
+ } catch {
242
+ throw failedFrame(index, encrypted);
243
+ }
244
+ yield data;
245
+ if (frame.final) {
246
+ if (await take(1)) {
247
+ throw archiveError(
248
+ 'BACKUP_INVALID',
249
+ 'data after the end of the backup'
250
+ );
251
+ }
252
+ return;
253
+ }
254
+ }
255
+ }
256
+
257
+ // '\n'-separated lines of a byte stream (a line is never split by a chunk).
258
+ async function* linesOf(source) {
259
+ const decoder = new StringDecoder('utf8');
260
+ let rest = '';
261
+ for await (const chunk of source) {
262
+ rest += typeof chunk === 'string' ? chunk : decoder.write(chunk);
263
+ let at = rest.indexOf('\n');
264
+ while (at !== -1) {
265
+ yield rest.slice(0, at);
266
+ rest = rest.slice(at + 1);
267
+ at = rest.indexOf('\n');
268
+ }
269
+ }
270
+ rest += decoder.end();
271
+ if (rest.length > 0) {
272
+ yield rest;
273
+ }
274
+ }
275
+
276
+ export const isRecordLine = (line) => line.startsWith('(');
277
+
278
+ const parseManifest = (line) => {
279
+ let manifest;
280
+ try {
281
+ manifest = JSON.parse(line);
282
+ } catch {
283
+ manifest = null;
284
+ }
285
+ if (manifest?.format !== FORMAT || manifest.version !== 2) {
286
+ throw archiveError('BACKUP_INVALID', 'not a meta-sovereign backup file');
287
+ }
288
+ return manifest;
289
+ };
290
+
291
+ const parseTrailer = (line) => {
292
+ try {
293
+ return JSON.parse(line.slice(TRAILER.length));
294
+ } catch {
295
+ throw archiveError('BACKUP_INVALID', 'the backup trailer is unreadable');
296
+ }
297
+ };
298
+
299
+ const checkTrailer = ({ manifest, trailer, lines }, sha256) => {
300
+ if (!manifest || !trailer) {
301
+ throw truncated();
302
+ }
303
+ if (trailer.sha256 !== sha256 || trailer.lines !== lines) {
304
+ throw archiveError(
305
+ 'BACKUP_INVALID',
306
+ 'the backup content does not match its trailer'
307
+ );
308
+ }
309
+ };
310
+
311
+ // Sort one plain line into the manifest, the trailer or a record; true for
312
+ // a record (which the caller hears).
313
+ const takeLine = (seen, line) => {
314
+ if (seen.trailer) {
315
+ throw archiveError('BACKUP_INVALID', 'data after the backup trailer');
316
+ }
317
+ if (!seen.manifest) {
318
+ seen.manifest = parseManifest(line);
319
+ return false;
320
+ }
321
+ if (line.startsWith(TRAILER)) {
322
+ seen.trailer = parseTrailer(line);
323
+ return false;
324
+ }
325
+ if (line.length === 0) {
326
+ return false;
327
+ }
328
+ seen.lines += 1;
329
+ return true;
330
+ };
331
+
332
+ /**
333
+ * Read a whole archive, checking everything: every frame, the manifest,
334
+ * the trailer's line count and SHA-256. `onLine(line)` hears each record
335
+ * line (awaited, so it can write them somewhere). Resolves to
336
+ * `{ manifest, trailer, lines, sha256 }`; nothing is trusted before it
337
+ * does, so a caller that applies lines as they come should scan once first.
338
+ */
339
+ export const scanArchive = async (file, { passphrase, onLine } = {}) => {
340
+ const hash = createHash('sha256');
341
+ const seen = { manifest: null, trailer: null, lines: 0 };
342
+ let fromCaller = null; // an error of `onLine` is the caller's, not damage
343
+ const hear = async (line) => {
344
+ try {
345
+ await onLine?.(line);
346
+ } catch (error) {
347
+ fromCaller = error;
348
+ throw error;
349
+ }
350
+ };
351
+ const gunzip = createGunzip();
352
+ const text = Readable.from(unframe(createReadStream(file), { passphrase }));
353
+ const plain = text.pipe(gunzip);
354
+ text.on('error', (error) => gunzip.destroy(error));
355
+ try {
356
+ for await (const line of linesOf(plain)) {
357
+ if (takeLine(seen, line)) {
358
+ await hear(line);
359
+ }
360
+ if (!seen.trailer) {
361
+ hash.update(`${line}\n`);
362
+ }
363
+ }
364
+ } catch (error) {
365
+ text.destroy();
366
+ if (error === fromCaller || error.code?.startsWith?.('BACKUP_')) {
367
+ throw error;
368
+ }
369
+ throw archiveError('BACKUP_TRUNCATED', 'the backup file is damaged');
370
+ }
371
+ const sha256 = hash.digest('hex');
372
+ const { manifest, trailer, lines } = seen;
373
+ checkTrailer(seen, sha256);
374
+ return { manifest, trailer, lines, sha256 };
375
+ };
376
+
377
+ /**
378
+ * The cheap check (no passphrase, no decompression): walk the frame
379
+ * headers; the file must end exactly on a frame marked final. Catches a
380
+ * file cut short; a flipped byte needs `scanArchive`.
381
+ */
382
+ export const quickCheckArchive = async (file) => {
383
+ const handle = await fs.open(file, 'r');
384
+ try {
385
+ const { size } = await handle.stat();
386
+ const head = Buffer.alloc(HEADER_BYTES);
387
+ const read = await handle.read(head, 0, HEADER_BYTES, 0);
388
+ if (read.bytesRead < HEADER_BYTES || !head.subarray(0, 6).equals(MAGIC)) {
389
+ return false;
390
+ }
391
+ let at = HEADER_BYTES;
392
+ const frame = Buffer.alloc(5);
393
+ for (;;) {
394
+ if (at + 5 > size) {
395
+ return false;
396
+ }
397
+ await handle.read(frame, 0, 5, at);
398
+ at += 5 + frame.readUInt32BE(1) + TAG_BYTES;
399
+ if (at > size) {
400
+ return false;
401
+ }
402
+ if (frame[0] === 1) {
403
+ return at === size;
404
+ }
405
+ }
406
+ } finally {
407
+ await handle.close();
408
+ }
409
+ };
410
+
411
+ /** Whether `bytes` start an archive of format 2. */
412
+ export const isArchiveFormat2 = async (file) => {
413
+ const handle = await fs.open(file, 'r');
414
+ try {
415
+ const head = Buffer.alloc(MAGIC.length);
416
+ await handle.read(head, 0, MAGIC.length, 0);
417
+ return head.equals(MAGIC);
418
+ } finally {
419
+ await handle.close();
420
+ }
421
+ };
422
+
423
+ // The last newline in the first `size` bytes of the log: where a snapshot
424
+ // of the log taken now ends. A torn last append (no newline) is left out.
425
+ export const lastNewlineEnd = async (handle, size) => {
426
+ const block = Buffer.alloc(64 * 1024);
427
+ let end = size;
428
+ while (end > 0) {
429
+ const start = Math.max(0, end - block.length);
430
+ const { bytesRead } = await handle.read(block, 0, end - start, start);
431
+ const at = block.subarray(0, bytesRead).lastIndexOf(0x0a);
432
+ if (at !== -1) {
433
+ return start + at + 1;
434
+ }
435
+ end = start;
436
+ }
437
+ return 0;
438
+ };
439
+
440
+ const idOf = (line) => {
441
+ try {
442
+ return decodeLine({ notation: line })?.id;
443
+ } catch {
444
+ return undefined;
445
+ }
446
+ };
447
+
448
+ export const isSecretLine = (line) =>
449
+ line.includes('secret:') &&
450
+ typeof idOf(line) === 'string' &&
451
+ idOf(line).startsWith('secret:');
452
+
453
+ // Raw record lines of the store's log up to its length now (appends that
454
+ // land during the backup are after that point and are not read).
455
+ export async function* logLines(store, onError) {
456
+ await store.flushLog?.();
457
+ const handle = await fs.open(store.logFile, 'r').catch((error) => {
458
+ if (error.code === 'ENOENT') {
459
+ return null;
460
+ }
461
+ throw error;
462
+ });
463
+ if (!handle) {
464
+ return;
465
+ }
466
+ try {
467
+ const { size } = await handle.stat();
468
+ const end = await lastNewlineEnd(handle, size);
469
+ if (end === 0) {
470
+ return;
471
+ }
472
+ const input = handle.createReadStream({
473
+ start: 0,
474
+ end: end - 1,
475
+ autoClose: false,
476
+ });
477
+ for await (const line of linesOf(input)) {
478
+ if (isRecordLine(line)) {
479
+ yield line;
480
+ } else if (line.trim().length > 0) {
481
+ onError?.(line); // legacy or damaged text: not archived
482
+ }
483
+ }
484
+ } finally {
485
+ await handle.close();
486
+ }
487
+ }
488
+
489
+ // Encoded links of a store that has no log file (memory stores, tests).
490
+ async function* queriedLines(store, encodeLine) {
491
+ const links = await store.query();
492
+ for (const link of links) {
493
+ yield encodeLine({ obj: link });
494
+ }
495
+ }
496
+
497
+ /**
498
+ * The plain content of an archive as an async stream of strings, and the
499
+ * running `stats` ({ lines, secretsExcluded, skipped, bytes, sha256 }; the
500
+ * hash is final once the stream ended). Secrets are filtered while
501
+ * streaming unless `includeSecrets`.
502
+ */
503
+ export const archiveContent = ({
504
+ store,
505
+ createdAt,
506
+ includeSecrets,
507
+ encodeLine,
508
+ onProgress,
509
+ schema = null,
510
+ }) => {
511
+ const stats = {
512
+ lines: 0,
513
+ secretsExcluded: 0,
514
+ skipped: 0,
515
+ bytes: 0,
516
+ sha256: null,
517
+ };
518
+ const hash = createHash('sha256');
519
+ const emit = (text) => {
520
+ hash.update(text);
521
+ stats.bytes += Buffer.byteLength(text);
522
+ return text;
523
+ };
524
+ async function* content() {
525
+ yield emit(
526
+ `${JSON.stringify({
527
+ format: FORMAT,
528
+ version: 2,
529
+ createdAt,
530
+ secrets: includeSecrets ? 'included-encrypted' : 'excluded',
531
+ // The data schema the records are in (R-Z17).
532
+ schema: schema === null ? undefined : { version: schema },
533
+ source: store.logFile ? 'log' : 'links',
534
+ })}\n`
535
+ );
536
+ const lines = store.logFile
537
+ ? logLines(store, () => (stats.skipped += 1))
538
+ : queriedLines(store, encodeLine);
539
+ let batch = '';
540
+ for await (const line of lines) {
541
+ if (!includeSecrets && isSecretLine(line)) {
542
+ stats.secretsExcluded += 1;
543
+ continue;
544
+ }
545
+ stats.lines += 1;
546
+ batch += `${line}\n`;
547
+ if (batch.length >= 256 * 1024) {
548
+ yield emit(batch);
549
+ batch = '';
550
+ onProgress?.(stats);
551
+ }
552
+ }
553
+ if (batch) {
554
+ yield emit(batch);
555
+ }
556
+ stats.sha256 = hash.digest('hex');
557
+ yield `${TRAILER}${JSON.stringify({
558
+ lines: stats.lines,
559
+ secretsExcluded: stats.secretsExcluded,
560
+ bytes: stats.bytes,
561
+ sha256: stats.sha256,
562
+ })}\n`;
563
+ }
564
+ return { stream: Readable.from(content(), { objectMode: false }), stats };
565
+ };
566
+
567
+ /** The stages from content to sealed frames: `pipeline(content, ...stages, sink)`. */
568
+ export const archiveStages = (passphrase) => [
569
+ createGzip({ level: 4 }),
570
+ sealStage(passphrase),
571
+ ];