parrot-blackbox 1.0.8 → 1.0.10

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": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "parrot-blackbox — 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",
@@ -167,9 +167,15 @@ export async function deleteSnapshot(name, { privileged = 'noninteractive' } = {
167
167
  }
168
168
  /**
169
169
  * Resolve the on-disk directory of a snapshot.
170
- * BTRFS mode keeps snapshots in a hidden subvolume that Timeshift mounts at
171
- * /run/timeshift/NNNN/backup (e.g. .../timeshift-btrfs/snapshots/<name>) check the
172
- * classic locations first, then search the mounted backup tree.
170
+ *
171
+ * BTRFS mode stores snapshots as subvolumes on the BTRFS partition. Timeshift mounts
172
+ * the root subvolume (subvolid=5) temporarily to /run/timeshift/NNNN/backup when needed.
173
+ *
174
+ * Strategy:
175
+ * 1. Check if already have a valid dir
176
+ * 2. Check static paths (rsync mode)
177
+ * 3. Mount the BTRFS root subvolume ourselves to access snapshots persistently
178
+ * 4. Fall back to triggering timeshift --list
173
179
  */
174
180
  export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {}) {
175
181
  if (snapshot.dir && fs.existsSync(snapshot.dir)) return snapshot.dir;
@@ -184,34 +190,70 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
184
190
  const direct = candidates.find((c) => fs.existsSync(c));
185
191
  if (direct) return direct;
186
192
 
187
- // Best effort: search the mounted Timeshift backup tree for the snapshot dir.
188
- // BTRFS mode uses dynamic PIDs in the mount path: /run/timeshift/NNNN/backup/...
193
+ // BTRFS mode: Mount the root subvolume to access snapshots
194
+ // Find the BTRFS device (usually the root filesystem)
189
195
  try {
190
- if (fs.existsSync('/run/timeshift')) {
191
- // Try without sudo first (faster)
192
- let found = execaSync(
193
- 'bash',
194
- ['-c', `find /run/timeshift -maxdepth 5 -type d -name '${snapshot.name}' 2>/dev/null | head -1`],
195
- { reject: false, timeout: 5000 },
196
- ).stdout.trim();
196
+ const mountPoint = `/run/parrot-blackbox-btrfs-${Date.now()}`;
197
+
198
+ // Get the device that contains the root filesystem
199
+ const deviceResult = execaSync('findmnt', ['-n', '-o', 'SOURCE', '/'], { reject: false });
200
+ const device = deviceResult.stdout.trim().split('[')[0];
201
+
202
+ if (device && deviceResult.exitCode === 0) {
203
+ // Create temporary mount point
204
+ const mkdirCmd = privileged === 'interactive' ? ['sudo', 'mkdir', '-p', mountPoint] : ['sudo', '-n', 'mkdir', '-p', mountPoint];
205
+ execaSync(mkdirCmd[0], mkdirCmd.slice(1), { reject: false });
197
206
 
198
- // If that fails and we can use sudo, try with elevated privileges
199
- if (!found && privileged === 'interactive') {
200
- found = execaSync(
201
- 'sudo',
202
- ['bash', '-c', `find /run/timeshift -maxdepth 5 -type d -name '${snapshot.name}' 2>/dev/null | head -1`],
203
- { reject: false, timeout: 5000 },
204
- ).stdout.trim();
205
- }
207
+ // Mount BTRFS root subvolume (subvolid=5 contains all subvolumes including snapshots)
208
+ const mountCmd = privileged === 'interactive'
209
+ ? ['sudo', 'mount', '-o', 'subvolid=5', device, mountPoint]
210
+ : ['sudo', '-n', 'mount', '-o', 'subvolid=5', device, mountPoint];
211
+
212
+ const mountResult = execaSync(mountCmd[0], mountCmd.slice(1), { reject: false, timeout: 5000 });
206
213
 
207
- if (found) return found;
214
+ if (mountResult.exitCode === 0) {
215
+ // Search for the snapshot in the mounted root subvolume
216
+ const searchPaths = [
217
+ `${mountPoint}/timeshift-btrfs/snapshots/${snapshot.name}`,
218
+ `${mountPoint}/@/timeshift-btrfs/snapshots/${snapshot.name}`,
219
+ `${mountPoint}/@timeshift/snapshots/${snapshot.name}`,
220
+ ];
221
+
222
+ const found = searchPaths.find((p) => fs.existsSync(p));
223
+
224
+ if (found) {
225
+ // Store the mount point for cleanup later
226
+ snapshot._tempMount = mountPoint;
227
+ return found;
228
+ }
229
+
230
+ // Unmount if we didn't find anything
231
+ const umountCmd = privileged === 'interactive' ? ['sudo', 'umount', mountPoint] : ['sudo', '-n', 'umount', mountPoint];
232
+ execaSync(umountCmd[0], umountCmd.slice(1), { reject: false });
233
+ execaSync('sudo', ['-n', 'rmdir', mountPoint], { reject: false });
234
+ }
208
235
  }
209
236
  } catch {
210
237
  /* fall through */
211
238
  }
239
+
240
+ // Last resort: return the most likely path (will fail with ENOENT if it doesn't exist)
212
241
  return candidates[0];
213
242
  }
214
243
 
244
+ /** Cleanup temporary BTRFS mount if one was created */
245
+ export function cleanupSnapshotMount(snapshot) {
246
+ if (snapshot._tempMount) {
247
+ try {
248
+ execaSync('sudo', ['-n', 'umount', snapshot._tempMount], { reject: false });
249
+ execaSync('sudo', ['-n', 'rmdir', snapshot._tempMount], { reject: false });
250
+ delete snapshot._tempMount;
251
+ } catch {
252
+ /* best effort */
253
+ }
254
+ }
255
+ }
256
+
215
257
  /**
216
258
  * Run one snapshot generation: create → upload to the pool → prune old ones
217
259
  * BOTH locally and in the cloud.
@@ -237,7 +279,11 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
237
279
  } catch (e) {
238
280
  // The local snapshot exists and is safe; the cloud upload failed.
239
281
  journal('snapshots', `upload failed for ${created.name}: ${e.message}`, 'error');
282
+ cleanupSnapshotMount(created); // Clean up any temporary BTRFS mount
240
283
  throw e;
284
+ } finally {
285
+ // Always cleanup the temporary mount after upload attempt
286
+ cleanupSnapshotMount(created);
241
287
  }
242
288
  manifest.due = due;
243
289
  manifest.snapshot = created.name;