parrot-blackbox 2.0.5 → 2.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parrot-blackbox",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
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",
@@ -259,6 +259,31 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
259
259
 
260
260
  return { deleted, failed };
261
261
  }
262
+ /**
263
+ * A module-level registry of all temporary BTRFS mounts created during this
264
+ * process lifetime, so we can unmount them all at exit even if the snapshot
265
+ * object that originally held the reference was garbage-collected.
266
+ */
267
+ const _activeTempMounts = new Set();
268
+
269
+ /**
270
+ * Unmount and remove all temporary BTRFS mount points created by this process.
271
+ * Called on process exit and also by cleanupSnapshotMount.
272
+ */
273
+ function _cleanupAllTempMounts() {
274
+ for (const mp of _activeTempMounts) {
275
+ try { sudoExecSync(['umount', mp]); } catch { /* best effort */ }
276
+ try { sudoExecSync(['rmdir', mp]); } catch { /* best effort */ }
277
+ _activeTempMounts.delete(mp);
278
+ }
279
+ }
280
+
281
+ // Register a process exit handler so stale mounts are always cleaned up,
282
+ // even if the snapshot object holding _tempMount was discarded before cleanup.
283
+ process.on('exit', _cleanupAllTempMounts);
284
+ // Also handle abnormal exits so the mount isn't left behind after Ctrl+C
285
+ // (SIGINT/SIGTERM handlers in cli.js call process.exit(), which fires 'exit').
286
+
262
287
  /**
263
288
  * Resolve the on-disk directory of a snapshot.
264
289
  *
@@ -270,6 +295,11 @@ export async function deleteAllSnapshots({ privileged = 'interactive', onProgres
270
295
  * 2. Check static paths (rsync mode)
271
296
  * 3. Mount the BTRFS root subvolume ourselves to access snapshots persistently
272
297
  * 4. Fall back to triggering timeshift --list
298
+ *
299
+ * IMPORTANT: the mount point is registered in _activeTempMounts so that
300
+ * _cleanupAllTempMounts() can release it on process exit even if the snapshot
301
+ * object is later discarded (e.g. after a resume-upload path that builds a new
302
+ * snapshot object from just the name).
273
303
  */
274
304
  export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
275
305
  if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
@@ -301,6 +331,9 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
301
331
  const mountResult = sudoExecSync(['mount', '-o', 'subvolid=5', device, mountPoint]);
302
332
 
303
333
  if (mountResult.exitCode === 0) {
334
+ // Register in the global set so it is always cleaned up on exit.
335
+ _activeTempMounts.add(mountPoint);
336
+
304
337
  // Search for the snapshot in the mounted root subvolume
305
338
  const searchPaths = [
306
339
  `${mountPoint}/timeshift-btrfs/snapshots/${snapshot.name}`,
@@ -311,12 +344,13 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
311
344
  const found = searchPaths.find((p) => fs.existsSync(p));
312
345
 
313
346
  if (found) {
314
- // Store the mount point for cleanup later
347
+ // Store the mount point on the snapshot object for explicit early cleanup.
315
348
  snapshot._tempMount = mountPoint;
316
349
  return found;
317
350
  }
318
351
 
319
- // Unmount if we didn't find anything
352
+ // Unmount immediately if we didn't find anything useful.
353
+ _activeTempMounts.delete(mountPoint);
320
354
  sudoExecSync(['umount', mountPoint]);
321
355
  sudoExecSync(['rmdir', mountPoint]);
322
356
  }
@@ -332,13 +366,15 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
332
366
  /** Cleanup temporary BTRFS mount if one was created */
333
367
  export function cleanupSnapshotMount(snapshot) {
334
368
  if (snapshot && snapshot._tempMount) {
369
+ const mp = snapshot._tempMount;
335
370
  try {
336
- sudoExecSync(['umount', snapshot._tempMount]);
337
- sudoExecSync(['rmdir', snapshot._tempMount]);
338
- delete snapshot._tempMount;
371
+ sudoExecSync(['umount', mp]);
372
+ sudoExecSync(['rmdir', mp]);
339
373
  } catch {
340
374
  /* best effort */
341
375
  }
376
+ _activeTempMounts.delete(mp);
377
+ delete snapshot._tempMount;
342
378
  }
343
379
  }
344
380
 
@@ -523,11 +559,30 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
523
559
  * `btrfs send` always receives a real subvolume, never a plain directory.
524
560
  */
525
561
  async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parentSubvolPath, accounts, cfg, due, privileged, onProgress }) {
526
- const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest } = await import('./btrfs-send.js');
562
+ const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest, isSubvolumeReadOnly, setSubvolumeReadOnly } = await import('./btrfs-send.js');
527
563
  const { planAndPlaceStream } = await import('../storage/allocator.js');
528
564
 
529
565
  if (!subvolPath) throw new Error('no BTRFS subvolume path for snapshot upload');
530
566
 
567
+ // ── RO guard ──────────────────────────────────────────────────────────────
568
+ // btrfs send requires the subvolume to be read-only (ro=true). Timeshift
569
+ // always creates snapshots as RO, but when they are accessed via a
570
+ // subvolid=5 bind-mount the kernel may report the inner @ subvolume as
571
+ // rw=false (writable) depending on mount flags. Detect and fix this before
572
+ // handing the path to btrfs send, otherwise the send exits 1 with
573
+ // "subvolume … is not read-only".
574
+ const isRO = await isSubvolumeReadOnly(subvolPath, { privileged });
575
+ if (!isRO) {
576
+ journal('snapshots', `subvolume ${subvolPath} is not read-only — setting ro=true before send`);
577
+ try {
578
+ await setSubvolumeReadOnly(subvolPath, true, { privileged });
579
+ journal('snapshots', `set ${subvolPath} read-only OK`);
580
+ } catch (e) {
581
+ throw new Error(`btrfs send requires a read-only subvolume but ${subvolPath} is writable and could not be set read-only: ${e.message}`);
582
+ }
583
+ }
584
+ // ─────────────────────────────────────────────────────────────────────────
585
+
531
586
  journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
532
587
 
533
588
  // Estimate size for progress reporting
package/src/cli.js CHANGED
@@ -2,6 +2,8 @@ import { defineCommand, runMain } from 'citty';
2
2
  import pc from 'picocolors';
3
3
  import * as p from '@clack/prompts';
4
4
  import { createRequire } from 'node:module';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
5
7
  import { runSetup } from './commands/setup.js';
6
8
  import { runWizard } from './commands/wizard.js';
7
9
  import { runRepair } from './commands/manage.js';
@@ -667,7 +669,7 @@ const main = defineCommand({
667
669
  });
668
670
  }
669
671
  s.stop('✔ Upload complete');
670
- import('node:fs').then(fs => fs.writeFileSync(outPath, JSON.stringify(manifest)));
672
+ fs.writeFileSync(outPath, JSON.stringify(manifest));
671
673
  process.exitCode = 0;
672
674
  } catch (e) {
673
675
  s.stop('✖ Upload failed');
@@ -677,16 +679,12 @@ const main = defineCommand({
677
679
  // If we are running as root via sudo, rclone might have refreshed OAuth tokens
678
680
  // and rewritten rclone.conf as root:root. Restore ownership to the real user.
679
681
  if (process.getuid && process.getuid() === 0 && process.env.SUDO_UID && process.env.SUDO_GID) {
680
- import('node:fs').then(fs => {
681
- import('node:path').then(path => {
682
- const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
683
- if (fs.existsSync(confPath)) {
684
- try {
685
- fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
686
- } catch { /* best effort */ }
687
- }
688
- });
689
- });
682
+ const confPath = path.join(process.env.HOME, '.config', 'rclone', 'rclone.conf');
683
+ if (fs.existsSync(confPath)) {
684
+ try {
685
+ fs.chownSync(confPath, parseInt(process.env.SUDO_UID, 10), parseInt(process.env.SUDO_GID, 10));
686
+ } catch { /* best effort */ }
687
+ }
690
688
  }
691
689
  }
692
690
  return;
@@ -702,4 +700,11 @@ const main = defineCommand({
702
700
  process.exitCode = 1;
703
701
  }
704
702
  },
705
- });runMain(main);
703
+ });
704
+
705
+ runMain(main).then(() => {
706
+ // Force the process to exit cleanly regardless of any open file descriptors or
707
+ // kernel mounts left by subvolid=5 bind-mounts created in snapshotDirFor.
708
+ // process.on('exit') in snapshot.js will unmount them before we terminate.
709
+ process.exit(process.exitCode ?? 0);
710
+ });