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/README.en.md +2 -6
- package/README.md +2 -6
- package/lib/client.js +130 -191
- package/lib/deploy-tasks.js +142 -0
- package/lib/index.js +111 -337
- package/lib/paths.js +10 -2
- package/lib/web-attach.js +12 -0
- package/package.json +2 -2
- package/lib/archive.js +0 -1005
- package/lib/backup.js +0 -711
- package/lib/routes-backup.js +0 -283
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
|
-
}
|