meta-sovereign 0.49.6 → 0.49.7

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
+ ];
@@ -1,33 +1,57 @@
1
1
  /**
2
- * Backup helper — snapshots a DualStore directory into a single
3
- * timestamped file in the configured archive folder, and applies
4
- * a simple keep-last-N retention policy (R-A4).
2
+ * Backup helper — snapshots a store into a single timestamped file in the
3
+ * configured archive folder, and applies a simple keep-last-N retention
4
+ * policy (R-A4).
5
5
  *
6
- * The archive format is intentionally trivial: a JSON manifest that
7
- * embeds the .lino text and base64'd binary blob. A future PR will
8
- * swap this for tar.zst once we add zstd-codec; the call sites do not
9
- * change because they only use `createBackup(store, opts)`.
6
+ * Archives are written and read as streams (format 2, `backup-archive.js`,
7
+ * R-Z16): the store's log is gzipped line by line and sealed in 1 MB
8
+ * authenticated frames (AES-256-GCM with a passphrase), so a multi-gigabyte
9
+ * store backs up and restores in flat memory. `data.bin` is not archived: it
10
+ * is a snapshot of the log and is rebuilt from it when the store opens.
11
+ * Archives of the earlier format (one JSON document, `.json`/`.json.enc`)
12
+ * still verify and restore.
10
13
  */
11
14
 
12
15
  import { promises as fs } from 'node:fs';
13
16
  import path from 'node:path';
14
17
  import { setInterval, clearInterval } from 'node:timers';
18
+ import { PassThrough } from 'node:stream';
19
+ import { once } from 'node:events';
15
20
  import {
16
21
  randomBytes,
17
22
  scryptSync,
18
23
  createCipheriv,
19
24
  createDecipheriv,
20
25
  } from 'node:crypto';
26
+ import { decodeLine, encodeLine } from 'lino-objects-codec';
21
27
  import { createMemoryStore, putAll } from './universal.js';
22
- import { ensurePrivateDir, sweepTemp, writeFileDurable } from './durable.js';
23
- import { enoughSpace, skippedEvent } from './disk-space.js';
24
28
  import {
25
29
  CURRENT_SCHEMA,
26
30
  MIGRATIONS,
27
31
  SCHEMA_ID,
28
32
  runMigrations,
33
+ schemaLink,
29
34
  versionOf,
30
35
  } from './migrations/index.js';
36
+ import {
37
+ ensurePrivateDir,
38
+ fsyncDir,
39
+ tighten,
40
+ sweepTemp,
41
+ writeStreamDurable,
42
+ } from './durable.js';
43
+ import { enoughSpace, skippedEvent } from './disk-space.js';
44
+ import { acquireStoreLock } from './store-lock.js';
45
+ import {
46
+ archiveContent,
47
+ archiveError,
48
+ archiveStages,
49
+ isArchiveFormat2,
50
+ isSecretLine,
51
+ logLines,
52
+ quickCheckArchive,
53
+ scanArchive,
54
+ } from './backup-archive.js';
31
55
 
32
56
  const ENC_VERSION = 1;
33
57
  const KDF_N = 1 << 14;
@@ -36,10 +60,9 @@ const deriveKey = (passphrase, salt) =>
36
60
  scryptSync(passphrase, salt, 32, { N: KDF_N });
37
61
 
38
62
  /**
39
- * Encrypt a backup manifest with AES-256-GCM. The output envelope
40
- * is JSON with base64 fields so it remains diff-friendly and trivially
41
- * portable — the binary path can use the same envelope when the call
42
- * sites move to tar.zst (R-A4 hardening).
63
+ * Encrypt a small text with AES-256-GCM. The output envelope is JSON with
64
+ * base64 fields. (Used for single secrets and exports; backups of a whole
65
+ * store are framed, see `backup-archive.js`.)
43
66
  */
44
67
  export const encryptBackup = (plaintext, passphrase) => {
45
68
  const salt = randomBytes(16);
@@ -80,45 +103,93 @@ export const decryptBackup = (envelopeJson, passphrase) => {
80
103
  const stamp = (d = new Date()) =>
81
104
  d.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace(/Z$/, '');
82
105
 
83
- const isSecretId = (link) =>
84
- typeof link?.id === 'string' && link.id.startsWith('secret:');
106
+ /** Whether `name` is one of our archives (format 2 `.msbk`, or the old `.json`). */
107
+ export const isBackupFile = (name) =>
108
+ /^meta-sovereign-.*\.(json|msbk)(\.enc)?$/.test(name);
85
109
 
86
- const BACKUP_FILE = /^meta-sovereign-.*\.json(\.enc)?$/;
110
+ // A second backup into one folder at the same time fails fast.
111
+ const running = new Set();
112
+
113
+ const looksWhole = (raw, file, passphrase) => {
114
+ if (!file.endsWith('.enc')) {
115
+ return Array.isArray(JSON.parse(raw)?.links);
116
+ }
117
+ const env = JSON.parse(raw);
118
+ const whole = ['salt', 'iv', 'tag', 'ct'].every(
119
+ (k) => typeof env[k] === 'string' && env[k].length > 0
120
+ );
121
+ if (!whole || env.cipher !== 'aes-256-gcm') {
122
+ return false;
123
+ }
124
+ return passphrase
125
+ ? Array.isArray(JSON.parse(decryptBackup(raw, passphrase))?.links)
126
+ : true;
127
+ };
87
128
 
88
129
  /**
89
- * Whether the archive `file` is complete (R-Z2): a plain one parses and
90
- * holds a links array; an encrypted one has a whole envelope and, when the
91
- * passphrase is at hand, decrypts to such a manifest. Never throws.
130
+ * Whether the archive `file` is complete (R-Z2, R-Z16). A format 2 archive
131
+ * is checked cheaply by default (its frames must end on the final one; no
132
+ * passphrase needed); `full: true` reads and checks every frame, the
133
+ * manifest, the line count and the SHA-256 (needs the passphrase of an
134
+ * encrypted one). An old-format archive parses and holds a links array.
135
+ * Never throws.
92
136
  */
93
- export const verifyBackup = async (file, { passphrase = null } = {}) => {
137
+ export const verifyBackup = async (
138
+ file,
139
+ { passphrase = null, full = false } = {}
140
+ ) => {
94
141
  try {
95
- const raw = await fs.readFile(file, 'utf8');
96
- if (!file.endsWith('.enc')) {
97
- return Array.isArray(JSON.parse(raw)?.links);
98
- }
99
- const env = JSON.parse(raw);
100
- const whole = ['salt', 'iv', 'tag', 'ct'].every(
101
- (k) => typeof env[k] === 'string' && env[k].length > 0
102
- );
103
- if (!whole || env.cipher !== 'aes-256-gcm') {
104
- return false;
142
+ if (await isArchiveFormat2(file)) {
143
+ if (!full) {
144
+ return await quickCheckArchive(file);
145
+ }
146
+ await scanArchive(file, { passphrase });
147
+ return true;
105
148
  }
106
- return passphrase
107
- ? Array.isArray(JSON.parse(decryptBackup(raw, passphrase))?.links)
108
- : true;
149
+ return looksWhole(await fs.readFile(file, 'utf8'), file, passphrase);
109
150
  } catch {
110
151
  return false;
111
152
  }
112
153
  };
113
154
 
155
+ // Free space a backup of `store` needs: gzip takes the text to about a
156
+ // third; a store without a log file is estimated from its link count.
157
+ const estimateBytes = async (store, passphrase) => {
158
+ const text = store.logFile
159
+ ? ((await fs.stat(store.logFile).catch(() => null))?.size ?? 0)
160
+ : (await store.query()).length * 1024;
161
+ return Math.ceil(text * 0.35 * (passphrase ? 1.02 : 1)) + 4096;
162
+ };
163
+
164
+ // Read the new archive back whole and compare it with what was written.
165
+ const confirmWritten = async (file, passphrase, stats) => {
166
+ try {
167
+ const read = await scanArchive(file, { passphrase });
168
+ if (read.sha256 !== stats.sha256 || read.lines !== stats.lines) {
169
+ throw new Error('content differs');
170
+ }
171
+ } catch (cause) {
172
+ await fs.unlink(file).catch(() => {});
173
+ throw Object.assign(new Error('the backup could not be read back'), {
174
+ code: 'BACKUP_VERIFY_FAILED',
175
+ cause,
176
+ });
177
+ }
178
+ };
179
+
114
180
  /**
115
181
  * Write a backup and say what it holds: `{ file, links, secrets,
116
- * secretsExcluded }`. The archive is written atomically (temp file, fsync,
117
- * rename, fsync of the folder), 0600, then read back; one that does not
118
- * verify is removed and the call fails. Secrets (`secret:*` links) go in
119
- * only when the archive is encrypted; otherwise they are left out and the
120
- * manifest says so (R-Z2). On a nearly full disk nothing is written and the
121
- * answer is `{ skipped: true, reason: 'low_disk', needed, free }` (R-Z14).
182
+ * secretsExcluded, bytes, sha256 }` (`links` counts the log records
183
+ * archived). The log is streamed up to the length it had when the backup
184
+ * began, cut back to its last whole line, so writes landing meanwhile
185
+ * cannot tear it. The archive is written atomically (temp file, fsync,
186
+ * rename, fsync of the folder), 0600, then read back whole and compared
187
+ * with what was written; one that does not verify is removed and the call
188
+ * fails. Secrets (`secret:*` links) go in only when the archive is
189
+ * encrypted; otherwise they are filtered out while streaming and the
190
+ * manifest counts them (R-Z2). On a nearly full disk nothing is written
191
+ * and the answer is `{ skipped: true, reason: 'low_disk', needed, free }`
192
+ * (R-Z14).
122
193
  */
123
194
  export const makeBackup = async (
124
195
  store,
@@ -127,51 +198,52 @@ export const makeBackup = async (
127
198
  now = new Date(),
128
199
  passphrase = null,
129
200
  onEvent = () => {},
201
+ onProgress,
130
202
  measureFree,
131
203
  }
132
204
  ) => {
133
205
  await ensurePrivateDir(archiveDir);
134
- const all = await store.query();
135
- const links = passphrase ? all : all.filter((l) => !isSecretId(l));
136
- const schemaVersion = versionOf(all.find((l) => l.id === SCHEMA_ID));
137
- const secretsExcluded = all.length - links.length;
138
- const manifest = {
139
- createdAt: now.toISOString(),
140
- version: 1,
141
- secrets: passphrase ? 'included-encrypted' : 'excluded',
142
- secretsExcluded,
143
- // The data schema the links are in (R-Z17); a manifest without it is version 0.
144
- schema: { version: schemaVersion },
145
- links,
146
- };
147
- const plaintext = JSON.stringify(manifest, null, 2);
148
- // The archive is about as large as its text (a third more once encrypted
149
- // and base64'd); on a nearly full disk it is skipped, not half written
150
- // (R-Z14).
151
- const estimate = Math.ceil(plaintext.length * (passphrase ? 1.4 : 1));
152
- const space = await enoughSpace(archiveDir, estimate, measureFree);
206
+ await tighten(archiveDir, 0o700);
207
+ const space = await enoughSpace(
208
+ archiveDir,
209
+ await estimateBytes(store, passphrase),
210
+ measureFree
211
+ );
153
212
  if (!space.ok) {
154
213
  onEvent(skippedEvent('backup_skipped', space));
155
214
  return { skipped: true, reason: 'low_disk', ...space, links: 0 };
156
215
  }
157
- const name = `meta-sovereign-${stamp(now)}.json${passphrase ? '.enc' : ''}`;
158
- const file = path.join(archiveDir, name);
159
- await writeFileDurable(
160
- file,
161
- passphrase ? encryptBackup(plaintext, passphrase) : plaintext
216
+ const folder = path.resolve(archiveDir);
217
+ if (running.has(folder)) {
218
+ throw archiveError('BACKUP_BUSY', 'a backup is already running');
219
+ }
220
+ running.add(folder);
221
+ const file = path.join(
222
+ archiveDir,
223
+ `meta-sovereign-${stamp(now)}.msbk${passphrase ? '.enc' : ''}`
162
224
  );
163
- if (!(await verifyBackup(file, { passphrase }))) {
164
- await fs.unlink(file).catch(() => {});
165
- throw Object.assign(new Error('the backup could not be read back'), {
166
- code: 'BACKUP_VERIFY_FAILED',
225
+ try {
226
+ const { stream, stats } = archiveContent({
227
+ store,
228
+ createdAt: now.toISOString(),
229
+ includeSecrets: Boolean(passphrase),
230
+ schema: store.get ? versionOf(await store.get(SCHEMA_ID)) : null,
231
+ encodeLine,
232
+ onProgress,
167
233
  });
234
+ await writeStreamDurable(file, stream, ...archiveStages(passphrase));
235
+ await confirmWritten(file, passphrase, stats);
236
+ return {
237
+ file,
238
+ links: stats.lines,
239
+ secrets: passphrase ? 'included-encrypted' : 'excluded',
240
+ secretsExcluded: stats.secretsExcluded,
241
+ bytes: (await fs.stat(file)).size,
242
+ sha256: stats.sha256,
243
+ };
244
+ } finally {
245
+ running.delete(folder);
168
246
  }
169
- return {
170
- file,
171
- links: links.length,
172
- secrets: manifest.secrets,
173
- secretsExcluded,
174
- };
175
247
  };
176
248
 
177
249
  export const createBackup = async (store, options) => {
@@ -188,12 +260,13 @@ export const createBackup = async (store, options) => {
188
260
  * Keep the newest `keep` archives. Nothing is deleted unless the newest
189
261
  * archive verifies as complete (a crash or a full disk may have left a
190
262
  * truncated one that must not cost the older good ones), and leftover
191
- * temp files of a crashed write are swept (R-Z2).
263
+ * temp files of a crashed write are swept (R-Z2). The check is the cheap
264
+ * one (frame structure; the full read happened when it was written).
192
265
  */
193
266
  export const pruneBackups = async ({ archiveDir, keep, passphrase = null }) => {
194
267
  await sweepTemp(archiveDir);
195
268
  const entries = await fs.readdir(archiveDir).catch(() => []);
196
- const ours = entries.filter((f) => BACKUP_FILE.test(f)).sort();
269
+ const ours = entries.filter(isBackupFile).sort();
197
270
  const toDelete = ours.slice(0, Math.max(0, ours.length - keep));
198
271
  if (keep < 1 || toDelete.length === 0) {
199
272
  return [];
@@ -272,12 +345,20 @@ export const createBackupScheduler = ({
272
345
  };
273
346
  };
274
347
 
348
+ const tooNew = (version, current) =>
349
+ Object.assign(
350
+ new Error(
351
+ `this backup was written by a newer version (schema ${version}, this app understands up to ${current}); update the app`
352
+ ),
353
+ { code: 'BACKUP_TOO_NEW', found: version, current }
354
+ );
355
+
275
356
  /**
276
- * The links of a backup in the current shapes (R-Z17). A backup of an older
277
- * schema is migrated in memory, before anything is written to the store, so
278
- * a failing migration leaves the store untouched; one of a newer schema is
279
- * refused with BACKUP_TOO_NEW. The backup's own `meta:schema` is never
280
- * restored (the store keeps its own).
357
+ * The links of an old-format backup in the current shapes (R-Z17). A backup
358
+ * of an older schema is migrated in memory, before anything is written to
359
+ * the store, so a failing migration leaves the store untouched; one of a
360
+ * newer schema is refused with BACKUP_TOO_NEW. The backup's own
361
+ * `meta:schema` is never restored (the store keeps its own).
281
362
  */
282
363
  export const linksOfManifest = async (
283
364
  manifest,
@@ -287,12 +368,7 @@ export const linksOfManifest = async (
287
368
  ? manifest.schema.version
288
369
  : 0;
289
370
  if (version > current) {
290
- throw Object.assign(
291
- new Error(
292
- `this backup was written by a newer version (schema ${version}, this app understands up to ${current}); update the app`
293
- ),
294
- { code: 'BACKUP_TOO_NEW', found: version, current }
295
- );
371
+ throw tooNew(version, current);
296
372
  }
297
373
  const links = manifest.links.filter((l) => l.id !== SCHEMA_ID);
298
374
  if (version === current) {
@@ -321,11 +397,90 @@ export const linksOfManifest = async (
321
397
  return (await scratch.query()).filter((l) => l.id !== SCHEMA_ID);
322
398
  };
323
399
 
324
- export const restoreBackup = async (
325
- store,
326
- file,
327
- { passphrase = null, onEvent = () => {} } = {}
328
- ) => {
400
+ // The schema a format 2 archive was written in; refuses a newer one.
401
+ const archiveSchema = (manifest) => {
402
+ const version = manifest?.schema?.version;
403
+ if (!Number.isInteger(version)) {
404
+ return null;
405
+ }
406
+ if (version > CURRENT_SCHEMA) {
407
+ throw tooNew(version, CURRENT_SCHEMA);
408
+ }
409
+ return version;
410
+ };
411
+
412
+ // Lower the store's schema stamp to the archive's, so the records restored
413
+ // in older shapes are migrated when the store next opens (R-Z17).
414
+ const stampOlder = async (store, version) => {
415
+ if (version === null || version >= CURRENT_SCHEMA) {
416
+ return;
417
+ }
418
+ const existing = await store.get(SCHEMA_ID);
419
+ if (versionOf(existing) > version) {
420
+ await store.put({
421
+ ...schemaLink({ version, applied: existing?.applied ?? [] }),
422
+ app: existing?.app ?? null,
423
+ });
424
+ }
425
+ };
426
+
427
+ const TOMBSTONE = '~deleted';
428
+ const isTombstone = (record) =>
429
+ record?.[TOMBSTONE] === true && Object.keys(record).length === 2;
430
+
431
+ const BATCH = 500;
432
+
433
+ const malformed = () =>
434
+ archiveError('BACKUP_INVALID', 'not a valid backup: a record is malformed');
435
+
436
+ const recordOfLine = (line) => {
437
+ let record;
438
+ try {
439
+ record = decodeLine({ notation: line });
440
+ } catch {
441
+ throw malformed();
442
+ }
443
+ if (typeof record?.id !== 'string' || !record.id) {
444
+ throw malformed();
445
+ }
446
+ return record;
447
+ };
448
+
449
+ // Replay the archive's records into `target`, a batch at a time.
450
+ const applyArchive = async (target, file, passphrase) => {
451
+ let batch = [];
452
+ let restored = 0;
453
+ const flush = async () => {
454
+ if (batch.length > 0) {
455
+ await putAll(target, batch);
456
+ restored += batch.length;
457
+ batch = [];
458
+ }
459
+ };
460
+ await scanArchive(file, {
461
+ passphrase,
462
+ onLine: async (line) => {
463
+ const record = recordOfLine(line);
464
+ if (record.id === SCHEMA_ID) {
465
+ return; // the store keeps its own stamp (see stampOlder)
466
+ }
467
+ if (isTombstone(record)) {
468
+ await flush();
469
+ await target.delete(record.id);
470
+ return;
471
+ }
472
+ batch.push(record);
473
+ if (batch.length >= BATCH) {
474
+ await flush();
475
+ }
476
+ },
477
+ });
478
+ await flush();
479
+ return restored;
480
+ };
481
+
482
+ // The old format: one JSON document, checked whole before anything is written.
483
+ const restoreLegacy = async (store, file, passphrase, onEvent) => {
329
484
  let raw = await fs.readFile(file, 'utf8');
330
485
  if (file.endsWith('.enc')) {
331
486
  if (!passphrase) {
@@ -334,14 +489,132 @@ export const restoreBackup = async (
334
489
  raw = decryptBackup(raw, passphrase);
335
490
  }
336
491
  const manifest = JSON.parse(raw);
337
- // Checked whole before anything is written: no half restore (R-Z2).
338
492
  const valid =
339
493
  Array.isArray(manifest?.links) &&
340
494
  manifest.links.every((l) => l && typeof l === 'object' && l.id);
341
495
  if (!valid) {
342
- throw Object.assign(new Error('not a valid backup: links are malformed'), {
343
- code: 'BACKUP_INVALID',
344
- });
496
+ throw archiveError(
497
+ 'BACKUP_INVALID',
498
+ 'not a valid backup: links are malformed'
499
+ );
345
500
  }
346
501
  return putAll(store, await linksOfManifest(manifest, { onEvent }));
347
502
  };
503
+
504
+ /**
505
+ * Put the archive's links into the open `store` and say how many (R-Z16).
506
+ * The whole archive is read and checked first (every frame, the manifest,
507
+ * the SHA-256, every record), so a damaged or tampered file changes
508
+ * nothing; then its records are replayed in order, 500 at a time (a later
509
+ * record replaces an earlier one, a tombstone deletes). Replaying is
510
+ * idempotent: an interrupted restore is finished by running it again. The
511
+ * store is the caller's open one, which holds the folder's lock, so
512
+ * another process cannot write to the folder meanwhile (R-Z2); to replace
513
+ * the store of a folder that no process has open, use `restoreBackupToDir`.
514
+ */
515
+ export const restoreBackup = async (
516
+ store,
517
+ file,
518
+ { passphrase = null, onEvent = () => {} } = {}
519
+ ) => {
520
+ if (!(await isArchiveFormat2(file))) {
521
+ return restoreLegacy(store, file, passphrase, onEvent);
522
+ }
523
+ const { manifest } = await scanArchive(file, {
524
+ passphrase,
525
+ onLine: (line) => {
526
+ recordOfLine(line);
527
+ },
528
+ });
529
+ const version = archiveSchema(manifest);
530
+ // The secret wrapper would seal the log's already sealed secrets again.
531
+ const restored = await applyArchive(
532
+ store.rawStore ?? store,
533
+ file,
534
+ passphrase
535
+ );
536
+ await stampOlder(store.rawStore ?? store, version);
537
+ return restored;
538
+ };
539
+
540
+ // Write `line` to `out`, waiting when its buffer is full.
541
+ const emitLine = async (out, line) => {
542
+ if (!out.write(`${line}\n`)) {
543
+ await once(out, 'drain');
544
+ }
545
+ };
546
+
547
+ // Feed `out` the archive's records, then (for a plain archive, which holds
548
+ // no secrets) the secrets of the log being replaced; end it when whole.
549
+ const feedRestored = async (out, file, { passphrase, oldLog }) => {
550
+ try {
551
+ const { manifest } = await scanArchive(file, {
552
+ passphrase,
553
+ onLine: (line) => emitLine(out, line),
554
+ });
555
+ if (oldLog && manifest.secrets === 'excluded') {
556
+ for await (const line of logLines({ logFile: oldLog })) {
557
+ if (isSecretLine(line)) {
558
+ await emitLine(out, line);
559
+ }
560
+ }
561
+ }
562
+ out.end();
563
+ } catch (error) {
564
+ out.destroy(error);
565
+ }
566
+ };
567
+
568
+ /**
569
+ * Restore an archive as the store of the folder `dir` (R-Z16): the
570
+ * archive is checked whole, then written to a temp file, fsynced and
571
+ * renamed over `data.lino` in one step, so the folder holds the old store
572
+ * or the whole new one, never half. Refuses (`STORE_LOCKED`) when another
573
+ * process has the folder open, and (`BACKUP_TARGET_NOT_EMPTY`) when it
574
+ * already holds a store unless `replace: true`; the replaced log is kept
575
+ * beside it as `data.lino.before-restore-<time>`. A plain archive has no
576
+ * secrets, so the folder's own `secret:*` lines are carried over. `data.bin`
577
+ * is removed: it is rebuilt from the log when the store opens. Resolves to
578
+ * the number of records restored.
579
+ */
580
+ export const restoreBackupToDir = async (
581
+ file,
582
+ dir,
583
+ { passphrase = null, replace = false } = {}
584
+ ) => {
585
+ if (!(await isArchiveFormat2(file))) {
586
+ throw archiveError(
587
+ 'BACKUP_INVALID',
588
+ 'only format 2 backups restore into a folder'
589
+ );
590
+ }
591
+ const checked = await scanArchive(file, { passphrase });
592
+ archiveSchema(checked.manifest);
593
+ await ensurePrivateDir(dir);
594
+ const lock = await acquireStoreLock(dir);
595
+ try {
596
+ const log = path.join(dir, 'data.lino');
597
+ const old = await fs.stat(log).catch(() => null);
598
+ if (old && old.size > 0 && !replace) {
599
+ throw archiveError(
600
+ 'BACKUP_TARGET_NOT_EMPTY',
601
+ 'the folder already holds a store (use replace)'
602
+ );
603
+ }
604
+ if (old) {
605
+ await fs.link(log, `${log}.before-restore-${stamp()}`);
606
+ }
607
+ const out = new PassThrough();
608
+ const feeding = feedRestored(out, file, {
609
+ passphrase,
610
+ oldLog: old ? log : null,
611
+ });
612
+ await writeStreamDurable(log, out);
613
+ await feeding;
614
+ await fs.rm(path.join(dir, 'data.bin'), { force: true });
615
+ await fsyncDir(dir);
616
+ return checked.lines;
617
+ } finally {
618
+ lock.release();
619
+ }
620
+ };
@@ -130,6 +130,7 @@ const readOnlyStore = (text, schema) => ({
130
130
  corruptLines: text.corruptLines,
131
131
  health: () => ({ degraded: false, queuedBytes: 0, error: null }),
132
132
  readOnly: true,
133
+ logFile: text.filePath, // streamed by a backup (R-Z16)
133
134
  });
134
135
 
135
136
  // A folder and everything below it (the downloaded pictures and files).
@@ -235,6 +236,9 @@ export const openDualStore = async (
235
236
  schema,
236
237
  migration: schema.migrated,
237
238
  corruptLines: text.corruptLines,
239
+ // What a backup streams, and the flush of just the log (R-Z16).
240
+ logFile: text.filePath,
241
+ flushLog: () => text.flush(),
238
242
  close: lock.release,
239
243
  };
240
244
  } catch (error) {
@@ -9,8 +9,9 @@
9
9
  * user: 0600, directories 0700 (a no-op on Windows).
10
10
  */
11
11
 
12
- import { promises as fs } from 'node:fs';
12
+ import { createWriteStream, promises as fs } from 'node:fs';
13
13
  import path from 'node:path';
14
+ import { pipeline } from 'node:stream/promises';
14
15
 
15
16
  export const FILE_MODE = 0o600;
16
17
  export const DIR_MODE = 0o700;
@@ -99,6 +100,30 @@ export const writeFileDurable = async (
99
100
  await fsyncDir(dir);
100
101
  };
101
102
 
103
+ /**
104
+ * Like `writeFileDurable`, but the content is a pipeline of streams
105
+ * (`source, ...stages`) written as it is produced, so a multi-gigabyte
106
+ * file never exists in memory (R-Z16). Temp file at 0600, fsync, rename,
107
+ * fsync of the folder; the temp file goes at once when anything fails.
108
+ */
109
+ export const writeStreamDurable = async (file, ...streams) => {
110
+ const dir = path.dirname(file);
111
+ await ensurePrivateDir(dir);
112
+ const tmp = `${file}.${process.pid}.tmp`;
113
+ try {
114
+ await pipeline(
115
+ ...streams,
116
+ createWriteStream(tmp, { flags: 'w', mode: FILE_MODE })
117
+ );
118
+ await fsyncFile(tmp);
119
+ await fs.rename(tmp, file);
120
+ } catch (error) {
121
+ await fs.unlink(tmp).catch(() => {});
122
+ throw error;
123
+ }
124
+ await fsyncDir(dir);
125
+ };
126
+
102
127
  /** Remove `*.tmp` leftovers of a crashed write, older than a minute. */
103
128
  export const sweepTemp = async (dir, olderThanMs = 60_000) => {
104
129
  const removed = [];
@@ -56,6 +56,11 @@ export const wrapSecretStore = ({ inner, passphrase }) => {
56
56
  return inner;
57
57
  }
58
58
  return {
59
+ // A backup streams the log as stored (secrets sealed) and a restore
60
+ // puts them back unsealed-again-free (R-Z16).
61
+ rawStore: inner,
62
+ logFile: inner.logFile,
63
+ flushLog: inner.flushLog,
59
64
  async put(link) {
60
65
  if (isSecret(link)) {
61
66
  const sealed = sealLink(link, passphrase);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meta-sovereign",
3
- "version": "0.49.6",
3
+ "version": "0.49.7",
4
4
  "description": "Personal meta profile sovereign system - fully local, privacy-respecting personal CRM and unified messenger across services",
5
5
  "type": "module",
6
6
  "main": "js/src/index.js",