dsh-remote 0.8.39 → 0.8.41

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/archive.js DELETED
@@ -1,680 +0,0 @@
1
- // dsh-remote — workspace archive (compressed backup / restore) primitives.
2
- //
3
- // Split out of lib/backup.js and lib/index.js so the whole command surface is a
4
- // set of PURE functions (no fs, no ssh) that can be unit-tested — including
5
- // against a real POSIX `sh -n`, which is the only thing that can prove the
6
- // generated shell is valid (a string assertion cannot; see the `do;` incident
7
- // documented in scripts/dev-standards.md).
8
- //
9
- // ── Two empirically established facts drive this module ─────────────────────
10
- //
11
- // 1. **`--exclude` patterns are matched against the ARCHIVE MEMBER name.** We
12
- // create archives with `tar -czf A -C <dir> .`, so members read `./x/y`
13
- // while a nested one reads `./a/x/y`. Measured on GNU tar 1.35:
14
- // · `--exclude=x/y` → excludes both `./x/y` AND `./a/x/y` (any depth)
15
- // · `--exclude=./x/y` → excludes ONLY `./x/y` (anchored)
16
- // · `--exclude=/x/y` → excludes NOTHING (silent no-op!)
17
- // A leading `/` therefore looks like "anchor at the root" but actually
18
- // disables the rule, and a leading `./` silently narrows it. Neither is
19
- // allowed to reach the command line: `normalizeExclude()` strips both, and
20
- // `buildCreateCommand()` asserts the invariant, and a test pins it.
21
- //
22
- // 2. **Extraction is safe only while the tar defaults are kept.** GNU tar
23
- // refuses `../` members and strips leading `/` unless `-P`/`--absolute-names`
24
- // is passed; we never pass it. A member sequence that tries to walk through
25
- // a symlink is also refused ("Cannot open: Not a directory"). The archive
26
- // itself is untrusted input (it may come from `rw_backup_list` on a host we
27
- // merely administer), so `-P` must never appear anywhere in this module —
28
- // asserted in code and covered by a regression test.
29
- import { shq, shortHash, relPathUnder, normalizeRemotePath } from './paths.js'
30
-
31
- /** Directory (relative to `$HOME`) an automatic backup lands in on the remote. */
32
- export const DEFAULT_BACKUP_SUFFIX = '.dsh-remote/backups'
33
-
34
- /** Files an archive is paired with on disk; both are discovered during listing. */
35
- export const ARCHIVE_SUFFIX = '.tar.gz'
36
- export const META_SUFFIX = '.meta.json'
37
-
38
- /** Hard ceiling on an archive we will pull to / push from the local machine.
39
- * 0 = unlimited. Backups themselves are never truncated — this only bounds the
40
- * optional cross-machine copy, which is what can fill a laptop's disk. */
41
- export const DEFAULT_MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024
42
-
43
- /** Marker prefix for one archive's sidecar block; the archive name follows `=`,
44
- * so a sidecar is paired by identity rather than by position (see
45
- * parseListResult). */
46
- export const META_BEGIN_MARK = 'ARCH_META_BEGIN'
47
-
48
- /** Machine-readable markers printed by the remote scripts. Parsing never guesses. */
49
- export const MARK = {
50
- ok: 'ARCH_OK',
51
- error: 'ARCH_ERROR',
52
- file: 'ARCH_FILE',
53
- sha: 'ARCH_SHA',
54
- bytes: 'ARCH_BYTES',
55
- members: 'ARCH_MEMBERS',
56
- verify: 'ARCH_VERIFY',
57
- excluded: 'ARCH_EXCLUDED',
58
- entry: 'ARCH_ENTRY',
59
- home: 'ARCH_HOME',
60
- action: 'ARCH_ACTION',
61
- exists: 'ARCH_EXISTS',
62
- tar: 'ARCH_TAR',
63
- }
64
-
65
- /**
66
- * Normalize one user- or caller-supplied exclude pattern into the ONLY form we
67
- * are willing to put on a command line.
68
- *
69
- * gitignore-style input is accepted (`/build/`, `./node_modules`, `build/`) but
70
- * every shape is rewritten to a bare relative pattern, because that is the form
71
- * GNU tar matches at ANY depth (fact 1 in the header): `/x` is a silent no-op
72
- * and `./x` silently anchors. Rewriting rather than rejecting keeps the caller's
73
- * evident intent (`/build/` clearly means "the build directory").
74
- *
75
- * @param {unknown} raw - one pattern, possibly with a leading `/` or `./` and a
76
- * trailing `/`.
77
- * @returns {string} a bare, non-empty pattern, or '' when the input carries no
78
- * pattern at all (`''`, `'/'`, `'./'`).
79
- */
80
- export function normalizeExclude(raw) {
81
- let s = String(raw ?? '').trim().replace(/\\/g, '/')
82
- if (!s) return ''
83
- // A bare-root or dot-only pattern excludes nothing meaningful.
84
- if (s === '/' || s === '.' || s === './') return ''
85
- s = s.replace(/^\.\//, '')
86
- s = s.replace(/^\/+/, '')
87
- s = s.replace(/\/+$/, '')
88
- // `.` / `..` segments carry no meaning for tar's matcher and would only make
89
- // the pattern harder to reason about.
90
- s = s.replace(/^\.\/+/, '')
91
- if (!s || s === '.' || s === '..') return ''
92
- // A pattern that still escapes upward is refused at the call site, not
93
- // rewritten: silently dropping `..` would exclude MORE than the caller asked.
94
- return s
95
- }
96
-
97
- /**
98
- * Normalize a whole exclude list: split a textarea/newline blob, drop blanks and
99
- * gitignore comments, normalize each pattern, and de-duplicate (order kept so a
100
- * later UI edit reads the same as what ran).
101
- * @param {string|string[]|undefined} input
102
- * @returns {{patterns: string[], dropped: string[]}} `dropped` names inputs that
103
- * were thrown away (comments/blank/root-only), for an honest report.
104
- */
105
- export function parseExcludes(input) {
106
- const lines = Array.isArray(input) ? input : String(input ?? '').split(/\r?\n/)
107
- const patterns = []
108
- const dropped = []
109
- const seen = new Set()
110
- for (const line of lines) {
111
- const trimmed = String(line ?? '').trim()
112
- if (!trimmed || trimmed.startsWith('#')) continue
113
- const norm = normalizeExclude(trimmed)
114
- if (!norm) {
115
- dropped.push(trimmed)
116
- continue
117
- }
118
- if (seen.has(norm)) continue
119
- seen.add(norm)
120
- patterns.push(norm)
121
- }
122
- return { patterns, dropped }
123
- }
124
-
125
- /** A pattern we refuse outright: one that walks out of the workspace, or that
126
- * names an absolute path. Both make the exclusion set impossible to reason
127
- * about, and `..` in a tar pattern is never needed for "exclude my build dir". */
128
- export function isUnsafeExclude(pattern) {
129
- const s = String(pattern ?? '')
130
- return s.split('/').includes('..') || s.startsWith('/') || /^[a-zA-Z]:/.test(s)
131
- }
132
-
133
- /** Readable slug for a remote workspace path (`/home/dev/my proj` → `my-proj`).
134
- * Never empty: the hash suffix from archiveStem() keeps two same-named
135
- * workspaces from colliding. */
136
- export function workspaceSlug(remotePath) {
137
- const norm = normalizeRemotePath(remotePath || '/')
138
- const base = norm.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'workspace'
139
- const slug = base
140
- .replace(/[^a-zA-Z0-9._-]+/g, '-')
141
- .replace(/^-+|-+$/g, '')
142
- .slice(0, 48)
143
- return slug || 'workspace'
144
- }
145
-
146
- /** Filename stem for an archive: `<slug>-<hash>` — the hash keys on the FULL
147
- * remote path, so `/a/proj` and `/b/proj` never share a stem. */
148
- export function archiveStem(remotePath) {
149
- const norm = normalizeRemotePath(remotePath || '/')
150
- return `${workspaceSlug(norm)}-${shortHash(norm)}`
151
- }
152
-
153
- /**
154
- * Build an archive filename with an injectable clock (deterministic in tests).
155
- * `2026-10-08T11-22-33` is used rather than `:` so the name is legal on Windows
156
- * remotes and in a URL query.
157
- */
158
- export function archiveName(date = new Date(), { label = '', ext = ARCHIVE_SUFFIX } = {}) {
159
- const p = (n, w = 2) => String(n).padStart(w, '0')
160
- const stamp = `${date.getUTCFullYear()}-${p(date.getUTCMonth() + 1)}-${p(date.getUTCDate())}`
161
- + `T${p(date.getUTCHours())}-${p(date.getUTCMinutes())}-${p(date.getUTCSeconds())}`
162
- const tag = String(label || '').replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32)
163
- return `${tag ? tag + '-' : ''}${stamp}${ext}`
164
- }
165
-
166
- /**
167
- * Whether a caller-supplied archive name is safe to join onto a backup dir.
168
- *
169
- * This is the traversal guard for every operation that takes a `file` argument
170
- * (list/verify/restore/delete/download): a name like `../../etc/passwd` or an
171
- * absolute path must never reach `rm`/`tar` on the remote.
172
- * @returns {boolean} true when the name is a plain basename ending in `.tar.gz`.
173
- */
174
- export function isSafeArchiveName(name) {
175
- const s = String(name ?? '')
176
- if (!s || s.length > 200) return false
177
- if (s.includes('/') || s.includes('\\')) return false
178
- if (s === '.' || s === '..') return false
179
- if (s.startsWith('.')) return false
180
- if (!s.endsWith(ARCHIVE_SUFFIX)) return false
181
- return /^[A-Za-z0-9._-]+$/.test(s)
182
- }
183
-
184
- /** The sidecar metadata path for an archive (same directory, derived name). */
185
- export function metaNameFor(archive) {
186
- return String(archive).replace(/\.tar\.gz$/, META_SUFFIX)
187
- }
188
-
189
- /**
190
- * Resolve the remote backup directory from a home directory.
191
- * @param {string} home - the remote `$HOME` ('' when unknown).
192
- * @param {string} [override] - an explicit configured directory (wins).
193
- * @returns {string} an absolute POSIX path, or '' when it cannot be resolved.
194
- */
195
- export function resolveBackupDir(home, override) {
196
- const explicit = String(override ?? '').trim()
197
- if (explicit) return normalizeRemotePath(explicit)
198
- const h = String(home ?? '').trim()
199
- if (!h) return ''
200
- return normalizeRemotePath(`${h.replace(/\/+$/, '')}/${DEFAULT_BACKUP_SUFFIX}`)
201
- }
202
-
203
- /** `$HOME` resolution script. Multiple sources on purpose: an ExecStart
204
- * environment may omit HOME (systemd, some CI shells) while `~` still expands,
205
- * and a Windows-Git-Bash remote reports the POSIX home from `pwd`. */
206
- export function buildHomeCommand() {
207
- return [
208
- 'ARCH_H="$HOME"',
209
- 'if [ -z "$ARCH_H" ]',
210
- 'then',
211
- ' ARCH_H=$(cd ~ 2>/dev/null && pwd)',
212
- 'fi',
213
- 'echo "ARCH_HOME=$ARCH_H"',
214
- ].join('\n')
215
- }
216
-
217
- /** Read one `KEY=value` marker out of script output. Last occurrence wins (the
218
- * scripts print failures after progress, and a later line is the final word). */
219
- export function readMarker(stdout, key) {
220
- const re = new RegExp(`^${key}=(.*)$`, 'gm')
221
- let value = ''
222
- let m
223
- while ((m = re.exec(String(stdout ?? ''))) !== null) value = m[1].trim()
224
- return value
225
- }
226
-
227
- /** Every `KEY=value` marker as an object (first occurrence per key). */
228
- export function readMarkers(stdout, keys = Object.values(MARK)) {
229
- const out = {}
230
- for (const key of keys) {
231
- const v = readMarker(stdout, key)
232
- if (v !== '') out[key] = v
233
- }
234
- return out
235
- }
236
-
237
- /** Render `--exclude=` flags as individually shell-quoted literal arguments.
238
- *
239
- * Inlined as literals (rather than interpolated from a single `$EXCLUDES`
240
- * variable) so no shell word-splitting or glob expansion can ever reinterpret a
241
- * pattern: `shq()` protects the value, and the caller's quote stops the shell
242
- * from expanding `*.log` before tar sees it. */
243
- export function excludeFlags(patterns = []) {
244
- return patterns.map((p) => shq(`--exclude=${p}`)).join(' ')
245
- }
246
-
247
- /**
248
- * Build the archive-creation script.
249
- *
250
- * Shape: `tar -czf <stage> -C <dir> [--exclude=…]… .` — members read `./x/y`,
251
- * which is what makes a bare exclude pattern match at any depth (fact 1) and
252
- * what makes `--strip-components=1` restore the contents exactly.
253
- *
254
- * The archive is written to a sibling staging file first and only `mv`-d into
255
- * place on success, so an interrupted or failing run never leaves a truncated
256
- * `.tar.gz` that a later `list`/`restore` would treat as a real backup.
257
- *
258
- * @param {object} spec
259
- * @param {string} spec.dir - absolute directory to archive.
260
- * @param {string} spec.archive - absolute path of the finished archive.
261
- * @param {string[]} [spec.excludes] - NORMALIZED patterns (see parseExcludes).
262
- * @param {string} [spec.verifyLabel] - marker value echoed in ARCH_VERIFY.
263
- * @returns {string} a POSIX shell script.
264
- */
265
- export function buildCreateCommand({ dir, archive, excludes = [], }) {
266
- const flags = excludeFlags(excludes)
267
- for (const p of excludes) {
268
- if (isUnsafeExclude(p) || p.startsWith('./') || p.startsWith('/')) {
269
- throw new Error(`dsh-remote: refusing to run tar with unsafe/ineffective exclude pattern ${JSON.stringify(p)}`)
270
- }
271
- }
272
- return [
273
- 'set -u',
274
- `ARCH_SRC=${shq(dir)}`,
275
- `ARCH_DST=${shq(archive)}`,
276
- 'if [ ! -d "$ARCH_SRC" ]',
277
- 'then',
278
- ` echo "${MARK.ok}=0"`,
279
- ` echo "${MARK.error}=source is not a directory: $ARCH_SRC"`,
280
- ' exit 1',
281
- 'fi',
282
- 'ARCH_DIR=$(dirname "$ARCH_DST")',
283
- 'mkdir -p "$ARCH_DIR"',
284
- 'if [ ! -d "$ARCH_DIR" ]',
285
- 'then',
286
- ` echo "${MARK.ok}=0"`,
287
- ` echo "${MARK.error}=cannot create archive directory: $ARCH_DIR"`,
288
- ' exit 1',
289
- 'fi',
290
- 'ARCH_STAGE="$ARCH_DST.stage.$$"',
291
- 'rm -f "$ARCH_STAGE"',
292
- // `-C` + `.` keeps member names relative (fact 1 and 2), never absolute.
293
- // No `-P`: member names stay sanitized on extraction.
294
- `tar -czf "$ARCH_STAGE" -C "$ARCH_SRC" ${flags ? flags + ' ' : ''}. 2>&1`,
295
- 'ARCH_RC=$?',
296
- 'if [ "$ARCH_RC" -ne 0 ]',
297
- 'then',
298
- ` echo "${MARK.ok}=0"`,
299
- ` echo "${MARK.error}=tar failed with exit $ARCH_RC"`,
300
- ' rm -f "$ARCH_STAGE"',
301
- ' exit 1',
302
- 'fi',
303
- 'if [ ! -s "$ARCH_STAGE" ]',
304
- 'then',
305
- ` echo "${MARK.ok}=0"`,
306
- ` echo "${MARK.error}=tar produced an empty archive"`,
307
- ' rm -f "$ARCH_STAGE"',
308
- ' exit 1',
309
- 'fi',
310
- // Verify BEFORE publishing the file: a corrupt archive must not appear in
311
- // the listing as restorable.
312
- 'if tar -tzf "$ARCH_STAGE" >/dev/null 2>&1',
313
- 'then',
314
- ` echo "${MARK.verify}=ok"`,
315
- 'else',
316
- ` echo "${MARK.verify}=bad"`,
317
- 'fi',
318
- 'mv -f "$ARCH_STAGE" "$ARCH_DST"',
319
- 'ARCH_BYTES=$(wc -c < "$ARCH_DST" | tr -d " ")',
320
- 'ARCH_MEMBERS=$(tar -tzf "$ARCH_DST" 2>/dev/null | wc -l | tr -d " ")',
321
- 'ARCH_SUM=""',
322
- 'if command -v sha256sum >/dev/null 2>&1',
323
- 'then',
324
- ' ARCH_SUM=$(sha256sum "$ARCH_DST" | awk \'{print $1}\')',
325
- 'elif command -v shasum >/dev/null 2>&1',
326
- 'then',
327
- ' ARCH_SUM=$(shasum -a 256 "$ARCH_DST" | awk \'{print $1}\')',
328
- 'elif command -v openssl >/dev/null 2>&1',
329
- 'then',
330
- ' ARCH_SUM=$(openssl dgst -sha256 "$ARCH_DST" | awk \'{print $NF}\')',
331
- 'fi',
332
- `echo "${MARK.file}=$ARCH_DST"`,
333
- `echo "${MARK.bytes}=$ARCH_BYTES"`,
334
- `echo "${MARK.members}=$ARCH_MEMBERS"`,
335
- `echo "${MARK.sha}=$ARCH_SUM"`,
336
- `echo "${MARK.ok}=1"`,
337
- ].join('\n')
338
- }
339
-
340
- /** Interpret buildCreateCommand() output.
341
- * @returns {{ok: boolean, error: string, file: string, bytes: number, members: number, sha256: string, verified: boolean}}
342
- */
343
- export function parseCreateResult(stdout) {
344
- const m = readMarkers(stdout, [MARK.ok, MARK.error, MARK.file, MARK.bytes, MARK.members, MARK.sha, MARK.verify])
345
- return {
346
- ok: m[MARK.ok] === '1',
347
- error: m[MARK.error] || '',
348
- file: m[MARK.file] || '',
349
- bytes: Number(m[MARK.bytes]) || 0,
350
- members: Number(m[MARK.members]) || 0,
351
- sha256: m[MARK.sha] || '',
352
- verified: m[MARK.verify] === 'ok',
353
- }
354
- }
355
-
356
- /**
357
- * Build the listing script: glob the backup dir and emit one `ARCH_ENTRY=` line
358
- * per archive, then (when present) the sidecar metadata file's CONTENT between
359
- * delimiters. Sidecars are emitted rather than parsed in shell so the host half
360
- * owns one JSON parser and the listing stays a single round trip.
361
- *
362
- * Glob + `[ -e ]` rather than `find -printf` on purpose: `-printf` is a GNU
363
- * extension (absent on macOS/BSD), and a silently empty listing would look
364
- * exactly like "no backups yet".
365
- */
366
- export function buildListCommand({ dir }) {
367
- return [
368
- 'set -u',
369
- `ARCH_DIR=${shq(dir)}`,
370
- 'if [ ! -d "$ARCH_DIR" ]',
371
- 'then',
372
- ` echo "${MARK.exists}=0"`,
373
- ' exit 0',
374
- 'fi',
375
- `echo "${MARK.exists}=1"`,
376
- 'for ARCH_F in "$ARCH_DIR"/*.tar.gz',
377
- 'do',
378
- ' if [ ! -f "$ARCH_F" ]',
379
- ' then',
380
- ' continue',
381
- ' fi',
382
- ' ARCH_N=$(basename "$ARCH_F")',
383
- ' ARCH_B=$(wc -c < "$ARCH_F" | tr -d " ")',
384
- ' ARCH_T=$(stat -c %Y "$ARCH_F" 2>/dev/null)',
385
- ' if [ -z "$ARCH_T" ]',
386
- ' then',
387
- ' ARCH_T=$(stat -f %m "$ARCH_F" 2>/dev/null)',
388
- ' fi',
389
- ' if [ -z "$ARCH_T" ]',
390
- ' then',
391
- ' ARCH_T=0',
392
- ' fi',
393
- ` printf '%s=%s|%s|%s\\n' "${MARK.entry}" "$ARCH_N" "$ARCH_B" "$ARCH_T"`,
394
- ' ARCH_M="$ARCH_DIR/${ARCH_N%.tar.gz}.meta.json"',
395
- ' if [ -f "$ARCH_M" ]',
396
- ' then',
397
- // The archive NAME rides on the begin marker so a sidecar is always paired
398
- // with the entry it belongs to. Pairing by position instead would silently
399
- // shift every later archive's metadata onto the wrong row the moment one
400
- // sidecar is missing or unreadable.
401
- ` printf '%s=%s\\n' "${META_BEGIN_MARK}" "$ARCH_N"`,
402
- ' cat "$ARCH_M"',
403
- ` printf '\\n%s\\n' "${'ARCH_META_END'}"`,
404
- ' fi',
405
- 'done',
406
- ].join('\n')
407
- }
408
-
409
- /** Parse buildListCommand() output.
410
- * @returns {{exists: boolean, entries: Array<{name: string, bytes: number, mtimeMs: number, meta: object|null}>}}
411
- */
412
- export function parseListResult(stdout) {
413
- const text = String(stdout ?? '')
414
- const exists = readMarker(text, MARK.exists) === '1'
415
- const entries = []
416
- const re = new RegExp(`^${MARK.entry}=([^|]+)\\|(\\d+)\\|(\\d+)$`, 'gm')
417
- let m
418
- while ((m = re.exec(text)) !== null) {
419
- entries.push({
420
- name: m[1].trim(),
421
- bytes: Number(m[2]) || 0,
422
- mtimeMs: (Number(m[3]) || 0) * 1000,
423
- meta: null,
424
- })
425
- }
426
- // Sidecars are paired BY NAME (the name rides on ARCH_META_BEGIN=), not by
427
- // position: a missing or unreadable sidecar for one archive must not shift
428
- // every later archive's metadata onto the wrong row.
429
- const byName = new Map()
430
- const blockRe = new RegExp(`^${META_BEGIN_MARK}=(.*)\\r?\\n([\\s\\S]*?)\\r?\\nARCH_META_END$`, 'gm')
431
- let b
432
- while ((b = blockRe.exec(text)) !== null) {
433
- try {
434
- const parsed = JSON.parse(b[2])
435
- if (parsed && typeof parsed === 'object') byName.set(b[1].trim(), parsed)
436
- } catch { /* a corrupt sidecar must not hide its archive */ }
437
- }
438
- for (const entry of entries) {
439
- if (byName.has(entry.name)) entry.meta = byName.get(entry.name)
440
- }
441
- return { exists, entries }
442
- }
443
-
444
- /** Build the verification script for one existing archive: integrity of the
445
- * gzip/tar stream plus a fresh sha256, so a caller can compare it with the
446
- * recorded digest (bit rot / truncated transfer). */
447
- export function buildVerifyCommand({ archive }) {
448
- return [
449
- 'set -u',
450
- `ARCH_F=${shq(archive)}`,
451
- 'if [ ! -f "$ARCH_F" ]',
452
- 'then',
453
- ` echo "${MARK.ok}=0"`,
454
- ` echo "${MARK.error}=archive not found: $ARCH_F"`,
455
- ' exit 1',
456
- 'fi',
457
- 'if tar -tzf "$ARCH_F" >/dev/null 2>&1',
458
- 'then',
459
- ` echo "${MARK.verify}=ok"`,
460
- 'else',
461
- ` echo "${MARK.verify}=bad"`,
462
- 'fi',
463
- 'ARCH_BYTES=$(wc -c < "$ARCH_F" | tr -d " ")',
464
- 'ARCH_MEMBERS=$(tar -tzf "$ARCH_F" 2>/dev/null | wc -l | tr -d " ")',
465
- 'ARCH_SUM=""',
466
- 'if command -v sha256sum >/dev/null 2>&1',
467
- 'then',
468
- ' ARCH_SUM=$(sha256sum "$ARCH_F" | awk \'{print $1}\')',
469
- 'elif command -v shasum >/dev/null 2>&1',
470
- 'then',
471
- ' ARCH_SUM=$(shasum -a 256 "$ARCH_F" | awk \'{print $1}\')',
472
- 'elif command -v openssl >/dev/null 2>&1',
473
- 'then',
474
- ' ARCH_SUM=$(openssl dgst -sha256 "$ARCH_F" | awk \'{print $NF}\')',
475
- 'fi',
476
- `echo "${MARK.bytes}=$ARCH_BYTES"`,
477
- `echo "${MARK.members}=$ARCH_MEMBERS"`,
478
- `echo "${MARK.sha}=$ARCH_SUM"`,
479
- `echo "${MARK.ok}=1"`,
480
- ].join('\n')
481
- }
482
-
483
- /** Build the delete script for one archive plus its sidecar. */
484
- export function buildDeleteCommand({ archive, meta }) {
485
- return [
486
- 'set -u',
487
- `ARCH_F=${shq(archive)}`,
488
- `ARCH_M=${shq(meta)}`,
489
- 'if [ ! -f "$ARCH_F" ]',
490
- 'then',
491
- ` echo "${MARK.ok}=0"`,
492
- ` echo "${MARK.error}=archive not found: $ARCH_F"`,
493
- ' exit 1',
494
- 'fi',
495
- 'rm -f "$ARCH_F"',
496
- 'rm -f "$ARCH_M"',
497
- 'if [ -f "$ARCH_F" ]',
498
- 'then',
499
- ` echo "${MARK.ok}=0"`,
500
- ` echo "${MARK.error}=could not remove: $ARCH_F"`,
501
- ' exit 1',
502
- 'fi',
503
- `echo "${MARK.ok}=1"`,
504
- ].join('\n')
505
- }
506
-
507
- /** Whether a restore mode means "the target's previous contents are replaced". */
508
- export function isReplaceMode(mode) {
509
- return String(mode || 'replace').toLowerCase() !== 'merge'
510
- }
511
-
512
- /**
513
- * Build the restore script.
514
- *
515
- * `replace` is implemented as stage → swap → drop-old so the operation is
516
- * recoverable at every step: the archive is extracted into a staging dir FIRST
517
- * (a corrupt archive fails there, leaving the target byte-for-byte untouched),
518
- * then the target is moved aside and the staging dir takes its place, and only
519
- * then is the old copy removed. This is what makes "restore a bad backup"
520
- * non-destructive, which is the whole point of having a backup.
521
- *
522
- * `merge` extracts over the existing tree and never deletes anything.
523
- *
524
- * @param {object} spec
525
- * @param {string} spec.archive - absolute archive path.
526
- * @param {string} spec.target - absolute destination directory.
527
- * @param {string} [spec.mode] - `replace` (default) or `merge`.
528
- * @param {string} [spec.stage] - staging directory (a sibling of `target`).
529
- * @param {string} [spec.swap] - where the old target is parked during the swap.
530
- */
531
- export function buildRestoreCommand({ archive, target, mode = 'replace', stage, swap }) {
532
- const replace = isReplaceMode(mode)
533
- const lines = [
534
- 'set -u',
535
- `ARCH_F=${shq(archive)}`,
536
- `ARCH_T=${shq(target)}`,
537
- 'if [ ! -f "$ARCH_F" ]',
538
- 'then',
539
- ` echo "${MARK.ok}=0"`,
540
- ` echo "${MARK.error}=archive not found: $ARCH_F"`,
541
- ' exit 1',
542
- 'fi',
543
- 'ARCH_P=$(dirname "$ARCH_T")',
544
- 'mkdir -p "$ARCH_P"',
545
- // Never extract with -P / --absolute-names: the tar defaults refuse `../`
546
- // members and strip leading `/`, which is what keeps a hostile archive
547
- // inside the target (fact 2 in the header).
548
- 'if tar -tzf "$ARCH_F" >/dev/null 2>&1',
549
- 'then',
550
- ` echo "${MARK.verify}=ok"`,
551
- 'else',
552
- ` echo "${MARK.verify}=bad"`,
553
- ` echo "${MARK.ok}=0"`,
554
- ` echo "${MARK.error}=archive is corrupt or truncated; nothing was written"`,
555
- ' exit 1',
556
- 'fi',
557
- ]
558
- if (replace) {
559
- lines.push(
560
- `ARCH_STAGE=${shq(stage)}`,
561
- `ARCH_SWAP=${shq(swap)}`,
562
- 'rm -rf "$ARCH_STAGE"',
563
- 'mkdir -p "$ARCH_STAGE"',
564
- 'if tar -xzf "$ARCH_F" -C "$ARCH_STAGE" --strip-components=1 2>&1',
565
- 'then',
566
- ' :',
567
- 'else',
568
- ' rm -rf "$ARCH_STAGE"',
569
- ` echo "${MARK.ok}=0"`,
570
- ` echo "${MARK.error}=extraction failed; target left unchanged"`,
571
- ' exit 1',
572
- 'fi',
573
- 'ARCH_HAD=0',
574
- 'if [ -e "$ARCH_T" ]',
575
- 'then',
576
- ' ARCH_HAD=1',
577
- ' rm -rf "$ARCH_SWAP"',
578
- ' if mv "$ARCH_T" "$ARCH_SWAP"',
579
- ' then',
580
- ' :',
581
- ' else',
582
- ' rm -rf "$ARCH_STAGE"',
583
- ` echo "${MARK.ok}=0"`,
584
- ` echo "${MARK.error}=could not move the existing target aside; nothing changed"`,
585
- ' exit 1',
586
- ' fi',
587
- 'fi',
588
- 'if mv "$ARCH_STAGE" "$ARCH_T"',
589
- 'then',
590
- ' :',
591
- 'else',
592
- ' if [ "$ARCH_HAD" = "1" ]',
593
- ' then',
594
- // Roll back: put the original back so a failed swap is not a data loss.
595
- ' mv "$ARCH_SWAP" "$ARCH_T"',
596
- ' fi',
597
- ' rm -rf "$ARCH_STAGE"',
598
- ` echo "${MARK.ok}=0"`,
599
- ` echo "${MARK.error}=could not move the restored tree into place; original restored"`,
600
- ' exit 1',
601
- 'fi',
602
- 'if [ "$ARCH_HAD" = "1" ]',
603
- 'then',
604
- ' rm -rf "$ARCH_SWAP"',
605
- 'fi',
606
- `echo "${MARK.action}=replaced"`,
607
- )
608
- } else {
609
- lines.push(
610
- 'mkdir -p "$ARCH_T"',
611
- 'if tar -xzf "$ARCH_F" -C "$ARCH_T" --strip-components=1 2>&1',
612
- 'then',
613
- ' :',
614
- 'else',
615
- ` echo "${MARK.ok}=0"`,
616
- ` echo "${MARK.error}=extraction failed (target may be partially updated — use replace mode for an atomic restore)"`,
617
- ' exit 1',
618
- 'fi',
619
- `echo "${MARK.action}=merged"`,
620
- )
621
- }
622
- lines.push(
623
- 'ARCH_FILES=$(find "$ARCH_T" -type f 2>/dev/null | wc -l | tr -d " ")',
624
- `echo "${MARK.members}=$ARCH_FILES"`,
625
- `echo "${MARK.ok}=1"`,
626
- )
627
- return lines.join('\n')
628
- }
629
-
630
- /** Parse the shared ok/error tail of a script's output. */
631
- export function parseStatus(stdout) {
632
- const m = readMarkers(stdout, [MARK.ok, MARK.error, MARK.verify, MARK.action, MARK.bytes, MARK.members, MARK.sha, MARK.exists])
633
- return {
634
- ok: m[MARK.ok] === '1',
635
- error: m[MARK.error] || '',
636
- verified: m[MARK.verify] === 'ok',
637
- action: m[MARK.action] || '',
638
- bytes: Number(m[MARK.bytes]) || 0,
639
- members: Number(m[MARK.members]) || 0,
640
- sha256: m[MARK.sha] || '',
641
- exists: m[MARK.exists] === '1',
642
- }
643
- }
644
-
645
- /**
646
- * Reject an exclude that would escape the workspace. Kept separate so the tool
647
- * can return a precise, actionable message instead of a generic failure, and so
648
- * the same rule is applied on the UI route and the model tool.
649
- * @returns {string} '' when acceptable, else the reason.
650
- */
651
- export function excludeRejection(patterns = []) {
652
- for (const p of patterns) {
653
- if (isUnsafeExclude(p)) {
654
- return `exclude pattern ${JSON.stringify(p)} escapes the workspace — backup excludes must be relative paths inside it`
655
- }
656
- }
657
- return ''
658
- }
659
-
660
- /** Human-readable byte size for tool/UI text (1024-based, one decimal). */
661
- export function formatBytes(n) {
662
- const v = Number(n) || 0
663
- if (v < 1024) return `${v} B`
664
- const units = ['KB', 'MB', 'GB', 'TB']
665
- let x = v / 1024
666
- let i = 0
667
- while (x >= 1024 && i < units.length - 1) { x /= 1024; i++ }
668
- return `${x.toFixed(x < 10 ? 1 : 0)} ${units[i]}`
669
- }
670
-
671
- /**
672
- * The relative path of a backup directory inside an archived workspace, or ''
673
- * when it sits outside (the default). Used to guarantee an archive never
674
- * contains a previous backup of itself, which would grow it every run.
675
- */
676
- export function backupDirUnderRoot(backupDir, root) {
677
- if (!backupDir || !root) return ''
678
- const rel = relPathUnder(root, backupDir)
679
- return rel === null || rel === '' ? '' : rel.replace(/^\/+/, '')
680
- }