dsh-remote 0.8.36 → 0.8.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/backup.js ADDED
@@ -0,0 +1,519 @@
1
+ // dsh-remote — workspace compressed backup / restore orchestration.
2
+ //
3
+ // Sits between the pure command builders in lib/archive.js and the callers
4
+ // (rw_backup / rw_backup_list / rw_restore tools and the /dsh-remote/backup*
5
+ // routes). Everything here takes the SSH pool as an ARGUMENT rather than
6
+ // reaching for a module-level singleton, so the whole flow can be exercised
7
+ // against an in-memory SFTP double in tests.
8
+ //
9
+ // Design commitments, each with a reason:
10
+ //
11
+ // · **A failed run never publishes a partial archive.** archive.js stages the
12
+ // file and `mv`s it into place only after tar exits 0 and the stream verifies.
13
+ //
14
+ // · **A restore is never destructive before the archive proves itself.** The
15
+ // extract happens into a staging directory first; a corrupt or truncated
16
+ // archive fails there and the target is left byte-for-byte untouched. Only
17
+ // then is the old tree swapped aside, and it is removed last.
18
+ //
19
+ // · **Every archive carries a sidecar.** Recording who/what/when/checksum next
20
+ // to the bytes means a restore can say what it is about to overwrite with,
21
+ // and a truncated transfer can be detected rather than trusted.
22
+ //
23
+ // · **The backup directory is never inside its own archive.** When the backup
24
+ // dir happens to live under the workspace (a user-chosen path), that subtree
25
+ // is added to the excludes; otherwise every run would embed the previous run
26
+ // and the archive would grow without bound.
27
+ import path from 'node:path'
28
+ import { createHash } from 'node:crypto'
29
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, rmSync, readdirSync } from 'node:fs'
30
+ import { remoteDirname, mkdirRemoteDirs } from './paths.js'
31
+ import {
32
+ MARK, META_BEGIN_MARK, ARCHIVE_SUFFIX, META_SUFFIX,
33
+ buildHomeCommand, buildCreateCommand, buildListCommand, buildVerifyCommand,
34
+ buildDeleteCommand, buildRestoreCommand, parseCreateResult, parseListResult,
35
+ parseStatus, resolveBackupDir, archiveName, archiveStem, metaNameFor,
36
+ isSafeArchiveName, excludeRejection, backupDirUnderRoot, formatBytes,
37
+ } from './archive.js'
38
+
39
+ /** Parse one `ARCH_*` marker without importing the whole marker map. */
40
+ const marker = (out, key) => {
41
+ const re = new RegExp(`^${key}=(.*)$`, 'gm')
42
+ let v = ''
43
+ let m
44
+ while ((m = re.exec(String(out ?? ''))) !== null) v = m[1].trim()
45
+ return v
46
+ }
47
+
48
+ /** How much of an archive we are willing to move across SFTP. The archive
49
+ * itself is uncapped (a backup is a backup); only the optional cross-machine
50
+ * copy is bounded, because that is what fills a laptop's disk. */
51
+ export function transferCapExceeded(bytes, cap) {
52
+ const limit = Number(cap) || 0
53
+ if (limit <= 0) return false
54
+ return Number(bytes) > limit
55
+ }
56
+
57
+ /**
58
+ * Resolve the remote user's home directory. Cached by the caller (the tool
59
+ * resolves it once per call), because a backup is a single round trip anyway.
60
+ * @returns {Promise<string>} '' when it cannot be determined.
61
+ */
62
+ export async function resolveRemoteHome(pool, timeoutMs = 10000) {
63
+ try {
64
+ const res = await pool.exec(buildHomeCommand(), { timeoutMs })
65
+ const home = marker(res && res.stdout, MARK.home)
66
+ return home && home.startsWith('/') ? home : ''
67
+ } catch {
68
+ return ''
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Stat a remote path, distinguishing "absent" from "I could not tell".
74
+ *
75
+ * The distinction matters for naming: treating a TRANSIENT failure as
76
+ * "does not exist" would pick an archive name that already exists and overwrite
77
+ * a real backup. Only a genuine not-found is reported as absent; anything else
78
+ * propagates so the caller fails loudly instead of destroying data.
79
+ *
80
+ * @returns {Promise<object|null>} the stat, or null when the path genuinely
81
+ * does not exist.
82
+ * @throws when the failure is anything other than not-found.
83
+ */
84
+ async function remoteStat(pool, p) {
85
+ let sftp
86
+ try {
87
+ sftp = await pool.sftp()
88
+ } catch (err) {
89
+ throw new Error('sftp unavailable: ' + String((err && err.message) || err))
90
+ }
91
+ try {
92
+ return await sftp.stat(p)
93
+ } catch (err) {
94
+ const code = err && (err.code === 2 || err.code === 'ENOENT')
95
+ const msg = String((err && err.message) || err)
96
+ if (code || /no such file|not found|does not exist|ENOENT/i.test(msg)) return null
97
+ throw new Error(`cannot stat ${p}: ${msg}`)
98
+ }
99
+ }
100
+
101
+ /** Whether a remote path exists. See remoteStat for why this is not a plain
102
+ * boolean wrapper. */
103
+ async function remoteExists(pool, p) {
104
+ return (await remoteStat(pool, p)) !== null
105
+ }
106
+
107
+ /** Read a remote JSON sidecar, or null when absent/unparsable. */
108
+ async function readRemoteMeta(pool, metaPath) {
109
+ try {
110
+ const sftp = await pool.sftp()
111
+ const buf = await sftp.readFile(metaPath)
112
+ const parsed = JSON.parse(buf.toString('utf8'))
113
+ return parsed && typeof parsed === 'object' ? parsed : null
114
+ } catch {
115
+ return null
116
+ }
117
+ }
118
+
119
+ /** Write a remote JSON sidecar (best effort — a missing sidecar must not fail
120
+ * an otherwise successful backup; the listing renders it as "unknown"). */
121
+ async function writeRemoteMeta(pool, metaPath, meta) {
122
+ try {
123
+ const sftp = await pool.sftp()
124
+ await mkdirRemoteDirs(sftp, remoteDirname(metaPath))
125
+ await sftp.writeFile(metaPath, Buffer.from(`${JSON.stringify(meta, null, 2)}\n`, 'utf8'))
126
+ return true
127
+ } catch {
128
+ return false
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Pick an archive filename that does not collide with an existing one.
134
+ *
135
+ * The timestamp alone is not enough: two backups inside the same second (a
136
+ * retry, or the tool and the settings button) would otherwise silently
137
+ * overwrite the first. `-2`, `-3` … keeps both.
138
+ */
139
+ export async function uniqueArchivePath(pool, dir, baseName) {
140
+ let candidate = `${dir}/${baseName}`
141
+ if (!(await remoteExists(pool, candidate))) return candidate
142
+ const stem = baseName.endsWith(ARCHIVE_SUFFIX) ? baseName.slice(0, -ARCHIVE_SUFFIX.length) : baseName
143
+ for (let i = 2; i <= 200; i++) {
144
+ candidate = `${dir}/${stem}-${i}${ARCHIVE_SUFFIX}`
145
+ if (!(await remoteExists(pool, candidate))) return candidate
146
+ }
147
+ throw new Error('dsh-remote: cannot find a free archive name in ' + dir)
148
+ }
149
+
150
+ /**
151
+ * Create one backup of a remote directory.
152
+ *
153
+ * @param {object} pool - SSH pool (needs `exec` + `sftp`).
154
+ * @param {object} spec
155
+ * @param {string} spec.dir - absolute directory to back up (the workspace root).
156
+ * @param {string} spec.backupDir - absolute remote backup directory ('' → resolve from HOME).
157
+ * @param {string} [spec.label] - user-facing tag embedded in the filename.
158
+ * @param {string[]} [spec.excludes] - NORMALIZED patterns (see archive.js).
159
+ * @param {object} [spec.meta] - extra fields recorded in the sidecar.
160
+ * @param {number} [spec.timeoutMs]
161
+ * @returns {Promise<{ok: boolean, error: string, archive: string, name: string, metaPath: string,
162
+ * bytes: number, members: number, sha256: string, verified: boolean, meta: object, home: string}>}
163
+ */
164
+ export async function createBackup(pool, spec = {}) {
165
+ const dir = String(spec.dir || '')
166
+ const blank = { ok: false, error: '', archive: '', name: '', metaPath: '', bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home: '' }
167
+ try {
168
+ return await createBackupInner(pool, spec)
169
+ } catch (err) {
170
+ // A transient failure (a stat we could not complete, a dropped SFTP channel)
171
+ // must surface as a failed BACKUP, never as a half-applied one: nothing was
172
+ // published because the archive is staged and only renamed on success.
173
+ return { ...blank, archive: '', error: String((err && err.message) || err) }
174
+ }
175
+ }
176
+
177
+ async function createBackupInner(pool, spec = {}) {
178
+ const dir = String(spec.dir || '')
179
+ if (!dir || dir === '/') throw new Error('dsh-remote: a workspace directory is required')
180
+ const timeoutMs = Number(spec.timeoutMs) || 0
181
+
182
+ let backupDir = String(spec.backupDir || '')
183
+ let home = ''
184
+ if (!backupDir) {
185
+ home = await resolveRemoteHome(pool)
186
+ backupDir = resolveBackupDir(home, '')
187
+ if (!backupDir) {
188
+ return { ok: false, error: 'cannot determine the remote $HOME to place the backup in — set backupDir explicitly', archive: '', name: '', metaPath: '', bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home: '' }
189
+ }
190
+ }
191
+
192
+ // An exclude list that escapes the workspace is refused rather than silently
193
+ // used: it would make the archived set impossible to describe.
194
+ const excludes = Array.isArray(spec.excludes) ? spec.excludes.slice() : []
195
+ const bad = excludeRejection(excludes)
196
+ if (bad) return { ok: false, error: bad, archive: '', name: '', metaPath: '', bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home }
197
+
198
+ // Never archive a previous backup of the same tree (see the header note).
199
+ const selfDir = backupDirUnderRoot(backupDir, dir)
200
+ if (selfDir && !excludes.includes(selfDir)) excludes.push(selfDir)
201
+
202
+ const baseName = `${archiveStem(dir)}-${archiveName(spec.date ? new Date(spec.date) : new Date(), { label: spec.label })}`
203
+ const archive = await uniqueArchivePath(pool, backupDir, baseName)
204
+ const name = archive.slice(archive.lastIndexOf('/') + 1)
205
+ const metaPath = `${backupDir}/${metaNameFor(name)}`
206
+
207
+ const script = buildCreateCommand({ dir, archive, excludes })
208
+ let out
209
+ try {
210
+ out = await pool.exec(script, timeoutMs ? { timeoutMs } : {})
211
+ } catch (err) {
212
+ return { ok: false, error: String((err && err.message) || err), archive, name, metaPath, bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home }
213
+ }
214
+ const parsed = parseCreateResult(out && out.stdout)
215
+ const stderr = String((out && out.stderr) || '').trim()
216
+ if (!parsed.ok) {
217
+ // tar's own message is far more actionable than "exit 2", so surface both.
218
+ const detail = [parsed.error, stderr].filter(Boolean).join(' — ')
219
+ return { ok: false, error: detail || 'backup failed', archive, name, metaPath, bytes: 0, members: 0, sha256: '', verified: false, meta: {}, home }
220
+ }
221
+
222
+ const meta = {
223
+ name,
224
+ workspace: dir,
225
+ // Recorded because it is part of the FILENAME: a listing that shows the
226
+ // label must not have to re-parse the name to get it.
227
+ label: String(spec.label || ''),
228
+ excludes,
229
+ bytes: parsed.bytes,
230
+ members: parsed.members,
231
+ sha256: parsed.sha256,
232
+ verified: parsed.verified,
233
+ createdAt: new Date().toISOString(),
234
+ ...(spec.meta && typeof spec.meta === 'object' ? spec.meta : {}),
235
+ tool: 'dsh-remote',
236
+ }
237
+ const metaWritten = await writeRemoteMeta(pool, metaPath, meta)
238
+ return {
239
+ ok: true,
240
+ error: '',
241
+ archive,
242
+ name,
243
+ metaPath,
244
+ bytes: parsed.bytes,
245
+ members: parsed.members,
246
+ sha256: parsed.sha256,
247
+ verified: parsed.verified,
248
+ metaWritten,
249
+ meta,
250
+ home,
251
+ }
252
+ }
253
+
254
+ /**
255
+ * List the backups in one remote directory, newest first.
256
+ * @returns {Promise<{exists: boolean, dir: string, entries: Array<object>}>} each
257
+ * entry merges the sidecar (when present) with the on-disk truth.
258
+ */
259
+ export async function listBackups(pool, spec = {}) {
260
+ const dir = String(spec.dir || '')
261
+ if (!dir) return { exists: false, dir: '', entries: [] }
262
+ let out
263
+ try {
264
+ out = await pool.exec(buildListCommand({ dir }), { timeoutMs: Number(spec.timeoutMs) || 15000 })
265
+ } catch (err) {
266
+ return { exists: false, dir, entries: [], error: String((err && err.message) || err) }
267
+ }
268
+ const parsed = parseListResult(out && out.stdout)
269
+ const entries = parsed.entries
270
+ .filter((e) => isSafeArchiveName(e.name))
271
+ .map((e) => ({
272
+ name: e.name,
273
+ bytes: e.bytes,
274
+ mtime: e.mtimeMs ? new Date(e.mtimeMs).toISOString() : '',
275
+ // The sidecar may be missing (older archive, failed write) — say so rather
276
+ // than inventing values the caller might trust.
277
+ workspace: (e.meta && e.meta.workspace) || '',
278
+ members: e.meta && Number.isFinite(e.meta.members) ? e.meta.members : null,
279
+ sha256: (e.meta && e.meta.sha256) || '',
280
+ verified: e.meta ? !!e.meta.verified : null,
281
+ label: (e.meta && e.meta.label) || '',
282
+ createdAt: (e.meta && e.meta.createdAt) || '',
283
+ hasMeta: !!e.meta,
284
+ rawMeta: e.meta || null,
285
+ }))
286
+ .sort((a, b) => String(b.mtime).localeCompare(String(a.mtime)))
287
+ return { exists: parsed.exists, dir, entries }
288
+ }
289
+
290
+ /** Verify one remote archive's integrity + digest. */
291
+ export async function verifyBackup(pool, spec = {}) {
292
+ const archive = String(spec.archive || '')
293
+ const out = await pool.exec(buildVerifyCommand({ archive }), { timeoutMs: Number(spec.timeoutMs) || 60000 })
294
+ return parseStatus(out && out.stdout)
295
+ }
296
+
297
+ /** Delete one remote archive and its sidecar. */
298
+ export async function deleteBackup(pool, spec = {}) {
299
+ const archive = String(spec.archive || '')
300
+ const meta = String(spec.meta || metaNameFor(archive))
301
+ const out = await pool.exec(buildDeleteCommand({ archive, meta }), { timeoutMs: Number(spec.timeoutMs) || 20000 })
302
+ return parseStatus(out && out.stdout)
303
+ }
304
+
305
+ /**
306
+ * Restore an archive over a target directory.
307
+ *
308
+ * @param {object} pool
309
+ * @param {object} spec
310
+ * @param {string} spec.archive - absolute archive path (already on the remote).
311
+ * @param {string} spec.target - absolute directory to restore into.
312
+ * @param {string} [spec.mode] - `replace` (default, atomic swap) or `merge`.
313
+ * @param {number} [spec.timeoutMs]
314
+ */
315
+ export async function restoreBackup(pool, spec = {}) {
316
+ const archive = String(spec.archive || '')
317
+ const target = String(spec.target || '')
318
+ if (!archive || !target || target === '/') {
319
+ return { ok: false, error: 'an archive and a restore target are required', action: '', members: 0 }
320
+ }
321
+ // Sibling paths keep the swap on the SAME filesystem, which is what makes
322
+ // `mv` atomic and therefore makes the rollback path meaningful.
323
+ const token = `${process.pid.toString(36)}${Math.random().toString(36).slice(2, 8)}`
324
+ const stage = `${target}.dsh-remote-stage-${token}`
325
+ const swap = `${target}.dsh-remote-bak-${token}`
326
+ const script = buildRestoreCommand({ archive, target, mode: spec.mode, stage, swap })
327
+ let out
328
+ try {
329
+ out = await pool.exec(script, { timeoutMs: Number(spec.timeoutMs) || 0 })
330
+ } catch (err) {
331
+ return { ok: false, error: String((err && err.message) || err), action: '', members: 0 }
332
+ }
333
+ const status = parseStatus(out && out.stdout)
334
+ const stderr = String((out && out.stderr) || '').trim()
335
+ if (!status.ok && !status.error) {
336
+ // A non-zero exit with no marker means the script died early (killed,
337
+ // unwritable parent) — report the raw stderr rather than a bare failure.
338
+ return { ok: false, error: stderr || 'restore failed before it could report a reason', action: '', members: 0, verified: status.verified }
339
+ }
340
+ return {
341
+ ok: status.ok,
342
+ error: status.error || (status.ok ? '' : stderr),
343
+ action: status.action,
344
+ members: status.members,
345
+ verified: status.verified,
346
+ mode: status.action === 'merged' ? 'merge' : 'replace',
347
+ }
348
+ }
349
+
350
+ /** Local directory holding pulled archives for one machine. */
351
+ export function localBackupDirFor(hostRoot) {
352
+ return path.join(hostRoot, 'backups')
353
+ }
354
+
355
+ /** Download one remote archive into a local directory (SFTP). */
356
+ export async function pullBackup(pool, spec = {}) {
357
+ const archive = String(spec.archive || '')
358
+ const localDir = String(spec.localDir || '')
359
+ const cap = Number(spec.maxTransferBytes) || 0
360
+ if (!archive || !localDir) return { ok: false, error: 'archive and localDir are required' }
361
+ const st = await remoteStat(pool, archive)
362
+ if (!st) return { ok: false, error: `archive not found on the remote: ${archive}` }
363
+ if (transferCapExceeded(st.size, cap)) {
364
+ return { ok: false, error: `archive is ${formatBytes(st.size)}, over the ${formatBytes(cap)} transfer cap — raise maxBackupTransferBytes to allow it` }
365
+ }
366
+ const name = archive.slice(archive.lastIndexOf('/') + 1)
367
+ mkdirSync(localDir, { recursive: true })
368
+ const localPath = path.join(localDir, name)
369
+ try {
370
+ const sftp = await pool.sftp()
371
+ await sftp.fastGet(archive, localPath)
372
+ } catch (err) {
373
+ return { ok: false, error: String((err && err.message) || err) }
374
+ }
375
+ // Pull the sidecar too, so the local copy lists with the same metadata.
376
+ let metaPulled = false
377
+ const metaRemote = metaNameFor(archive)
378
+ try {
379
+ const sftp = await pool.sftp()
380
+ await sftp.fastGet(metaRemote, path.join(localDir, metaNameFor(name)))
381
+ metaPulled = true
382
+ } catch { /* sidecar is optional */ }
383
+ const bytes = existsSync(localPath) ? statSync(localPath).size : 0
384
+ return { ok: true, error: '', localPath, name, bytes, metaPulled }
385
+ }
386
+
387
+ /** Upload a local archive to a remote directory (SFTP), for restore-from-local. */
388
+ export async function pushBackup(pool, spec = {}) {
389
+ const localPath = String(spec.localPath || '')
390
+ const remoteDir = String(spec.remoteDir || '')
391
+ const cap = Number(spec.maxTransferBytes) || 0
392
+ if (!localPath || !remoteDir) return { ok: false, error: 'localPath and remoteDir are required' }
393
+ if (!existsSync(localPath)) return { ok: false, error: `local archive not found: ${localPath}` }
394
+ const st = statSync(localPath)
395
+ if (transferCapExceeded(st.size, cap)) {
396
+ return { ok: false, error: `archive is ${formatBytes(st.size)}, over the ${formatBytes(cap)} transfer cap — raise maxBackupTransferBytes to allow it` }
397
+ }
398
+ const name = path.basename(localPath)
399
+ if (!isSafeArchiveName(name)) return { ok: false, error: `refusing to upload a suspicious archive name: ${name}` }
400
+ const remotePath = `${remoteDir.replace(/\/+$/, '')}/${name}`
401
+ try {
402
+ const sftp = await pool.sftp()
403
+ await mkdirRemoteDirs(sftp, remoteDir)
404
+ await sftp.fastPut(localPath, remotePath)
405
+ } catch (err) {
406
+ return { ok: false, error: String((err && err.message) || err) }
407
+ }
408
+ return { ok: true, error: '', remotePath, name, bytes: st.size }
409
+ }
410
+
411
+ /** Read a local JSON sidecar, or null. */
412
+ function readLocalMeta(localDir, name) {
413
+ try {
414
+ const parsed = JSON.parse(readFileSync(path.join(localDir, metaNameFor(name)), 'utf8'))
415
+ return parsed && typeof parsed === 'object' ? parsed : null
416
+ } catch {
417
+ return null
418
+ }
419
+ }
420
+
421
+ /** Write a local JSON sidecar (best effort). */
422
+ function writeLocalMeta(localDir, name, meta) {
423
+ try {
424
+ mkdirSync(localDir, { recursive: true })
425
+ writeFileSync(path.join(localDir, metaNameFor(name)), `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
426
+ return true
427
+ } catch {
428
+ return false
429
+ }
430
+ }
431
+
432
+ /** List locally pulled archives, newest first (same shape as listBackups). */
433
+ export function listLocalBackups(localDir) {
434
+ const entries = []
435
+ if (!localDir || !existsSync(localDir)) return { exists: false, dir: localDir || '', entries }
436
+ let names = []
437
+ try {
438
+ names = readdirSync(localDir)
439
+ } catch {
440
+ return { exists: false, dir: localDir, entries }
441
+ }
442
+ for (const name of names) {
443
+ if (!name.endsWith(ARCHIVE_SUFFIX) || !isSafeArchiveName(name)) continue
444
+ let st = null
445
+ try { st = statSync(path.join(localDir, name)) } catch { continue }
446
+ const meta = readLocalMeta(localDir, name)
447
+ entries.push({
448
+ name,
449
+ bytes: st.size,
450
+ mtime: st.mtime.toISOString(),
451
+ workspace: (meta && meta.workspace) || '',
452
+ members: meta && Number.isFinite(meta.members) ? meta.members : null,
453
+ sha256: (meta && meta.sha256) || '',
454
+ verified: meta ? !!meta.verified : null,
455
+ label: (meta && meta.label) || '',
456
+ createdAt: (meta && meta.createdAt) || '',
457
+ hasMeta: !!meta,
458
+ rawMeta: meta || null,
459
+ localPath: path.join(localDir, name),
460
+ })
461
+ }
462
+ entries.sort((a, b) => String(b.mtime).localeCompare(String(a.mtime)))
463
+ return { exists: true, dir: localDir, entries }
464
+ }
465
+
466
+ /** Delete one local archive + sidecar. */
467
+ export function deleteLocalBackup(localDir, name) {
468
+ if (!isSafeArchiveName(name)) return { ok: false, error: `refusing to delete a suspicious name: ${name}` }
469
+ const archive = path.join(localDir, name)
470
+ const meta = path.join(localDir, metaNameFor(name))
471
+ let removed = false
472
+ try {
473
+ if (existsSync(archive)) { rmSync(archive, { force: true }); removed = true }
474
+ if (existsSync(meta)) rmSync(meta, { force: true })
475
+ } catch (err) {
476
+ return { ok: false, error: String((err && err.message) || err) }
477
+ }
478
+ return removed ? { ok: true, error: '' } : { ok: false, error: `local archive not found: ${name}` }
479
+ }
480
+
481
+ /** Re-verify a LOCAL archive: recompute its digest and compare with the digest
482
+ * recorded when the backup was created. That comparison is the transfer-
483
+ * fidelity question — a truncated or corrupted pull is exactly what it catches,
484
+ * and the local half deliberately has no `tar` dependency to do it. */
485
+ export function verifyLocalBackup(localDir, name) {
486
+ if (!isSafeArchiveName(name)) return { ok: false, error: `refusing to inspect a suspicious name: ${name}` }
487
+ const p = path.join(localDir, name)
488
+ if (!existsSync(p)) return { ok: false, error: `local archive not found: ${name}` }
489
+ const st = statSync(p)
490
+ let sha256 = ''
491
+ try {
492
+ const h = createHash('sha256')
493
+ h.update(readFileSync(p))
494
+ sha256 = h.digest('hex')
495
+ } catch { sha256 = '' }
496
+ const meta = readLocalMeta(localDir, name)
497
+ const expected = (meta && meta.sha256) || ''
498
+ // `null` (rather than false) when there is nothing to compare against: an
499
+ // archive with no sidecar is unverified, which is different from corrupt.
500
+ const matches = !expected || !sha256 ? null : expected === sha256
501
+ return { ok: true, error: '', bytes: st.size, sha256, expectedSha256: expected, matches, meta }
502
+ }
503
+
504
+ /** Human summary of a create result (shared by the tool and the route). */
505
+ export function describeCreate(result, { dest = 'remote' } = {}) {
506
+ if (!result || !result.ok) return `backup failed: ${(result && result.error) || 'unknown error'}`
507
+ const where = dest === 'local' ? 'on the local machine'
508
+ : dest === 'both' ? 'on both the remote and the local machine'
509
+ : 'on the remote'
510
+ const bits = [
511
+ `created ${result.name} (${formatBytes(result.bytes)}${result.members ? `, ${result.members} entries` : ''}) — stored ${where}`,
512
+ ]
513
+ if (result.verified) bits.push('verified readable')
514
+ if (result.sha256) bits.push(`sha256 ${result.sha256.slice(0, 16)}…`)
515
+ return bits.join('; ')
516
+ }
517
+
518
+ /** Marker used by tests to assert the sidecar name derivation. */
519
+ export { META_SUFFIX, ARCHIVE_SUFFIX, META_BEGIN_MARK }