parrot-blackbox 2.0.3 → 2.0.5
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.md +14 -0
- package/package.json +1 -1
- package/src/backup/btrfs-send.js +84 -18
- package/src/backup/restore.js +140 -12
- package/src/backup/snapshot.js +169 -70
- package/src/cli.js +1 -1
- package/src/storage/archive.js +15 -1
package/README.md
CHANGED
|
@@ -86,6 +86,20 @@ rclone cat cloud → [decrypt] → zstd decompress → btrfs receive → Timeshi
|
|
|
86
86
|
- Applies incrementals automatically
|
|
87
87
|
- Reconstructs the exact filesystem byte-for-byte
|
|
88
88
|
|
|
89
|
+
> **Timeshift layout on BTRFS:** Timeshift (BTRFS mode) stores every snapshot
|
|
90
|
+
> as a *directory* `<snapshots>/<name>/` that contains a read-only `@`
|
|
91
|
+
> subvolume. parrot-blackbox automatically resolves that inner `@` before
|
|
92
|
+
> running `btrfs send` (so uploads never hit the "not a subvolume" error), and
|
|
93
|
+
> on restore it receives each stream back into `<name>/@` plus the `info.json`
|
|
94
|
+
> control file — so `timeshift --restore --snapshot <id>` recognizes the
|
|
95
|
+
> restored backup immediately. If a snapshot is not a subvolume at all
|
|
96
|
+
> (Timeshift rsync mode), the tool falls back to the legacy file-copy method.
|
|
97
|
+
>
|
|
98
|
+
> **No phantom backups:** a failed `btrfs send` (for example a wrong path) used
|
|
99
|
+
> to leave a few-byte "stream" and manifest in the cloud that could corrupt the
|
|
100
|
+
> incremental chain. Only real streams (≥ 1 MiB) are now recorded, and any
|
|
101
|
+
> interrupted upload is removed from the cloud automatically.
|
|
102
|
+
|
|
89
103
|
### Why BTRFS send/receive is better
|
|
90
104
|
|
|
91
105
|
| Old way (v1.x) | New way (v2.0) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parrot-blackbox",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
4
4
|
"description": "Crash-proof, multi-cloud backup & recovery automation for Parrot OS. Daily/weekly off-disk backups with automatic catch-up, smart storage across many MEGA + Google Drive accounts, Timeshift snapshot backups and one-command restore.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
package/src/backup/btrfs-send.js
CHANGED
|
@@ -18,7 +18,16 @@ import path from 'node:path';
|
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
19
|
import { execa, execaSync } from 'execa';
|
|
20
20
|
import { hasCommandSync } from '../core/store.js';
|
|
21
|
-
import { sudoExec, sudoExecSync } from '../util/sudo.js';
|
|
21
|
+
import { sudoExec, sudoExecSync, sudoNonInteractive } from '../util/sudo.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Minimum size (bytes) a stored BTRFS stream must have to be considered a
|
|
25
|
+
* real backup. Failed sends still splice a few dozen bytes through the pipe
|
|
26
|
+
* before exiting 1, and those phantom streams must never be used as an
|
|
27
|
+
* incremental parent or offered for restore. A real system snapshot stream
|
|
28
|
+
* is orders of magnitude larger than 1 MiB, so this threshold is safe.
|
|
29
|
+
*/
|
|
30
|
+
export const MIN_VALID_STREAM_BYTES = 1024 * 1024;
|
|
22
31
|
|
|
23
32
|
/**
|
|
24
33
|
* Check if BTRFS tools are available on the system.
|
|
@@ -27,6 +36,18 @@ export function hasBtrfs() {
|
|
|
27
36
|
return hasCommandSync('btrfs');
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Validate a schema-2 snapshot manifest: does it describe a real BTRFS stream
|
|
41
|
+
* big enough to be an actual backup (not a failed few-byte send)?
|
|
42
|
+
* @param {object} manifest
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isValidBtrfsStreamManifest(manifest) {
|
|
46
|
+
if (!manifest || manifest.schema !== 2) return false;
|
|
47
|
+
const entry = (manifest.entries || []).find((e) => e.rel === 'btrfs.stream');
|
|
48
|
+
return Boolean(entry && entry.size >= MIN_VALID_STREAM_BYTES && Array.isArray(entry.loc) && entry.loc.length > 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
/**
|
|
31
52
|
* Check if a given path is on a BTRFS filesystem.
|
|
32
53
|
* @param {string} dirPath - Path to check (e.g., '/' for root filesystem)
|
|
@@ -60,13 +81,17 @@ export async function getBtrfsDevice(mountPoint = '/') {
|
|
|
60
81
|
|
|
61
82
|
/**
|
|
62
83
|
* Check if a path is actually a BTRFS subvolume (not just a directory on BTRFS).
|
|
63
|
-
* @param {string}
|
|
84
|
+
* @param {string} subvolPath
|
|
85
|
+
* @param {object} opts - {privileged: 'interactive'|'noninteractive'}
|
|
64
86
|
* @returns {Promise<boolean>}
|
|
65
87
|
*/
|
|
66
|
-
export async function isSubvolume(
|
|
88
|
+
export async function isSubvolume(subvolPath, { privileged = 'noninteractive' } = {}) {
|
|
89
|
+
if (!subvolPath || !fs.existsSync(subvolPath)) return false;
|
|
67
90
|
try {
|
|
68
|
-
|
|
69
|
-
const res =
|
|
91
|
+
const args = ['btrfs', 'subvolume', 'show', subvolPath];
|
|
92
|
+
const res = process.env.PBB_SUDO_DIRECT === '1'
|
|
93
|
+
? await execa('btrfs', ['subvolume', 'show', subvolPath], { reject: false })
|
|
94
|
+
: await sudoNonInteractive(args);
|
|
70
95
|
return res.exitCode === 0;
|
|
71
96
|
} catch {
|
|
72
97
|
return false;
|
|
@@ -98,28 +123,64 @@ export async function setSubvolumeReadOnly(subvolPath, readOnly = true, { privil
|
|
|
98
123
|
/**
|
|
99
124
|
* Check if a subvolume is read-only.
|
|
100
125
|
* @param {string} subvolPath
|
|
126
|
+
* @param {object} opts - {privileged: 'interactive'|'noninteractive'}
|
|
101
127
|
* @returns {Promise<boolean>}
|
|
102
128
|
*/
|
|
103
|
-
export async function isSubvolumeReadOnly(subvolPath) {
|
|
129
|
+
export async function isSubvolumeReadOnly(subvolPath, { privileged = 'noninteractive' } = {}) {
|
|
104
130
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
if (process.env.PBB_SUDO_DIRECT === '1') {
|
|
132
|
+
const res = await execa('btrfs', ['property', 'get', '-ts', subvolPath, 'ro'], { reject: false });
|
|
133
|
+
return res.exitCode === 0 && res.stdout.includes('ro=true');
|
|
134
|
+
}
|
|
135
|
+
const res = await sudoNonInteractive(['btrfs', 'property', 'get', '-ts', subvolPath, 'ro']);
|
|
136
|
+
return res.exitCode === 0 && res.stdout.includes('ro=true');
|
|
108
137
|
} catch {
|
|
109
138
|
return false;
|
|
110
139
|
}
|
|
111
140
|
}
|
|
112
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Resolve the actual BTRFS subvolume inside a Timeshift snapshot directory.
|
|
144
|
+
*
|
|
145
|
+
* Timeshift (BTRFS mode) stores every snapshot as:
|
|
146
|
+
* <snapshots>/<name>/@ <- read-only snapshot subvolume of "@"
|
|
147
|
+
* <snapshots>/<name>/info.json <- control file
|
|
148
|
+
*
|
|
149
|
+
* Running `btrfs send` on the *container directory* fails with
|
|
150
|
+
* "failed to get flags for subvolume ... Invalid argument" — the directory is
|
|
151
|
+
* NOT a subvolume, only the inner "@" is. Some older/exotic layouts put the
|
|
152
|
+
* snapshot content directly in a subvolume at the container path itself, so
|
|
153
|
+
* both shapes are resolved here.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} snapDir - Snapshot directory (container) already on disk
|
|
156
|
+
* @param {object} opts - {privileged}
|
|
157
|
+
* @returns {Promise<string|null>} - The subvolume path to send, or null when
|
|
158
|
+
* the snapshot is not backed by a subvolume at all (e.g. Timeshift rsync mode).
|
|
159
|
+
*/
|
|
160
|
+
export async function findSnapshotSubvolume(snapDir, { privileged = 'noninteractive' } = {}) {
|
|
161
|
+
if (!snapDir || !fs.existsSync(snapDir)) return null;
|
|
162
|
+
if (await isSubvolume(snapDir, { privileged })) return snapDir;
|
|
163
|
+
const nested = path.join(snapDir, '@');
|
|
164
|
+
if (fs.existsSync(nested) && (await isSubvolume(nested, { privileged }))) return nested;
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
113
168
|
/**
|
|
114
169
|
* Find the most recent successfully uploaded snapshot that can serve as a parent.
|
|
115
170
|
* Looks for manifest files in the local manifests directory.
|
|
171
|
+
*
|
|
172
|
+
* A manifest is only trusted when it describes a REAL BTRFS stream: the send
|
|
173
|
+
* must have completed and uploaded a stream at least MIN_VALID_STREAM_BYTES
|
|
174
|
+
* in size. Failed sends (e.g. "not a subvolume") leave behind byte-sized
|
|
175
|
+
* phantom manifests that would otherwise corrupt the incremental chain.
|
|
176
|
+
*
|
|
116
177
|
* @param {string} manifestsDir - Path to the manifests directory
|
|
117
178
|
* @param {Array<{name:string}>} localSnapshots - List of local snapshots from timeshift
|
|
118
179
|
* @returns {string|null} - The snapshot name to use as parent, or null for full send
|
|
119
180
|
*/
|
|
120
181
|
export function findLastUploadedSnapshot(manifestsDir, localSnapshots) {
|
|
121
182
|
if (!fs.existsSync(manifestsDir)) return null;
|
|
122
|
-
|
|
183
|
+
|
|
123
184
|
const manifestFiles = fs.readdirSync(manifestsDir)
|
|
124
185
|
.filter(f => f.startsWith('snapshots-') && f.endsWith('.json'))
|
|
125
186
|
.map(f => {
|
|
@@ -129,14 +190,19 @@ export function findLastUploadedSnapshot(manifestsDir, localSnapshots) {
|
|
|
129
190
|
// Sort by name (which is timestamp-based) descending
|
|
130
191
|
.sort((a, b) => b.name.localeCompare(a.name));
|
|
131
192
|
|
|
132
|
-
// Find the most recent manifest whose snapshot still exists locally
|
|
133
|
-
for (const { name } of manifestFiles) {
|
|
193
|
+
// Find the most recent valid manifest whose snapshot still exists locally
|
|
194
|
+
for (const { name, file } of manifestFiles) {
|
|
134
195
|
const existsLocally = localSnapshots.some(s => s.name === name);
|
|
135
|
-
if (existsLocally)
|
|
136
|
-
|
|
196
|
+
if (!existsLocally) continue;
|
|
197
|
+
try {
|
|
198
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(manifestsDir, file), 'utf8'));
|
|
199
|
+
if (!isValidBtrfsStreamManifest(manifest)) continue;
|
|
200
|
+
} catch {
|
|
201
|
+
continue;
|
|
137
202
|
}
|
|
203
|
+
return name;
|
|
138
204
|
}
|
|
139
|
-
|
|
205
|
+
|
|
140
206
|
return null;
|
|
141
207
|
}
|
|
142
208
|
|
|
@@ -172,9 +238,9 @@ export async function createSendStream(subvolPath, { parent = null, privileged =
|
|
|
172
238
|
|
|
173
239
|
// Spawn via sudo, return the stdout stream and the child process so callers
|
|
174
240
|
// can detect errors (btrfs send exits non-zero when the path is not a subvolume).
|
|
175
|
-
const sudoArgs =
|
|
176
|
-
?
|
|
177
|
-
: ['sudo', '-n', ...args];
|
|
241
|
+
const sudoArgs = process.env.PBB_SUDO_DIRECT === '1'
|
|
242
|
+
? args
|
|
243
|
+
: (privileged === 'interactive' ? ['sudo', '-E', ...args] : ['sudo', '-n', ...args]);
|
|
178
244
|
|
|
179
245
|
const child = spawn(sudoArgs[0], sudoArgs.slice(1), {
|
|
180
246
|
stdio: ['ignore', 'pipe', 'pipe'],
|
package/src/backup/restore.js
CHANGED
|
@@ -69,6 +69,10 @@ export async function restoreSnapshot({ id, accounts, cfg, toDir, confirm = fals
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Let Timeshift mount its own repo — our temporary subvolid=5 mount is no
|
|
73
|
+
// longer needed and must not linger.
|
|
74
|
+
await cleanupRestoreMount();
|
|
75
|
+
|
|
72
76
|
// Run the actual restore (interactive sudo → the password prompt is visible).
|
|
73
77
|
console.log(`\n🔄 Restoring snapshot ${id} over the current system…\n`);
|
|
74
78
|
await ensureSudo();
|
|
@@ -150,11 +154,12 @@ async function restoreBtrfsStream({ manifest, snapId, cfg, privileged, onProgres
|
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
const btrfsCfg = cfg.jobs.snapshots.btrfs || {};
|
|
153
|
-
const base =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
await
|
|
157
|
+
const { path: base } = await resolveTimeshiftSnapshotRepo({ privileged });
|
|
158
|
+
const snapDir = path.join(base, snapId);
|
|
159
|
+
|
|
160
|
+
// Ensure the per-snapshot container directory exists (root-owned on real setups).
|
|
161
|
+
const mk = await runPrivileged(['mkdir', '-p', snapDir], privileged);
|
|
162
|
+
if (mk.exitCode !== 0) throw new Error(`could not create snapshot dir ${snapDir} (exit ${mk.exitCode})`);
|
|
158
163
|
|
|
159
164
|
// Build reverse pipeline
|
|
160
165
|
const { spawn } = await import('node:child_process');
|
|
@@ -220,12 +225,13 @@ async function restoreBtrfsStream({ manifest, snapId, cfg, privileged, onProgres
|
|
|
220
225
|
currentStream = zstd.stdout;
|
|
221
226
|
}
|
|
222
227
|
|
|
223
|
-
// Stage 4: BTRFS receive
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
228
|
+
// Stage 4: BTRFS receive — into the per-snapshot container dir so the
|
|
229
|
+
// received subvolume lands at <snapDir>/@ (Timeshift's BTRFS layout).
|
|
230
|
+
console.log(` 💾 Receiving into ${snapDir}...`);
|
|
231
|
+
const recvArgs = process.env.PBB_SUDO_DIRECT === '1'
|
|
232
|
+
? ['btrfs', 'receive', snapDir]
|
|
233
|
+
: ((privileged === 'interactive' ? ['sudo', '-E', 'btrfs', 'receive', snapDir] : ['sudo', '-n', 'btrfs', 'receive', snapDir]));
|
|
234
|
+
const receive = spawn(recvArgs[0], recvArgs.slice(1), {
|
|
229
235
|
stdio: ['pipe', 'pipe', 'inherit'],
|
|
230
236
|
});
|
|
231
237
|
currentStream.pipe(receive.stdin);
|
|
@@ -240,10 +246,132 @@ async function restoreBtrfsStream({ manifest, snapId, cfg, privileged, onProgres
|
|
|
240
246
|
});
|
|
241
247
|
last.on('error', reject);
|
|
242
248
|
});
|
|
243
|
-
|
|
249
|
+
|
|
250
|
+
await registerSnapshotWithTimeshift({ snapDir, snapId, privileged });
|
|
244
251
|
journal('restore', `btrfs receive completed for ${snapId}`);
|
|
245
252
|
}
|
|
246
253
|
|
|
254
|
+
/* ------------------------------------------------------------------ */
|
|
255
|
+
/* Timeshift BTRFS repository resolution + snapshot registration */
|
|
256
|
+
/* ------------------------------------------------------------------ */
|
|
257
|
+
|
|
258
|
+
let _restoreMountPoint = null;
|
|
259
|
+
|
|
260
|
+
/** Run a privileged command honoring the interactive/non-interactive mode. */
|
|
261
|
+
async function runPrivileged(args, privileged) {
|
|
262
|
+
const { sudoExec } = await import('../util/sudo.js');
|
|
263
|
+
return sudoExec(args, { interactive: privileged === 'interactive' });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Resolve where Timeshift stores BTRFS snapshots on THIS machine.
|
|
268
|
+
*
|
|
269
|
+
* On a classic BTRFS install the snapshot repo lives at
|
|
270
|
+
* `<root-subvolume>/timeshift-btrfs/snapshots`, reachable only after mounting
|
|
271
|
+
* the top-level subvolume (subvolid=5) — the same shape snapshotDirFor() uses
|
|
272
|
+
* on the upload side. Static paths cover rsync-mode and sandbox setups.
|
|
273
|
+
*
|
|
274
|
+
* @returns {Promise<{path: string, unmount: string|null}>}
|
|
275
|
+
*/
|
|
276
|
+
async function resolveTimeshiftSnapshotRepo({ privileged }) {
|
|
277
|
+
const staticPaths = [
|
|
278
|
+
path.join(timeshiftDir(), 'snapshots'), // rsync mode & sandbox stub
|
|
279
|
+
path.join(timeshiftDir(), 'timeshift-btrfs', 'snapshots'),
|
|
280
|
+
'/run/timeshift/backup/timeshift-btrfs/snapshots',
|
|
281
|
+
];
|
|
282
|
+
for (const p of staticPaths) {
|
|
283
|
+
if (fs.existsSync(p)) return { path: p, unmount: null };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// BTRFS mode: mount the top-level subvolume where Timeshift keeps repos.
|
|
287
|
+
if (!_restoreMountPoint) {
|
|
288
|
+
const mountPoint = `/run/parrot-blackbox-restore-${Date.now()}`;
|
|
289
|
+
const { execaSync } = await import('execa');
|
|
290
|
+
const findmnt = execaSync('findmnt', ['-n', '-o', 'SOURCE', '/'], { reject: false });
|
|
291
|
+
const device = findmnt.stdout?.trim().split('[')[0];
|
|
292
|
+
if (device && findmnt.exitCode === 0) {
|
|
293
|
+
const mk = await runPrivileged(['mkdir', '-p', mountPoint], privileged);
|
|
294
|
+
if (mk.exitCode === 0) {
|
|
295
|
+
const mnt = await runPrivileged(['mount', '-o', 'subvolid=5', device, mountPoint], privileged);
|
|
296
|
+
if (mnt.exitCode === 0) {
|
|
297
|
+
const repo = path.join(mountPoint, 'timeshift-btrfs', 'snapshots');
|
|
298
|
+
if (fs.existsSync(repo)) {
|
|
299
|
+
_restoreMountPoint = mountPoint;
|
|
300
|
+
return { path: repo, unmount: mountPoint };
|
|
301
|
+
}
|
|
302
|
+
await runPrivileged(['umount', mountPoint], privileged).catch(() => null);
|
|
303
|
+
}
|
|
304
|
+
await runPrivileged(['rmdir', mountPoint], privileged).catch(() => null);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
const repo = path.join(_restoreMountPoint, 'timeshift-btrfs', 'snapshots');
|
|
309
|
+
if (fs.existsSync(repo)) return { path: repo, unmount: null };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Last resort — will produce a clear failure if it's not a real repo path.
|
|
313
|
+
return { path: path.join(timeshiftDir(), 'snapshots'), unmount: null };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Unmount the temporary subvolid=5 mount (best effort), when one was made. */
|
|
317
|
+
export async function cleanupRestoreMount() {
|
|
318
|
+
const mountPoint = _restoreMountPoint;
|
|
319
|
+
_restoreMountPoint = null;
|
|
320
|
+
if (!mountPoint) return;
|
|
321
|
+
await runPrivileged(['umount', mountPoint], 'interactive').catch(() => null);
|
|
322
|
+
await runPrivileged(['rmdir', mountPoint], 'interactive').catch(() => null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Parse a Timeshift snapshot name "2026-09-03_12-08-44" into local epoch seconds. */
|
|
326
|
+
export function snapshotEpochFromName(name) {
|
|
327
|
+
const m = /^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})$/.exec(String(name || '').trim());
|
|
328
|
+
if (m) {
|
|
329
|
+
const t = new Date(`${m[1].replace(/-/g, '/')} ${m[2].replace(/-/g, ':')}`);
|
|
330
|
+
if (!Number.isNaN(t.getTime())) return Math.floor(t.getTime() / 1000);
|
|
331
|
+
}
|
|
332
|
+
return Math.floor(Date.now() / 1000);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Register a freshly received BTRFS subvolume with Timeshift so
|
|
337
|
+
* `timeshift --list` / `timeshift --restore --snapshot <id>` recognize it.
|
|
338
|
+
*
|
|
339
|
+
* Timeshift validates a BTRFS snapshot by the control file `info.json` inside
|
|
340
|
+
* the container directory and the read-only "@" subvolume — exactly the shape
|
|
341
|
+
* `btrfs receive <snapDir>` produced.
|
|
342
|
+
*/
|
|
343
|
+
async function registerSnapshotWithTimeshift({ snapDir, snapId, privileged }) {
|
|
344
|
+
const receivedSubvol = path.join(snapDir, '@');
|
|
345
|
+
if (!fs.existsSync(receivedSubvol)) {
|
|
346
|
+
throw new Error(`btrfs receive did not create ${receivedSubvol} — stream name did not match "@"?`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const ctl = {
|
|
350
|
+
created: String(snapshotEpochFromName(snapId)),
|
|
351
|
+
'sys-uuid': '',
|
|
352
|
+
'sys-distro': '',
|
|
353
|
+
'app-version': '24.06.4',
|
|
354
|
+
file_count: '0',
|
|
355
|
+
tags: 'O',
|
|
356
|
+
comments: 'restored via parrot-blackbox',
|
|
357
|
+
live: 'false',
|
|
358
|
+
type: 'btrfs',
|
|
359
|
+
};
|
|
360
|
+
const ctlPath = path.join(snapDir, 'info.json');
|
|
361
|
+
const tmpCtl = path.join(stateDir(), `.ts-info-${snapId}-${process.pid}.json`);
|
|
362
|
+
fs.writeFileSync(tmpCtl, JSON.stringify(ctl, null, 2) + '\n');
|
|
363
|
+
try {
|
|
364
|
+
const cp = await runPrivileged(['cp', tmpCtl, ctlPath], privileged);
|
|
365
|
+
if (cp.exitCode !== 0) throw new Error(`could not write Timeshift control file (exit ${cp.exitCode})`);
|
|
366
|
+
await runPrivileged(['chown', 'root:root', ctlPath], privileged).catch(() => null);
|
|
367
|
+
// Timeshift BTR snapshots are read-only; receive produces writable subvols.
|
|
368
|
+
await runPrivileged(['btrfs', 'property', 'set', '-ts', receivedSubvol, 'ro', 'true'], privileged).catch(() => null);
|
|
369
|
+
console.log(` ✅ Registered snapshot ${snapId} with Timeshift`);
|
|
370
|
+
} finally {
|
|
371
|
+
try { fs.rmSync(tmpCtl, { force: true }); } catch { /* best effort */ }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
247
375
|
/** Move a downloaded snapshot tree into the Timeshift snapshot folder (root-owned). */
|
|
248
376
|
async function placeIntoTimeshift(id, tmpRoot, cfg, privileged, manifest) {
|
|
249
377
|
const base = determineSnapshotBase();
|
package/src/backup/snapshot.js
CHANGED
|
@@ -158,6 +158,8 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
158
158
|
if (privileged === 'interactive') {
|
|
159
159
|
await ensureSudo();
|
|
160
160
|
const res = await sudoInteractive(args);
|
|
161
|
+
// Timeshift exits 0 even when the qgroup destroy fails, so we verify the
|
|
162
|
+
// snapshot is actually gone rather than trusting the exit code alone.
|
|
161
163
|
if (res.exitCode !== 0) throw new Error(`timeshift --delete ${name} failed (exit ${res.exitCode})`);
|
|
162
164
|
return true;
|
|
163
165
|
}
|
|
@@ -169,16 +171,32 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
169
171
|
return true;
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
/** Run btrfs quota rescan -w / and wait for it to finish. */
|
|
175
|
+
async function btrfsQuotaRescan({ privileged = 'interactive', onProgress } = {}) {
|
|
176
|
+
onProgress?.('Running btrfs quota rescan — this may take a moment…');
|
|
177
|
+
const res = privileged === 'interactive'
|
|
178
|
+
? await sudoInteractive(['btrfs', 'quota', 'rescan', '-w', '/'])
|
|
179
|
+
: await sudoNonInteractive(['btrfs', 'quota', 'rescan', '-w', '/']);
|
|
180
|
+
if (res.exitCode !== 0) {
|
|
181
|
+
onProgress?.(`⚠ btrfs quota rescan exited ${res.exitCode} — quotas may not be enabled, continuing`);
|
|
182
|
+
} else {
|
|
183
|
+
onProgress?.('✔ btrfs quota rescan complete');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
172
187
|
/**
|
|
173
188
|
* Delete ALL local Timeshift snapshots.
|
|
174
189
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
190
|
+
* Strategy (matches the confirmed-working pattern from research):
|
|
191
|
+
* 1. Run `btrfs quota rescan -w /` upfront.
|
|
192
|
+
* 2. For each snapshot: attempt delete, then verify it is GONE from
|
|
193
|
+
* `timeshift --list`. Timeshift exits 0 even when it prints
|
|
194
|
+
* "E: Failed to remove snapshot" (qgroup destroy fails silently).
|
|
195
|
+
* Verification catches that.
|
|
196
|
+
* 3. If a snapshot is still present after the first attempt:
|
|
197
|
+
* run another rescan (the delete itself may have left a new stale entry)
|
|
198
|
+
* and retry exactly once.
|
|
199
|
+
* 4. If it still persists after retry, record it as failed and move on.
|
|
182
200
|
*
|
|
183
201
|
* @param {object} opts
|
|
184
202
|
* @param {'interactive'|'noninteractive'} opts.privileged
|
|
@@ -188,40 +206,53 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
|
|
|
188
206
|
export async function deleteAllSnapshots({ privileged = 'interactive', onProgress } = {}) {
|
|
189
207
|
if (privileged === 'interactive') await ensureSudo();
|
|
190
208
|
|
|
191
|
-
// Step 1: rescan
|
|
192
|
-
|
|
193
|
-
const rescanRes = privileged === 'interactive'
|
|
194
|
-
? await sudoInteractive(['btrfs', 'quota', 'rescan', '-w', '/'])
|
|
195
|
-
: await sudoNonInteractive(['btrfs', 'quota', 'rescan', '-w', '/']);
|
|
196
|
-
|
|
197
|
-
// A non-zero exit here is not fatal — it just means quotas may not be enabled
|
|
198
|
-
// (e.g. rsync-mode Timeshift). Log and continue.
|
|
199
|
-
if (rescanRes.exitCode !== 0) {
|
|
200
|
-
onProgress?.(`⚠ btrfs quota rescan exited ${rescanRes.exitCode} — continuing anyway`);
|
|
201
|
-
} else {
|
|
202
|
-
onProgress?.('✔ btrfs quota rescan complete');
|
|
203
|
-
}
|
|
209
|
+
// Step 1: initial rescan to clear stale qgroup entries.
|
|
210
|
+
await btrfsQuotaRescan({ privileged, onProgress });
|
|
204
211
|
|
|
205
|
-
// Step 2:
|
|
212
|
+
// Step 2: delete each snapshot, verifying it's actually gone.
|
|
206
213
|
const snapshots = await listLocalSnapshots({ privileged });
|
|
207
214
|
const deleted = [];
|
|
208
215
|
const failed = [];
|
|
209
216
|
|
|
210
217
|
for (const sn of snapshots) {
|
|
211
218
|
onProgress?.(`Deleting snapshot: ${sn.name}`);
|
|
212
|
-
|
|
213
|
-
|
|
219
|
+
let success = false;
|
|
220
|
+
|
|
221
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
222
|
+
try {
|
|
223
|
+
await deleteSnapshot(sn.name, { privileged });
|
|
224
|
+
} catch {
|
|
225
|
+
// Timeshift may have removed the subvolume but still exit non-zero.
|
|
226
|
+
// Fall through to the verify step.
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Verify the snapshot is actually gone from timeshift --list.
|
|
230
|
+
const remaining = await listLocalSnapshots({ privileged });
|
|
231
|
+
const stillPresent = remaining.some((s) => s.name === sn.name);
|
|
232
|
+
|
|
233
|
+
if (!stillPresent) {
|
|
234
|
+
success = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (attempt === 1) {
|
|
239
|
+
// The delete left a new stale qgroup entry — rescan and retry once.
|
|
240
|
+
onProgress?.(` ⚠ ${sn.name} still present after delete — rescanning qgroups and retrying…`);
|
|
241
|
+
await btrfsQuotaRescan({ privileged, onProgress });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (success) {
|
|
214
246
|
deleted.push(sn.name);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
onProgress?.(` ✖ ${sn.name}: ${e.message}`);
|
|
247
|
+
} else {
|
|
248
|
+
failed.push({ name: sn.name, error: 'still present after 2 attempts + qgroup rescan' });
|
|
249
|
+
onProgress?.(` ✖ ${sn.name}: could not delete after rescan — try deleting it individually`);
|
|
219
250
|
}
|
|
220
251
|
}
|
|
221
252
|
|
|
222
253
|
if (failed.length > 0) {
|
|
223
254
|
throw Object.assign(
|
|
224
|
-
new Error(`${failed.length} snapshot(s) could not be deleted
|
|
255
|
+
new Error(`${failed.length} snapshot(s) could not be deleted`),
|
|
225
256
|
{ deleted, failed },
|
|
226
257
|
);
|
|
227
258
|
}
|
|
@@ -300,7 +331,7 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
300
331
|
|
|
301
332
|
/** Cleanup temporary BTRFS mount if one was created */
|
|
302
333
|
export function cleanupSnapshotMount(snapshot) {
|
|
303
|
-
if (snapshot._tempMount) {
|
|
334
|
+
if (snapshot && snapshot._tempMount) {
|
|
304
335
|
try {
|
|
305
336
|
sudoExecSync(['umount', snapshot._tempMount]);
|
|
306
337
|
sudoExecSync(['rmdir', snapshot._tempMount]);
|
|
@@ -392,15 +423,41 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
392
423
|
const dir = snapshotDirFor(created, { privileged });
|
|
393
424
|
const parentDir = parentSnap ? snapshotDirFor(parentSnap, { privileged }) : null;
|
|
394
425
|
|
|
426
|
+
// Timeshift (BTRFS mode) stores each snapshot as a *directory* containing an
|
|
427
|
+
// "@" snapshot subvolume. The container directory itself is NOT a subvolume
|
|
428
|
+
// and `btrfs send` fails on it with "Invalid argument". Resolve the real
|
|
429
|
+
// subvolume here, and drop to file-copy mode when the snapshots genuinely
|
|
430
|
+
// aren't subvolumes (e.g. Timeshift rsync mode).
|
|
431
|
+
const { findSnapshotSubvolume } = await import('./btrfs-send.js');
|
|
432
|
+
let subvolPath = null;
|
|
433
|
+
let parentSubvolPath = null;
|
|
434
|
+
if (useBtrfs) {
|
|
435
|
+
subvolPath = await findSnapshotSubvolume(dir, { privileged });
|
|
436
|
+
if (!subvolPath) {
|
|
437
|
+
console.log('⚠ Timeshift snapshots are not BTRFS subvolumes (rsync mode?) — falling back to file-copy mode');
|
|
438
|
+
useBtrfs = false;
|
|
439
|
+
} else if (parentDir) {
|
|
440
|
+
parentSubvolPath = await findSnapshotSubvolume(parentDir, { privileged });
|
|
441
|
+
if (!parentSubvolPath) {
|
|
442
|
+
console.log('⚠ Parent snapshot has no BTRFS subvolume — running a full send instead');
|
|
443
|
+
parentSnap = null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// File-copy source: when the snapshot dir is a Timeshift BTRFS container
|
|
449
|
+
// (<dir>/@), walk the "@" contents so the actual system tree is uploaded.
|
|
450
|
+
const fileCopyDir = fs.existsSync(path.join(dir, '@')) ? path.join(dir, '@') : dir;
|
|
451
|
+
|
|
395
452
|
let manifest;
|
|
396
453
|
try {
|
|
397
454
|
if (useBtrfs) {
|
|
398
455
|
// V2 BTRFS send/receive path
|
|
399
456
|
manifest = await uploadViaBtrfsSend({
|
|
400
457
|
snapshot: created,
|
|
401
|
-
|
|
458
|
+
subvolPath,
|
|
402
459
|
parentSnapshot: parentSnap,
|
|
403
|
-
|
|
460
|
+
parentSubvolPath,
|
|
404
461
|
accounts,
|
|
405
462
|
cfg,
|
|
406
463
|
due,
|
|
@@ -411,8 +468,7 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
411
468
|
// Legacy file-copy fallback (v2 behavior)
|
|
412
469
|
manifest = await uploadViaFileCopy({
|
|
413
470
|
snapshot: created,
|
|
414
|
-
snapshotDir:
|
|
415
|
-
parentDir,
|
|
471
|
+
snapshotDir: fileCopyDir,
|
|
416
472
|
accounts,
|
|
417
473
|
cfg,
|
|
418
474
|
due,
|
|
@@ -423,10 +479,10 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
423
479
|
} catch (e) {
|
|
424
480
|
// The local snapshot exists and is safe; the cloud upload failed.
|
|
425
481
|
journal('snapshots', `upload failed for ${created.name}: ${e.message}`, 'error');
|
|
426
|
-
cleanupSnapshotMount(created);
|
|
427
482
|
throw e;
|
|
428
483
|
} finally {
|
|
429
484
|
cleanupSnapshotMount(created);
|
|
485
|
+
if (parentSnap) cleanupSnapshotMount(parentSnap);
|
|
430
486
|
}
|
|
431
487
|
|
|
432
488
|
manifest.due = due;
|
|
@@ -462,19 +518,15 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
462
518
|
* Upload a snapshot using BTRFS send/receive streaming.
|
|
463
519
|
* Creates: btrfs send [-p parent] | zstd | [openssl] | rclone rcat (chunked)
|
|
464
520
|
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
521
|
+
* `subvolPath` / `parentSubvolPath` are the actual BTRFS subvolumes (resolved by
|
|
522
|
+
* findSnapshotSubvolume() — Timeshift keeps snapshots at <snapshot-dir>/@), so
|
|
523
|
+
* `btrfs send` always receives a real subvolume, never a plain directory.
|
|
468
524
|
*/
|
|
469
|
-
async function uploadViaBtrfsSend({ snapshot,
|
|
470
|
-
const { createSendStream, estimateSendSize } = await import('./btrfs-send.js');
|
|
525
|
+
async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parentSubvolPath, accounts, cfg, due, privileged, onProgress }) {
|
|
526
|
+
const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest } = await import('./btrfs-send.js');
|
|
471
527
|
const { planAndPlaceStream } = await import('../storage/allocator.js');
|
|
472
528
|
|
|
473
|
-
|
|
474
|
-
// (e.g. /run/parrot-blackbox-btrfs-<ts>/timeshift-btrfs/snapshots/<name>).
|
|
475
|
-
// Use it directly; do NOT fall back to a hardcoded absolute path.
|
|
476
|
-
const subvolPath = snapshotDir;
|
|
477
|
-
const parentSubvolPath = parentDir || null;
|
|
529
|
+
if (!subvolPath) throw new Error('no BTRFS subvolume path for snapshot upload');
|
|
478
530
|
|
|
479
531
|
journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
|
|
480
532
|
|
|
@@ -531,34 +583,78 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
|
|
|
531
583
|
originalSize: estimatedSize,
|
|
532
584
|
});
|
|
533
585
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
));
|
|
586
|
+
/**
|
|
587
|
+
* Abort a failed/incomplete upload so no fragment stream or manifest is left
|
|
588
|
+
* behind — exactly the corruption that previously let 13-byte phantom
|
|
589
|
+
* streams appear in the cloud and break the incremental chain.
|
|
590
|
+
*/
|
|
591
|
+
const abortGhostUpload = async () => {
|
|
592
|
+
for (const proc of pipeline) { try { proc.kill('SIGKILL'); } catch { /* already gone */ } }
|
|
593
|
+
try { currentStream.destroy(); } catch { /* best effort */ }
|
|
594
|
+
// Let the concurrent uploader settle (bounded) so it can't write a manifest after we return.
|
|
595
|
+
await Promise.race([
|
|
596
|
+
manifestPromise.catch(() => {}),
|
|
597
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
598
|
+
]);
|
|
599
|
+
const { removeArtifact } = await import('../storage/archive.js');
|
|
600
|
+
try { await removeArtifact('snapshots', snapshot.name, accounts, cfg.storage.remoteRoot); } catch { /* best effort */ }
|
|
601
|
+
try { fs.rmSync(path.join(manifestsDir(), `snapshots-${snapshot.name}.json`), { force: true }); } catch { /* best effort */ }
|
|
602
|
+
};
|
|
552
603
|
|
|
553
|
-
|
|
604
|
+
try {
|
|
605
|
+
// Wait for all pipeline stages (including btrfs send itself) to exit cleanly
|
|
606
|
+
await Promise.all(pipeline.map((proc) =>
|
|
607
|
+
new Promise((resolve, reject) => {
|
|
608
|
+
proc.on('close', (code) => {
|
|
609
|
+
if (code === 0 || code === null) {
|
|
610
|
+
resolve();
|
|
611
|
+
} else {
|
|
612
|
+
const stderr = Buffer.concat(sendStderr).toString().trim();
|
|
613
|
+
reject(new Error(
|
|
614
|
+
proc === sendChild
|
|
615
|
+
? `btrfs send failed (exit ${code})${stderr ? ': ' + stderr : ''} — is ${subvolPath} a BTRFS subvolume?`
|
|
616
|
+
: `Pipeline stage failed (exit ${code})`
|
|
617
|
+
));
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
proc.on('error', reject);
|
|
621
|
+
})
|
|
622
|
+
));
|
|
623
|
+
|
|
624
|
+
const manifest = await manifestPromise;
|
|
625
|
+
|
|
626
|
+
// A real system stream is orders of magnitude larger than 1 MiB. A tiny
|
|
627
|
+
// stream means the send failed and must never be recorded as a backup.
|
|
628
|
+
if (!isValidBtrfsStreamManifest(manifest)) {
|
|
629
|
+
await abortGhostUpload();
|
|
630
|
+
throw new Error(`btrfs send produced an undersized stream (${manifest?.totalSize ?? 0} bytes) — aborting`);
|
|
631
|
+
}
|
|
554
632
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
633
|
+
// Enrich the manifest with the parent-chain info BEFORE persisting it, and
|
|
634
|
+
// push the enriched copy back to the cloud so a restore from a wiped
|
|
635
|
+
// machine can still resolve the incremental chain.
|
|
636
|
+
manifest.snapshot = snapshot.name;
|
|
637
|
+
manifest.parent = parentSnapshot?.name || null;
|
|
638
|
+
|
|
639
|
+
// Save manifest locally
|
|
640
|
+
const manifestPath = path.join(manifestsDir(), `snapshots-${snapshot.name}.json`);
|
|
641
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
642
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
643
|
+
|
|
644
|
+
// Re-upload the enriched manifest (now carrying the parent link) to the cloud.
|
|
645
|
+
if (manifest.account) {
|
|
646
|
+
const { copyToFile } = await import('../storage/rclone.js');
|
|
647
|
+
const remotePath = `${manifest.account}:${manifest.remoteRoot}/snapshots/${snapshot.name}/__MANIFEST__.json`;
|
|
648
|
+
const res = await copyToFile(manifestPath, remotePath);
|
|
649
|
+
if (!res.ok) journal('snapshots', `could not refresh cloud manifest for ${snapshot.name}`, 'warn');
|
|
650
|
+
}
|
|
559
651
|
|
|
560
|
-
|
|
561
|
-
|
|
652
|
+
console.log(`\n✓ Uploaded ${(manifest.totalSize / (1024 ** 3)).toFixed(2)} GiB to cloud`);
|
|
653
|
+
return manifest;
|
|
654
|
+
} catch (err) {
|
|
655
|
+
await abortGhostUpload();
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
562
658
|
}
|
|
563
659
|
|
|
564
660
|
/**
|
|
@@ -573,7 +669,10 @@ async function uploadViaFileCopy({ snapshot, snapshotDir, parentDir, accounts, c
|
|
|
573
669
|
HOME: process.env.HOME,
|
|
574
670
|
PBB_STATE_DIR: stateDir(),
|
|
575
671
|
PBB_CONFIG_FILE: configFile(),
|
|
576
|
-
|
|
672
|
+
// The subprocess must never re-enable BTRFS send — file-copy mode was
|
|
673
|
+
// chosen because the snapshots are NOT subvolumes (rsync mode, or tools
|
|
674
|
+
// missing). Re-detection in the child would resurrect the failing send.
|
|
675
|
+
PBB_DISABLE_BTRFS: '1',
|
|
577
676
|
};
|
|
578
677
|
const cmdArgs = [process.execPath, bin, '_internal_upload', snapshotDir, 'snapshots', snapshot.name, cfg.storage.remoteRoot, String(cfg.storage.chunkSize), outPath];
|
|
579
678
|
const args = process.env.PBB_SUDO_DIRECT === '1' ? cmdArgs : ['-E', ...cmdArgs];
|
package/src/cli.js
CHANGED
|
@@ -421,7 +421,7 @@ const main = defineCommand({
|
|
|
421
421
|
if (sub === 'prune') {
|
|
422
422
|
const cfg = loadConfig();
|
|
423
423
|
try {
|
|
424
|
-
const pruned = await pruneSnapshots(cfg,
|
|
424
|
+
const pruned = await pruneSnapshots(cfg, listAccounts(), { privileged: 'interactive' });
|
|
425
425
|
if (pruned.length) console.log(pc.green(`✔ Pruned: ${pruned.join(', ')} (local + cloud).`));
|
|
426
426
|
else console.log(pc.dim('Nothing to prune.'));
|
|
427
427
|
} catch (e) {
|
package/src/storage/archive.js
CHANGED
|
@@ -14,9 +14,20 @@ import { createWriteStream } from 'node:fs';
|
|
|
14
14
|
import { execa } from 'execa';
|
|
15
15
|
import { catRemote, lsjson, purge, copyToFile, downloadBatch } from './rclone.js';
|
|
16
16
|
import { MANIFEST_NAME } from './allocator.js';
|
|
17
|
+
import { isValidBtrfsStreamManifest } from '../backup/btrfs-send.js';
|
|
17
18
|
|
|
18
19
|
export { MANIFEST_NAME };
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A schema-2 snapshot manifest whose stream never made it (failed `btrfs send`
|
|
23
|
+
* leaves a dozens-of-bytes phantom) is garbage: it must not be listed, offered
|
|
24
|
+
* for restore, or used as an incremental parent. Schema-1 (legacy file-tree)
|
|
25
|
+
* backups are always kept.
|
|
26
|
+
*/
|
|
27
|
+
function isPhantom(kind, manifest) {
|
|
28
|
+
return kind === 'snapshots' && manifest.schema === 2 && !isValidBtrfsStreamManifest(manifest);
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
function manifestMirrorPath(kind, id) {
|
|
21
32
|
const dir = process.env.PBB_MANIFESTS_DIR || path.join(process.env.PBB_STATE_DIR || '.', 'manifests');
|
|
22
33
|
return path.join(dir, `${kind}-${id}.json`);
|
|
@@ -35,7 +46,9 @@ export async function discoverManifest(kind, id, accounts, remoteRoot) {
|
|
|
35
46
|
if (res.ok) {
|
|
36
47
|
try {
|
|
37
48
|
const manifest = JSON.parse(res.stdout);
|
|
38
|
-
if (manifest.kind === kind && manifest.id === id
|
|
49
|
+
if (manifest.kind === kind && manifest.id === id && !isPhantom(kind, manifest)) {
|
|
50
|
+
return { account: acc, manifest };
|
|
51
|
+
}
|
|
39
52
|
} catch {
|
|
40
53
|
/* corrupt manifest — skip */
|
|
41
54
|
}
|
|
@@ -217,6 +230,7 @@ export async function listArtifacts(kind, accounts, remoteRoot, onProgress) {
|
|
|
217
230
|
if (!cat.ok) return null;
|
|
218
231
|
try {
|
|
219
232
|
const manifest = JSON.parse(cat.stdout);
|
|
233
|
+
if (isPhantom(kind, manifest)) return null;
|
|
220
234
|
return { kind, id, createdAt: manifest.createdAt, totalSize: manifest.totalSize, account: acc.remote, manifest };
|
|
221
235
|
} catch {
|
|
222
236
|
return null;
|