dsh-remote 0.8.40 → 0.8.42

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,1005 +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
- /** Marker value for the manifest directory inside a "all workspaces" archive. */
248
- export const ALL_MANIFEST_DIR = 'dsh-remote-backup.json'
249
- export const ALL_MANIFEST_FILE = 'manifest.json'
250
-
251
- /**
252
- * Build the archive-creation script for SEVERAL workspaces at once.
253
- *
254
- * One archive, one restore button — the whole point is that the user does not
255
- * have to think per-workspace. Members are stored by their ABSOLUTE path with
256
- * the leading `/` stripped (`-C /`), next to a manifest listing each workspace's
257
- * real absolute path. Restoring reads the manifest, so an archive made on one
258
- * machine can be restored on another, where the same absolute path may not exist
259
- * (the caller supplies the fallback location).
260
- *
261
- * Excludes are passed for the WHOLE set: `node_modules` must be skipped at any
262
- * depth, so the caller passes both the bare form and the `*​/…` form (GNU tar
263
- * matches a bare pattern at any depth already, but a pattern that contains a `/`
264
- * is anchored, so the `*​/` variant is what keeps a NESTED `a/node_modules` out
265
- * — measured, see the header note).
266
- *
267
- * @param {object} spec
268
- * @param {Array<{path: string, member: string}>} spec.workspaces - absolute
269
- * directory paths plus the archive member each one is stored as.
270
- * @param {string} spec.archive - absolute destination for the .tar.gz.
271
- * @param {string} spec.manifestJson - sidecar manifest text stored in the archive.
272
- * @param {string} [spec.pathsTsv] - `member<TAB>path` lines the restore loop reads
273
- * (the remote has no JSON parser we can rely on).
274
- * @param {string[]} [spec.excludes] - NORMALIZED tar patterns (see parseExcludes).
275
- * @returns {string} a POSIX shell script.
276
- */
277
- export function buildCreateAllCommand({ workspaces = [], archive, manifestJson = '', pathsTsv = '', excludes = [] }) {
278
- const seen = new Set()
279
- const members = []
280
- for (const w of workspaces) {
281
- const p = String((w && w.path) || '')
282
- const member = String((w && w.member) || '')
283
- if (!p || p === '/' || seen.has(p)) continue
284
- seen.add(p)
285
- // Guard the member expression: a workspace the archive cannot address would
286
- // silently land somewhere unexpected on restore.
287
- if (!p.startsWith('/')) throw new Error(`dsh-remote: refusing to archive a non-absolute workspace path ${JSON.stringify(p)}`)
288
- if (p.split('/').includes('..')) throw new Error(`dsh-remote: refusing to archive a workspace path containing '..': ${JSON.stringify(p)}`)
289
- if (!member || member.startsWith('/') || member.split('/').includes('..')) {
290
- throw new Error(`dsh-remote: refusing a bad archive member ${JSON.stringify(member)} for ${p}`)
291
- }
292
- members.push({ path: p, member })
293
- }
294
- if (!members.length) throw new Error('dsh-remote: no workspaces to archive')
295
- for (const p of excludes) {
296
- if (isUnsafeExclude(p) || p.startsWith('./') || p.startsWith('/')) {
297
- throw new Error(`dsh-remote: refusing to run tar with unsafe/ineffective exclude pattern ${JSON.stringify(p)}`)
298
- }
299
- }
300
- const flags = excludeFlags(excludes)
301
- const manifestB64 = Buffer.from(String(manifestJson), 'utf8').toString('base64')
302
- const pathsTsvB64 = Buffer.from(String(pathsTsv || ''), 'utf8').toString('base64')
303
- return [
304
- 'set -u',
305
- `ARCH_DST=${shq(archive)}`,
306
- 'ARCH_DIR=$(dirname "$ARCH_DST")',
307
- 'mkdir -p "$ARCH_DIR"',
308
- 'if [ ! -d "$ARCH_DIR" ]',
309
- 'then',
310
- ` echo "${MARK.ok}=0"`,
311
- ` echo "${MARK.error}=cannot create archive directory: $ARCH_DIR"`,
312
- ' exit 1',
313
- 'fi',
314
- // Refuse BEFORE writing anything when a listed workspace is gone: a silent
315
- // partial archive is exactly how "I backed it up" becomes false.
316
- // The check uses the REAL absolute path, not the archive member — the two
317
- // differ (`/allws/w1` vs `allws/w1`), and checking the member is what the
318
- // first version did, failing every run with "these workspaces do not exist".
319
- 'ARCH_MISSING=""',
320
- ...members.map((m) => `if [ ! -d ${shq(m.path)} ]\nthen\n ARCH_MISSING="$ARCH_MISSING ${shq(m.path)}"\nfi`),
321
- 'if [ -n "$ARCH_MISSING" ]',
322
- 'then',
323
- ` echo "${MARK.ok}=0"`,
324
- ` echo "${MARK.error}=these workspaces do not exist:$ARCH_MISSING"`,
325
- ' exit 1',
326
- 'fi',
327
- // The manifest is materialised into a staging dir and added with its OWN -C
328
- // group, so one invocation writes the archive plus its manifest.
329
- 'ARCH_TMP="$ARCH_DST.stage.$$"',
330
- 'rm -rf "$ARCH_TMP"',
331
- // The manifest lives in its OWN subdirectory inside the staging dir, because
332
- // tar adds `"$ARCH_TMP"/dsh-remote-backup.json` as a member of that name.
333
- // Writing the files flat into ARCH_TMP made tar fail with "Cannot stat" —
334
- // caught by the real-shell run, not by a string assertion.
335
- `mkdir -p "$ARCH_TMP/${ALL_MANIFEST_DIR}"`,
336
- // Both manifest files are written HERE, before tar runs.
337
- // Base64 avoids every quoting hazard in the text (quotes, tabs, newlines).
338
- `printf '%s' ${shq(manifestB64)} | base64 -d > "$ARCH_TMP/${ALL_MANIFEST_DIR}/${ALL_MANIFEST_FILE}"`,
339
- `printf '%s' ${shq(pathsTsvB64)} | base64 -d > "$ARCH_TMP/${ALL_MANIFEST_DIR}/paths.tsv"`,
340
- 'ARCH_STAGE="$ARCH_DST.stage.$$.tgz"',
341
- 'rm -f "$ARCH_STAGE"',
342
- // `-C /` keeps member names absolute-relative (no leading slash), and `-C`
343
- // again for the manifest dir; `-P` is never passed.
344
- `tar -czf "$ARCH_STAGE" -C / ${flags ? flags + ' ' : ''}${members.map((m) => shq(m.member)).join(' ')} -C "$ARCH_TMP" ${shq(ALL_MANIFEST_DIR)} 2>&1`,
345
- 'ARCH_RC=$?',
346
- 'if [ "$ARCH_RC" -ne 0 ]',
347
- 'then',
348
- ` echo "${MARK.ok}=0"`,
349
- ` echo "${MARK.error}=tar failed with exit $ARCH_RC"`,
350
- ' rm -f "$ARCH_STAGE"',
351
- ' rm -rf "$ARCH_TMP"',
352
- ' exit 1',
353
- 'fi',
354
- 'if [ ! -s "$ARCH_STAGE" ]',
355
- 'then',
356
- ` echo "${MARK.ok}=0"`,
357
- ` echo "${MARK.error}=tar produced an empty archive"`,
358
- ' rm -f "$ARCH_STAGE"',
359
- ' rm -rf "$ARCH_TMP"',
360
- ' exit 1',
361
- 'fi',
362
- 'if tar -tzf "$ARCH_STAGE" >/dev/null 2>&1',
363
- 'then',
364
- ` echo "${MARK.verify}=ok"`,
365
- 'else',
366
- ` echo "${MARK.verify}=bad"`,
367
- 'fi',
368
- 'mv -f "$ARCH_STAGE" "$ARCH_DST"',
369
- 'rm -rf "$ARCH_TMP"',
370
- 'ARCH_BYTES=$(wc -c < "$ARCH_DST" | tr -d " ")',
371
- 'ARCH_MEMBERS=$(tar -tzf "$ARCH_DST" 2>/dev/null | wc -l | tr -d " ")',
372
- 'ARCH_SUM=""',
373
- 'if command -v sha256sum >/dev/null 2>&1',
374
- 'then',
375
- ' ARCH_SUM=$(sha256sum "$ARCH_DST" | awk \'{print $1}\')',
376
- 'elif command -v shasum >/dev/null 2>&1',
377
- 'then',
378
- ' ARCH_SUM=$(shasum -a 256 "$ARCH_DST" | awk \'{print $1}\')',
379
- 'elif command -v openssl >/dev/null 2>&1',
380
- 'then',
381
- ' ARCH_SUM=$(openssl dgst -sha256 "$ARCH_DST" | awk \'{print $NF}\')',
382
- 'fi',
383
- `echo "${MARK.file}=$ARCH_DST"`,
384
- `echo "${MARK.bytes}=$ARCH_BYTES"`,
385
- `echo "${MARK.members}=$ARCH_MEMBERS"`,
386
- `echo "${MARK.sha}=$ARCH_SUM"`,
387
- `echo "${MARK.ok}=1"`,
388
- ].join('\n')
389
- }
390
-
391
- /**
392
- * Build the restore-all script.
393
- *
394
- * Order matters and is the safety property: verify the stream, extract to a
395
- * staging dir, then relocate each workspace into place. Relocation is
396
- * per-workspace `mv` of the EXTRACTED copy over the destination, moving the
397
- * previous contents aside first and deleting them only after the new tree is in
398
- * place — the same stage/swap discipline as the single-workspace restore.
399
- *
400
- * A workspace in the manifest whose absolute path cannot be created falls back
401
- * to `<fallbackRoot>/<basename>`, so an archive restored on a different machine
402
- * still lands somewhere sensible instead of failing outright.
403
- */
404
- export function buildRestoreAllCommand({ archive, fallbackRoot = '/tmp' }) {
405
- return [
406
- 'set -u',
407
- `ARCH_F=${shq(archive)}`,
408
- `ARCH_FALLBACK=${shq(fallbackRoot)}`,
409
- 'if [ ! -f "$ARCH_F" ]',
410
- 'then',
411
- ` echo "${MARK.ok}=0"`,
412
- ` echo "${MARK.error}=archive not found: $ARCH_F"`,
413
- ' exit 1',
414
- 'fi',
415
- 'if tar -tzf "$ARCH_F" >/dev/null 2>&1',
416
- 'then',
417
- ` echo "${MARK.verify}=ok"`,
418
- 'else',
419
- ` echo "${MARK.verify}=bad"`,
420
- ` echo "${MARK.ok}=0"`,
421
- ` echo "${MARK.error}=archive is corrupt or truncated; nothing was written"`,
422
- ' exit 1',
423
- 'fi',
424
- 'ARCH_STAGE="$ARCH_F.extract.$$"',
425
- 'rm -rf "$ARCH_STAGE"',
426
- 'mkdir -p "$ARCH_STAGE"',
427
- 'if tar -xzf "$ARCH_F" -C "$ARCH_STAGE" 2>&1',
428
- 'then',
429
- ' :',
430
- 'else',
431
- ' rm -rf "$ARCH_STAGE"',
432
- ` echo "${MARK.ok}=0"`,
433
- ` echo "${MARK.error}=extraction failed; nothing was written"`,
434
- ' exit 1',
435
- 'fi',
436
- `if [ ! -f "$ARCH_STAGE/${ALL_MANIFEST_DIR}/${ALL_MANIFEST_FILE}" ]`,
437
- 'then',
438
- ' rm -rf "$ARCH_STAGE"',
439
- ` echo "${MARK.ok}=0"`,
440
- ` echo "${MARK.error}=archive has no manifest (${ALL_MANIFEST_DIR}/${ALL_MANIFEST_FILE})"`,
441
- ' exit 1',
442
- 'fi',
443
- // Parse the manifest without jq: each workspace is one line of the form
444
- // `MEMBER<TAB>PATH`, which the host emits into the manifest as an extra
445
- // field for exactly this reason (no JSON parser on the remote).
446
- `ARCH_LIST="$ARCH_STAGE/${ALL_MANIFEST_DIR}/paths.tsv"`,
447
- 'if [ ! -f "$ARCH_LIST" ]',
448
- 'then',
449
- ' rm -rf "$ARCH_STAGE"',
450
- ` echo "${MARK.ok}=0"`,
451
- ` echo "${MARK.error}=archive manifest has no paths.tsv"`,
452
- ' exit 1',
453
- 'fi',
454
- 'ARCH_RESTORED=0',
455
- 'ARCH_FAILED=""',
456
- 'while IFS="\t" read -r ARCH_MEMBER ARCH_TARGET',
457
- 'do',
458
- ' if [ -z "$ARCH_MEMBER" ] || [ -z "$ARCH_TARGET" ]',
459
- ' then',
460
- ' continue',
461
- ' fi',
462
- ' ARCH_SRC="$ARCH_STAGE/$ARCH_MEMBER"',
463
- ' if [ ! -e "$ARCH_SRC" ]',
464
- ' then',
465
- ' continue',
466
- ' fi',
467
- // An unwritable original location falls back to <fallback>/<basename> rather
468
- // than failing the whole restore half-way.
469
- ' ARCH_P=$(dirname "$ARCH_TARGET")',
470
- ' if ! mkdir -p "$ARCH_P" 2>/dev/null',
471
- ' then',
472
- ' ARCH_TARGET="$ARCH_FALLBACK/$(basename "$ARCH_TARGET")"',
473
- ' ARCH_P=$(dirname "$ARCH_TARGET")',
474
- ' mkdir -p "$ARCH_P" 2>/dev/null || { ARCH_FAILED="$ARCH_FAILED $ARCH_SRC"; continue; }',
475
- ' fi',
476
- ' ARCH_SWAP="$ARCH_TARGET.dsh-remote-bak.$$"',
477
- ' ARCH_HAD=0',
478
- ' if [ -e "$ARCH_TARGET" ]',
479
- ' then',
480
- ' ARCH_HAD=1',
481
- ' rm -rf "$ARCH_SWAP"',
482
- ' if ! mv "$ARCH_TARGET" "$ARCH_SWAP"',
483
- ' then',
484
- ' ARCH_FAILED="$ARCH_FAILED $ARCH_TARGET"',
485
- ' continue',
486
- ' fi',
487
- ' fi',
488
- ' if mv "$ARCH_SRC" "$ARCH_TARGET"',
489
- ' then',
490
- ' if [ "$ARCH_HAD" = "1" ]',
491
- ' then',
492
- ' rm -rf "$ARCH_SWAP"',
493
- ' fi',
494
- ' ARCH_RESTORED=$((ARCH_RESTORED+1))',
495
- ` echo "${MARK.entry}=$ARCH_TARGET"`,
496
- ' else',
497
- ' if [ "$ARCH_HAD" = "1" ]',
498
- ' then',
499
- ' mv "$ARCH_SWAP" "$ARCH_TARGET"',
500
- ' fi',
501
- ' ARCH_FAILED="$ARCH_FAILED $ARCH_TARGET"',
502
- ' fi',
503
- `done < "$ARCH_LIST"`,
504
- 'rm -rf "$ARCH_STAGE"',
505
- `echo "${MARK.members}=$ARCH_RESTORED"`,
506
- 'if [ -n "$ARCH_FAILED" ]',
507
- 'then',
508
- ` echo "${MARK.error}=could not restore:$ARCH_FAILED"`,
509
- 'fi',
510
- `echo "${MARK.ok}=1"`,
511
- ].join('\n')
512
- }
513
-
514
- /**
515
- * Build the archive-creation script.
516
- *
517
- * Shape: `tar -czf <stage> -C <dir> [--exclude=…]… .` — members read `./x/y`,
518
- * which is what makes a bare exclude pattern match at any depth (fact 1) and
519
- * what makes `--strip-components=1` restore the contents exactly.
520
- *
521
- * The archive is written to a sibling staging file first and only `mv`-d into
522
- * place on success, so an interrupted or failing run never leaves a truncated
523
- * `.tar.gz` that a later `list`/`restore` would treat as a real backup.
524
- *
525
- * @param {object} spec
526
- * @param {string} spec.dir - absolute directory to archive.
527
- * @param {string} spec.archive - absolute path of the finished archive.
528
- * @param {string[]} [spec.excludes] - NORMALIZED patterns (see parseExcludes).
529
- * @param {string} [spec.verifyLabel] - marker value echoed in ARCH_VERIFY.
530
- * @returns {string} a POSIX shell script.
531
- */
532
- export function buildCreateCommand({ dir, archive, excludes = [], }) {
533
- const flags = excludeFlags(excludes)
534
- for (const p of excludes) {
535
- if (isUnsafeExclude(p) || p.startsWith('./') || p.startsWith('/')) {
536
- throw new Error(`dsh-remote: refusing to run tar with unsafe/ineffective exclude pattern ${JSON.stringify(p)}`)
537
- }
538
- }
539
- return [
540
- 'set -u',
541
- `ARCH_SRC=${shq(dir)}`,
542
- `ARCH_DST=${shq(archive)}`,
543
- 'if [ ! -d "$ARCH_SRC" ]',
544
- 'then',
545
- ` echo "${MARK.ok}=0"`,
546
- ` echo "${MARK.error}=source is not a directory: $ARCH_SRC"`,
547
- ' exit 1',
548
- 'fi',
549
- 'ARCH_DIR=$(dirname "$ARCH_DST")',
550
- 'mkdir -p "$ARCH_DIR"',
551
- 'if [ ! -d "$ARCH_DIR" ]',
552
- 'then',
553
- ` echo "${MARK.ok}=0"`,
554
- ` echo "${MARK.error}=cannot create archive directory: $ARCH_DIR"`,
555
- ' exit 1',
556
- 'fi',
557
- 'ARCH_STAGE="$ARCH_DST.stage.$$"',
558
- 'rm -f "$ARCH_STAGE"',
559
- // `-C` + `.` keeps member names relative (fact 1 and 2), never absolute.
560
- // No `-P`: member names stay sanitized on extraction.
561
- `tar -czf "$ARCH_STAGE" -C "$ARCH_SRC" ${flags ? flags + ' ' : ''}. 2>&1`,
562
- 'ARCH_RC=$?',
563
- 'if [ "$ARCH_RC" -ne 0 ]',
564
- 'then',
565
- ` echo "${MARK.ok}=0"`,
566
- ` echo "${MARK.error}=tar failed with exit $ARCH_RC"`,
567
- ' rm -f "$ARCH_STAGE"',
568
- ' exit 1',
569
- 'fi',
570
- 'if [ ! -s "$ARCH_STAGE" ]',
571
- 'then',
572
- ` echo "${MARK.ok}=0"`,
573
- ` echo "${MARK.error}=tar produced an empty archive"`,
574
- ' rm -f "$ARCH_STAGE"',
575
- ' exit 1',
576
- 'fi',
577
- // Verify BEFORE publishing the file: a corrupt archive must not appear in
578
- // the listing as restorable.
579
- 'if tar -tzf "$ARCH_STAGE" >/dev/null 2>&1',
580
- 'then',
581
- ` echo "${MARK.verify}=ok"`,
582
- 'else',
583
- ` echo "${MARK.verify}=bad"`,
584
- 'fi',
585
- 'mv -f "$ARCH_STAGE" "$ARCH_DST"',
586
- 'ARCH_BYTES=$(wc -c < "$ARCH_DST" | tr -d " ")',
587
- 'ARCH_MEMBERS=$(tar -tzf "$ARCH_DST" 2>/dev/null | wc -l | tr -d " ")',
588
- 'ARCH_SUM=""',
589
- 'if command -v sha256sum >/dev/null 2>&1',
590
- 'then',
591
- ' ARCH_SUM=$(sha256sum "$ARCH_DST" | awk \'{print $1}\')',
592
- 'elif command -v shasum >/dev/null 2>&1',
593
- 'then',
594
- ' ARCH_SUM=$(shasum -a 256 "$ARCH_DST" | awk \'{print $1}\')',
595
- 'elif command -v openssl >/dev/null 2>&1',
596
- 'then',
597
- ' ARCH_SUM=$(openssl dgst -sha256 "$ARCH_DST" | awk \'{print $NF}\')',
598
- 'fi',
599
- `echo "${MARK.file}=$ARCH_DST"`,
600
- `echo "${MARK.bytes}=$ARCH_BYTES"`,
601
- `echo "${MARK.members}=$ARCH_MEMBERS"`,
602
- `echo "${MARK.sha}=$ARCH_SUM"`,
603
- `echo "${MARK.ok}=1"`,
604
- ].join('\n')
605
- }
606
-
607
- /** Interpret buildCreateCommand() output.
608
- * @returns {{ok: boolean, error: string, file: string, bytes: number, members: number, sha256: string, verified: boolean}}
609
- */
610
- export function parseCreateResult(stdout) {
611
- const m = readMarkers(stdout, [MARK.ok, MARK.error, MARK.file, MARK.bytes, MARK.members, MARK.sha, MARK.verify])
612
- return {
613
- ok: m[MARK.ok] === '1',
614
- error: m[MARK.error] || '',
615
- file: m[MARK.file] || '',
616
- bytes: Number(m[MARK.bytes]) || 0,
617
- members: Number(m[MARK.members]) || 0,
618
- sha256: m[MARK.sha] || '',
619
- verified: m[MARK.verify] === 'ok',
620
- }
621
- }
622
-
623
- /**
624
- * Build the listing script: glob the backup dir and emit one `ARCH_ENTRY=` line
625
- * per archive, then (when present) the sidecar metadata file's CONTENT between
626
- * delimiters. Sidecars are emitted rather than parsed in shell so the host half
627
- * owns one JSON parser and the listing stays a single round trip.
628
- *
629
- * Glob + `[ -e ]` rather than `find -printf` on purpose: `-printf` is a GNU
630
- * extension (absent on macOS/BSD), and a silently empty listing would look
631
- * exactly like "no backups yet".
632
- */
633
- export function buildListCommand({ dir }) {
634
- return [
635
- 'set -u',
636
- `ARCH_DIR=${shq(dir)}`,
637
- 'if [ ! -d "$ARCH_DIR" ]',
638
- 'then',
639
- ` echo "${MARK.exists}=0"`,
640
- ' exit 0',
641
- 'fi',
642
- `echo "${MARK.exists}=1"`,
643
- 'for ARCH_F in "$ARCH_DIR"/*.tar.gz',
644
- 'do',
645
- ' if [ ! -f "$ARCH_F" ]',
646
- ' then',
647
- ' continue',
648
- ' fi',
649
- ' ARCH_N=$(basename "$ARCH_F")',
650
- ' ARCH_B=$(wc -c < "$ARCH_F" | tr -d " ")',
651
- ' ARCH_T=$(stat -c %Y "$ARCH_F" 2>/dev/null)',
652
- ' if [ -z "$ARCH_T" ]',
653
- ' then',
654
- ' ARCH_T=$(stat -f %m "$ARCH_F" 2>/dev/null)',
655
- ' fi',
656
- ' if [ -z "$ARCH_T" ]',
657
- ' then',
658
- ' ARCH_T=0',
659
- ' fi',
660
- ` printf '%s=%s|%s|%s\\n' "${MARK.entry}" "$ARCH_N" "$ARCH_B" "$ARCH_T"`,
661
- ' ARCH_M="$ARCH_DIR/${ARCH_N%.tar.gz}.meta.json"',
662
- ' if [ -f "$ARCH_M" ]',
663
- ' then',
664
- // The archive NAME rides on the begin marker so a sidecar is always paired
665
- // with the entry it belongs to. Pairing by position instead would silently
666
- // shift every later archive's metadata onto the wrong row the moment one
667
- // sidecar is missing or unreadable.
668
- ` printf '%s=%s\\n' "${META_BEGIN_MARK}" "$ARCH_N"`,
669
- ' cat "$ARCH_M"',
670
- ` printf '\\n%s\\n' "${'ARCH_META_END'}"`,
671
- ' fi',
672
- 'done',
673
- ].join('\n')
674
- }
675
-
676
- /** Parse buildListCommand() output.
677
- * @returns {{exists: boolean, entries: Array<{name: string, bytes: number, mtimeMs: number, meta: object|null}>}}
678
- */
679
- export function parseListResult(stdout) {
680
- const text = String(stdout ?? '')
681
- const exists = readMarker(text, MARK.exists) === '1'
682
- const entries = []
683
- const re = new RegExp(`^${MARK.entry}=([^|]+)\\|(\\d+)\\|(\\d+)$`, 'gm')
684
- let m
685
- while ((m = re.exec(text)) !== null) {
686
- entries.push({
687
- name: m[1].trim(),
688
- bytes: Number(m[2]) || 0,
689
- mtimeMs: (Number(m[3]) || 0) * 1000,
690
- meta: null,
691
- })
692
- }
693
- // Sidecars are paired BY NAME (the name rides on ARCH_META_BEGIN=), not by
694
- // position: a missing or unreadable sidecar for one archive must not shift
695
- // every later archive's metadata onto the wrong row.
696
- const byName = new Map()
697
- const blockRe = new RegExp(`^${META_BEGIN_MARK}=(.*)\\r?\\n([\\s\\S]*?)\\r?\\nARCH_META_END$`, 'gm')
698
- let b
699
- while ((b = blockRe.exec(text)) !== null) {
700
- try {
701
- const parsed = JSON.parse(b[2])
702
- if (parsed && typeof parsed === 'object') byName.set(b[1].trim(), parsed)
703
- } catch { /* a corrupt sidecar must not hide its archive */ }
704
- }
705
- for (const entry of entries) {
706
- if (byName.has(entry.name)) entry.meta = byName.get(entry.name)
707
- }
708
- return { exists, entries }
709
- }
710
-
711
- /** Build the verification script for one existing archive: integrity of the
712
- * gzip/tar stream plus a fresh sha256, so a caller can compare it with the
713
- * recorded digest (bit rot / truncated transfer). */
714
- export function buildVerifyCommand({ archive }) {
715
- return [
716
- 'set -u',
717
- `ARCH_F=${shq(archive)}`,
718
- 'if [ ! -f "$ARCH_F" ]',
719
- 'then',
720
- ` echo "${MARK.ok}=0"`,
721
- ` echo "${MARK.error}=archive not found: $ARCH_F"`,
722
- ' exit 1',
723
- 'fi',
724
- 'if tar -tzf "$ARCH_F" >/dev/null 2>&1',
725
- 'then',
726
- ` echo "${MARK.verify}=ok"`,
727
- 'else',
728
- ` echo "${MARK.verify}=bad"`,
729
- 'fi',
730
- 'ARCH_BYTES=$(wc -c < "$ARCH_F" | tr -d " ")',
731
- 'ARCH_MEMBERS=$(tar -tzf "$ARCH_F" 2>/dev/null | wc -l | tr -d " ")',
732
- 'ARCH_SUM=""',
733
- 'if command -v sha256sum >/dev/null 2>&1',
734
- 'then',
735
- ' ARCH_SUM=$(sha256sum "$ARCH_F" | awk \'{print $1}\')',
736
- 'elif command -v shasum >/dev/null 2>&1',
737
- 'then',
738
- ' ARCH_SUM=$(shasum -a 256 "$ARCH_F" | awk \'{print $1}\')',
739
- 'elif command -v openssl >/dev/null 2>&1',
740
- 'then',
741
- ' ARCH_SUM=$(openssl dgst -sha256 "$ARCH_F" | awk \'{print $NF}\')',
742
- 'fi',
743
- `echo "${MARK.bytes}=$ARCH_BYTES"`,
744
- `echo "${MARK.members}=$ARCH_MEMBERS"`,
745
- `echo "${MARK.sha}=$ARCH_SUM"`,
746
- `echo "${MARK.ok}=1"`,
747
- ].join('\n')
748
- }
749
-
750
- /** Build the delete script for one archive plus its sidecar. */
751
- export function buildDeleteCommand({ archive, meta }) {
752
- return [
753
- 'set -u',
754
- `ARCH_F=${shq(archive)}`,
755
- `ARCH_M=${shq(meta)}`,
756
- 'if [ ! -f "$ARCH_F" ]',
757
- 'then',
758
- ` echo "${MARK.ok}=0"`,
759
- ` echo "${MARK.error}=archive not found: $ARCH_F"`,
760
- ' exit 1',
761
- 'fi',
762
- 'rm -f "$ARCH_F"',
763
- 'rm -f "$ARCH_M"',
764
- 'if [ -f "$ARCH_F" ]',
765
- 'then',
766
- ` echo "${MARK.ok}=0"`,
767
- ` echo "${MARK.error}=could not remove: $ARCH_F"`,
768
- ' exit 1',
769
- 'fi',
770
- `echo "${MARK.ok}=1"`,
771
- ].join('\n')
772
- }
773
-
774
- /** Whether a restore mode means "the target's previous contents are replaced". */
775
- export function isReplaceMode(mode) {
776
- return String(mode || 'replace').toLowerCase() !== 'merge'
777
- }
778
-
779
- /**
780
- * Build the restore script.
781
- *
782
- * `replace` is implemented as stage → swap → drop-old so the operation is
783
- * recoverable at every step: the archive is extracted into a staging dir FIRST
784
- * (a corrupt archive fails there, leaving the target byte-for-byte untouched),
785
- * then the target is moved aside and the staging dir takes its place, and only
786
- * then is the old copy removed. This is what makes "restore a bad backup"
787
- * non-destructive, which is the whole point of having a backup.
788
- *
789
- * `merge` extracts over the existing tree and never deletes anything.
790
- *
791
- * @param {object} spec
792
- * @param {string} spec.archive - absolute archive path.
793
- * @param {string} spec.target - absolute destination directory.
794
- * @param {string} [spec.mode] - `replace` (default) or `merge`.
795
- * @param {string} [spec.stage] - staging directory (a sibling of `target`).
796
- * @param {string} [spec.swap] - where the old target is parked during the swap.
797
- */
798
- export function buildRestoreCommand({ archive, target, mode = 'replace', stage, swap }) {
799
- const replace = isReplaceMode(mode)
800
- const lines = [
801
- 'set -u',
802
- `ARCH_F=${shq(archive)}`,
803
- `ARCH_T=${shq(target)}`,
804
- 'if [ ! -f "$ARCH_F" ]',
805
- 'then',
806
- ` echo "${MARK.ok}=0"`,
807
- ` echo "${MARK.error}=archive not found: $ARCH_F"`,
808
- ' exit 1',
809
- 'fi',
810
- 'ARCH_P=$(dirname "$ARCH_T")',
811
- 'mkdir -p "$ARCH_P"',
812
- // Never extract with -P / --absolute-names: the tar defaults refuse `../`
813
- // members and strip leading `/`, which is what keeps a hostile archive
814
- // inside the target (fact 2 in the header).
815
- 'if tar -tzf "$ARCH_F" >/dev/null 2>&1',
816
- 'then',
817
- ` echo "${MARK.verify}=ok"`,
818
- 'else',
819
- ` echo "${MARK.verify}=bad"`,
820
- ` echo "${MARK.ok}=0"`,
821
- ` echo "${MARK.error}=archive is corrupt or truncated; nothing was written"`,
822
- ' exit 1',
823
- 'fi',
824
- ]
825
- if (replace) {
826
- lines.push(
827
- `ARCH_STAGE=${shq(stage)}`,
828
- `ARCH_SWAP=${shq(swap)}`,
829
- 'rm -rf "$ARCH_STAGE"',
830
- 'mkdir -p "$ARCH_STAGE"',
831
- 'if tar -xzf "$ARCH_F" -C "$ARCH_STAGE" --strip-components=1 2>&1',
832
- 'then',
833
- ' :',
834
- 'else',
835
- ' rm -rf "$ARCH_STAGE"',
836
- ` echo "${MARK.ok}=0"`,
837
- ` echo "${MARK.error}=extraction failed; target left unchanged"`,
838
- ' exit 1',
839
- 'fi',
840
- 'ARCH_HAD=0',
841
- 'if [ -e "$ARCH_T" ]',
842
- 'then',
843
- ' ARCH_HAD=1',
844
- ' rm -rf "$ARCH_SWAP"',
845
- ' if mv "$ARCH_T" "$ARCH_SWAP"',
846
- ' then',
847
- ' :',
848
- ' else',
849
- ' rm -rf "$ARCH_STAGE"',
850
- ` echo "${MARK.ok}=0"`,
851
- ` echo "${MARK.error}=could not move the existing target aside; nothing changed"`,
852
- ' exit 1',
853
- ' fi',
854
- 'fi',
855
- 'if mv "$ARCH_STAGE" "$ARCH_T"',
856
- 'then',
857
- ' :',
858
- 'else',
859
- ' if [ "$ARCH_HAD" = "1" ]',
860
- ' then',
861
- // Roll back: put the original back so a failed swap is not a data loss.
862
- ' mv "$ARCH_SWAP" "$ARCH_T"',
863
- ' fi',
864
- ' rm -rf "$ARCH_STAGE"',
865
- ` echo "${MARK.ok}=0"`,
866
- ` echo "${MARK.error}=could not move the restored tree into place; original restored"`,
867
- ' exit 1',
868
- 'fi',
869
- 'if [ "$ARCH_HAD" = "1" ]',
870
- 'then',
871
- ' rm -rf "$ARCH_SWAP"',
872
- 'fi',
873
- `echo "${MARK.action}=replaced"`,
874
- )
875
- } else {
876
- lines.push(
877
- 'mkdir -p "$ARCH_T"',
878
- 'if tar -xzf "$ARCH_F" -C "$ARCH_T" --strip-components=1 2>&1',
879
- 'then',
880
- ' :',
881
- 'else',
882
- ` echo "${MARK.ok}=0"`,
883
- ` echo "${MARK.error}=extraction failed (target may be partially updated — use replace mode for an atomic restore)"`,
884
- ' exit 1',
885
- 'fi',
886
- `echo "${MARK.action}=merged"`,
887
- )
888
- }
889
- lines.push(
890
- 'ARCH_FILES=$(find "$ARCH_T" -type f 2>/dev/null | wc -l | tr -d " ")',
891
- `echo "${MARK.members}=$ARCH_FILES"`,
892
- `echo "${MARK.ok}=1"`,
893
- )
894
- return lines.join('\n')
895
- }
896
-
897
- /** Parse the shared ok/error tail of a script's output. */
898
- export function parseStatus(stdout) {
899
- const m = readMarkers(stdout, [MARK.ok, MARK.error, MARK.verify, MARK.action, MARK.bytes, MARK.members, MARK.sha, MARK.exists])
900
- return {
901
- ok: m[MARK.ok] === '1',
902
- error: m[MARK.error] || '',
903
- verified: m[MARK.verify] === 'ok',
904
- action: m[MARK.action] || '',
905
- bytes: Number(m[MARK.bytes]) || 0,
906
- members: Number(m[MARK.members]) || 0,
907
- sha256: m[MARK.sha] || '',
908
- exists: m[MARK.exists] === '1',
909
- }
910
- }
911
-
912
- /** Every `ARCH_ENTRY=` value from a restore-all run (one per relocated workspace). */
913
- export function parseRestoredEntries(stdout) {
914
- const out = []
915
- const re = new RegExp(`^${MARK.entry}=(.*)$`, 'gm')
916
- let m
917
- while ((m = re.exec(String(stdout ?? ''))) !== null) {
918
- const v = m[1].trim()
919
- if (v) out.push(v)
920
- }
921
- return out
922
- }
923
-
924
- /**
925
- * The default exclude set for an "all workspaces" archive.
926
- *
927
- * Deliberately narrow and predictable: dependency/build trees that are huge,
928
- * reproducible from the manifest, and the main reason a naive full backup is
929
- * unusably large. Anything NOT listed here is archived — silently dropping more
930
- * than this is how a restore turns into data loss.
931
- *
932
- * Both the bare and the star-prefixed form of each directory pattern are emitted
933
- * because GNU tar anchors a pattern that contains a slash: the bare form covers
934
- * the top level and the star-slash form covers every nested occurrence
935
- * (measured, see the header).
936
- */
937
- /** Directory names whose dependency/build content is excluded from an "all
938
- * workspaces" archive. Kept as bare names so the star-prefixed variants can be
939
- * DERIVED instead of typed — a hand-typed `*​/` once carried a zero-width space
940
- * that made the pattern unusable, and a derivation cannot do that. */
941
- export const ALL_BACKUP_EXCLUDE_DIRS = [
942
- 'node_modules', '.git', 'dist', 'build', 'target',
943
- '__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache',
944
- ]
945
-
946
- /** Expand bare directory names into the tar patterns actually passed.
947
- *
948
- * Both forms are needed: GNU tar anchors a pattern containing a slash, so the
949
- * bare name covers the top level while the star-prefixed one covers every nested
950
- * occurrence (measured on GNU tar 1.35 — see the module header). */
951
- export function expandExcludeDirs(dirs = ALL_BACKUP_EXCLUDE_DIRS) {
952
- const out = []
953
- for (const d of dirs) {
954
- const name = String(d || '').trim()
955
- if (!name) continue
956
- out.push(name)
957
- out.push(`*/${name}`)
958
- }
959
- return out
960
- }
961
-
962
- /** The default exclude set for an "all workspaces" archive.
963
- *
964
- * Deliberately narrow and predictable: dependency/build trees that are huge,
965
- * reproducible from the manifest, and the main reason a naive full backup is
966
- * unusably large. Anything NOT listed here is archived — silently dropping more
967
- * than this is how a restore turns into data loss. */
968
- export const ALL_BACKUP_EXCLUDES = expandExcludeDirs()
969
-
970
- /**
971
- * Reject an exclude that would escape the workspace. Kept separate so the tool
972
- * can return a precise, actionable message instead of a generic failure, and so
973
- * the same rule is applied on the UI route and the model tool.
974
- * @returns {string} '' when acceptable, else the reason.
975
- */
976
- export function excludeRejection(patterns = []) {
977
- for (const p of patterns) {
978
- if (isUnsafeExclude(p)) {
979
- return `exclude pattern ${JSON.stringify(p)} escapes the workspace — backup excludes must be relative paths inside it`
980
- }
981
- }
982
- return ''
983
- }
984
-
985
- /** Human-readable byte size for tool/UI text (1024-based, one decimal). */
986
- export function formatBytes(n) {
987
- const v = Number(n) || 0
988
- if (v < 1024) return `${v} B`
989
- const units = ['KB', 'MB', 'GB', 'TB']
990
- let x = v / 1024
991
- let i = 0
992
- while (x >= 1024 && i < units.length - 1) { x /= 1024; i++ }
993
- return `${x.toFixed(x < 10 ? 1 : 0)} ${units[i]}`
994
- }
995
-
996
- /**
997
- * The relative path of a backup directory inside an archived workspace, or ''
998
- * when it sits outside (the default). Used to guarantee an archive never
999
- * contains a previous backup of itself, which would grow it every run.
1000
- */
1001
- export function backupDirUnderRoot(backupDir, root) {
1002
- if (!backupDir || !root) return ''
1003
- const rel = relPathUnder(root, backupDir)
1004
- return rel === null || rel === '' ? '' : rel.replace(/^\/+/, '')
1005
- }