parrot-blackbox 2.0.4 → 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 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.4",
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",
@@ -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} path
84
+ * @param {string} subvolPath
85
+ * @param {object} opts - {privileged: 'interactive'|'noninteractive'}
64
86
  * @returns {Promise<boolean>}
65
87
  */
66
- export async function isSubvolume(path) {
88
+ export async function isSubvolume(subvolPath, { privileged = 'noninteractive' } = {}) {
89
+ if (!subvolPath || !fs.existsSync(subvolPath)) return false;
67
90
  try {
68
- // Need sudo to check subvolume info
69
- const res = await execa('sudo', ['-n', 'btrfs', 'subvolume', 'show', path], { reject: false });
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
- const res = await execa('btrfs', ['property', 'get', '-ts', subvolPath, 'ro'], { reject: false });
106
- if (res.exitCode !== 0) return false;
107
- return res.stdout.includes('ro=true');
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
- return name;
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 = privileged === 'interactive'
176
- ? ['sudo', '-E', ...args]
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'],
@@ -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 = determineSnapshotBase();
154
-
155
- // Ensure base directory exists
156
- await ensureSudo();
157
- await sudoInteractive(['mkdir', '-p', base]);
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
- console.log(` 💾 Receiving into ${base}...`);
225
- const sudoArgs = privileged === 'interactive'
226
- ? ['sudo', '-E', 'btrfs', 'receive', base]
227
- : ['sudo', '-n', 'btrfs', 'receive', base];
228
- const receive = spawn(sudoArgs[0], sudoArgs.slice(1), {
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();
@@ -331,7 +331,7 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
331
331
 
332
332
  /** Cleanup temporary BTRFS mount if one was created */
333
333
  export function cleanupSnapshotMount(snapshot) {
334
- if (snapshot._tempMount) {
334
+ if (snapshot && snapshot._tempMount) {
335
335
  try {
336
336
  sudoExecSync(['umount', snapshot._tempMount]);
337
337
  sudoExecSync(['rmdir', snapshot._tempMount]);
@@ -423,15 +423,41 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
423
423
  const dir = snapshotDirFor(created, { privileged });
424
424
  const parentDir = parentSnap ? snapshotDirFor(parentSnap, { privileged }) : null;
425
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
+
426
452
  let manifest;
427
453
  try {
428
454
  if (useBtrfs) {
429
455
  // V2 BTRFS send/receive path
430
456
  manifest = await uploadViaBtrfsSend({
431
457
  snapshot: created,
432
- snapshotDir: dir,
458
+ subvolPath,
433
459
  parentSnapshot: parentSnap,
434
- parentDir,
460
+ parentSubvolPath,
435
461
  accounts,
436
462
  cfg,
437
463
  due,
@@ -442,8 +468,7 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
442
468
  // Legacy file-copy fallback (v2 behavior)
443
469
  manifest = await uploadViaFileCopy({
444
470
  snapshot: created,
445
- snapshotDir: dir,
446
- parentDir,
471
+ snapshotDir: fileCopyDir,
447
472
  accounts,
448
473
  cfg,
449
474
  due,
@@ -454,10 +479,10 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
454
479
  } catch (e) {
455
480
  // The local snapshot exists and is safe; the cloud upload failed.
456
481
  journal('snapshots', `upload failed for ${created.name}: ${e.message}`, 'error');
457
- cleanupSnapshotMount(created);
458
482
  throw e;
459
483
  } finally {
460
484
  cleanupSnapshotMount(created);
485
+ if (parentSnap) cleanupSnapshotMount(parentSnap);
461
486
  }
462
487
 
463
488
  manifest.due = due;
@@ -493,19 +518,15 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
493
518
  * Upload a snapshot using BTRFS send/receive streaming.
494
519
  * Creates: btrfs send [-p parent] | zstd | [openssl] | rclone rcat (chunked)
495
520
  *
496
- * Uses the path resolved by snapshotDirFor() (which mounts the BTRFS root subvolume
497
- * at a temporary mount point) so that `btrfs send` receives an actual subvolume path,
498
- * not a directory inside a regular mount.
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.
499
524
  */
500
- async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, parentDir, accounts, cfg, due, privileged, onProgress }) {
501
- 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');
502
527
  const { planAndPlaceStream } = await import('../storage/allocator.js');
503
528
 
504
- // snapshotDir was resolved by snapshotDirFor() it is the real subvolume path
505
- // (e.g. /run/parrot-blackbox-btrfs-<ts>/timeshift-btrfs/snapshots/<name>).
506
- // Use it directly; do NOT fall back to a hardcoded absolute path.
507
- const subvolPath = snapshotDir;
508
- const parentSubvolPath = parentDir || null;
529
+ if (!subvolPath) throw new Error('no BTRFS subvolume path for snapshot upload');
509
530
 
510
531
  journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
511
532
 
@@ -562,34 +583,78 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
562
583
  originalSize: estimatedSize,
563
584
  });
564
585
 
565
- // Wait for all pipeline stages (including btrfs send itself) to exit cleanly
566
- await Promise.all(pipeline.map((proc) =>
567
- new Promise((resolve, reject) => {
568
- proc.on('close', (code) => {
569
- if (code === 0 || code === null) {
570
- resolve();
571
- } else {
572
- const stderr = Buffer.concat(sendStderr).toString().trim();
573
- reject(new Error(
574
- proc === sendChild
575
- ? `btrfs send failed (exit ${code})${stderr ? ': ' + stderr : ''} — is ${subvolPath} a BTRFS subvolume?`
576
- : `Pipeline stage failed (exit ${code})`
577
- ));
578
- }
579
- });
580
- proc.on('error', reject);
581
- })
582
- ));
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
+ };
583
603
 
584
- const manifest = await manifestPromise;
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
+ }
585
632
 
586
- // Save manifest locally
587
- const manifestPath = path.join(manifestsDir(), `snapshots-${snapshot.name}.json`);
588
- fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
589
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
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
+ }
590
651
 
591
- console.log(`\n✓ Uploaded ${(manifest.totalSize / (1024 ** 3)).toFixed(2)} GiB to cloud`);
592
- return manifest;
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
+ }
593
658
  }
594
659
 
595
660
  /**
@@ -604,7 +669,10 @@ async function uploadViaFileCopy({ snapshot, snapshotDir, parentDir, accounts, c
604
669
  HOME: process.env.HOME,
605
670
  PBB_STATE_DIR: stateDir(),
606
671
  PBB_CONFIG_FILE: configFile(),
607
- PBB_PARENT_DIR: parentDir || '',
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',
608
676
  };
609
677
  const cmdArgs = [process.execPath, bin, '_internal_upload', snapshotDir, 'snapshots', snapshot.name, cfg.storage.remoteRoot, String(cfg.storage.chunkSize), outPath];
610
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, loadState(), listAccounts(), { privileged: 'interactive' });
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) {
@@ -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) return { account: acc, manifest };
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;