parrot-blackbox 2.0.4 → 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/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 +170 -47
- package/src/cli.js +18 -13
- 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.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",
|
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
|
@@ -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
|
|
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
|
}
|
|
@@ -331,14 +365,16 @@ export function snapshotDirFor(snapshot, { privileged = 'noninteractive' } = {})
|
|
|
331
365
|
|
|
332
366
|
/** Cleanup temporary BTRFS mount if one was created */
|
|
333
367
|
export function cleanupSnapshotMount(snapshot) {
|
|
334
|
-
if (snapshot._tempMount) {
|
|
368
|
+
if (snapshot && snapshot._tempMount) {
|
|
369
|
+
const mp = snapshot._tempMount;
|
|
335
370
|
try {
|
|
336
|
-
sudoExecSync(['umount',
|
|
337
|
-
sudoExecSync(['rmdir',
|
|
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
|
|
|
@@ -423,15 +459,41 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
423
459
|
const dir = snapshotDirFor(created, { privileged });
|
|
424
460
|
const parentDir = parentSnap ? snapshotDirFor(parentSnap, { privileged }) : null;
|
|
425
461
|
|
|
462
|
+
// Timeshift (BTRFS mode) stores each snapshot as a *directory* containing an
|
|
463
|
+
// "@" snapshot subvolume. The container directory itself is NOT a subvolume
|
|
464
|
+
// and `btrfs send` fails on it with "Invalid argument". Resolve the real
|
|
465
|
+
// subvolume here, and drop to file-copy mode when the snapshots genuinely
|
|
466
|
+
// aren't subvolumes (e.g. Timeshift rsync mode).
|
|
467
|
+
const { findSnapshotSubvolume } = await import('./btrfs-send.js');
|
|
468
|
+
let subvolPath = null;
|
|
469
|
+
let parentSubvolPath = null;
|
|
470
|
+
if (useBtrfs) {
|
|
471
|
+
subvolPath = await findSnapshotSubvolume(dir, { privileged });
|
|
472
|
+
if (!subvolPath) {
|
|
473
|
+
console.log('⚠ Timeshift snapshots are not BTRFS subvolumes (rsync mode?) — falling back to file-copy mode');
|
|
474
|
+
useBtrfs = false;
|
|
475
|
+
} else if (parentDir) {
|
|
476
|
+
parentSubvolPath = await findSnapshotSubvolume(parentDir, { privileged });
|
|
477
|
+
if (!parentSubvolPath) {
|
|
478
|
+
console.log('⚠ Parent snapshot has no BTRFS subvolume — running a full send instead');
|
|
479
|
+
parentSnap = null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// File-copy source: when the snapshot dir is a Timeshift BTRFS container
|
|
485
|
+
// (<dir>/@), walk the "@" contents so the actual system tree is uploaded.
|
|
486
|
+
const fileCopyDir = fs.existsSync(path.join(dir, '@')) ? path.join(dir, '@') : dir;
|
|
487
|
+
|
|
426
488
|
let manifest;
|
|
427
489
|
try {
|
|
428
490
|
if (useBtrfs) {
|
|
429
491
|
// V2 BTRFS send/receive path
|
|
430
492
|
manifest = await uploadViaBtrfsSend({
|
|
431
493
|
snapshot: created,
|
|
432
|
-
|
|
494
|
+
subvolPath,
|
|
433
495
|
parentSnapshot: parentSnap,
|
|
434
|
-
|
|
496
|
+
parentSubvolPath,
|
|
435
497
|
accounts,
|
|
436
498
|
cfg,
|
|
437
499
|
due,
|
|
@@ -442,8 +504,7 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
442
504
|
// Legacy file-copy fallback (v2 behavior)
|
|
443
505
|
manifest = await uploadViaFileCopy({
|
|
444
506
|
snapshot: created,
|
|
445
|
-
snapshotDir:
|
|
446
|
-
parentDir,
|
|
507
|
+
snapshotDir: fileCopyDir,
|
|
447
508
|
accounts,
|
|
448
509
|
cfg,
|
|
449
510
|
due,
|
|
@@ -454,10 +515,10 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
454
515
|
} catch (e) {
|
|
455
516
|
// The local snapshot exists and is safe; the cloud upload failed.
|
|
456
517
|
journal('snapshots', `upload failed for ${created.name}: ${e.message}`, 'error');
|
|
457
|
-
cleanupSnapshotMount(created);
|
|
458
518
|
throw e;
|
|
459
519
|
} finally {
|
|
460
520
|
cleanupSnapshotMount(created);
|
|
521
|
+
if (parentSnap) cleanupSnapshotMount(parentSnap);
|
|
461
522
|
}
|
|
462
523
|
|
|
463
524
|
manifest.due = due;
|
|
@@ -493,19 +554,34 @@ export async function runSnapshotBackup(cfg, state, { due, privileged = 'noninte
|
|
|
493
554
|
* Upload a snapshot using BTRFS send/receive streaming.
|
|
494
555
|
* Creates: btrfs send [-p parent] | zstd | [openssl] | rclone rcat (chunked)
|
|
495
556
|
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
557
|
+
* `subvolPath` / `parentSubvolPath` are the actual BTRFS subvolumes (resolved by
|
|
558
|
+
* findSnapshotSubvolume() — Timeshift keeps snapshots at <snapshot-dir>/@), so
|
|
559
|
+
* `btrfs send` always receives a real subvolume, never a plain directory.
|
|
499
560
|
*/
|
|
500
|
-
async function uploadViaBtrfsSend({ snapshot,
|
|
501
|
-
const { createSendStream, estimateSendSize } = await import('./btrfs-send.js');
|
|
561
|
+
async function uploadViaBtrfsSend({ snapshot, subvolPath, parentSnapshot, parentSubvolPath, accounts, cfg, due, privileged, onProgress }) {
|
|
562
|
+
const { createSendStream, estimateSendSize, isValidBtrfsStreamManifest, isSubvolumeReadOnly, setSubvolumeReadOnly } = await import('./btrfs-send.js');
|
|
502
563
|
const { planAndPlaceStream } = await import('../storage/allocator.js');
|
|
503
564
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
565
|
+
if (!subvolPath) throw new Error('no BTRFS subvolume path for snapshot upload');
|
|
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
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
509
585
|
|
|
510
586
|
journal('snapshots', `btrfs send subvolPath=${subvolPath} parent=${parentSubvolPath || 'null'}`);
|
|
511
587
|
|
|
@@ -562,34 +638,78 @@ async function uploadViaBtrfsSend({ snapshot, snapshotDir, parentSnapshot, paren
|
|
|
562
638
|
originalSize: estimatedSize,
|
|
563
639
|
});
|
|
564
640
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
));
|
|
641
|
+
/**
|
|
642
|
+
* Abort a failed/incomplete upload so no fragment stream or manifest is left
|
|
643
|
+
* behind — exactly the corruption that previously let 13-byte phantom
|
|
644
|
+
* streams appear in the cloud and break the incremental chain.
|
|
645
|
+
*/
|
|
646
|
+
const abortGhostUpload = async () => {
|
|
647
|
+
for (const proc of pipeline) { try { proc.kill('SIGKILL'); } catch { /* already gone */ } }
|
|
648
|
+
try { currentStream.destroy(); } catch { /* best effort */ }
|
|
649
|
+
// Let the concurrent uploader settle (bounded) so it can't write a manifest after we return.
|
|
650
|
+
await Promise.race([
|
|
651
|
+
manifestPromise.catch(() => {}),
|
|
652
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
653
|
+
]);
|
|
654
|
+
const { removeArtifact } = await import('../storage/archive.js');
|
|
655
|
+
try { await removeArtifact('snapshots', snapshot.name, accounts, cfg.storage.remoteRoot); } catch { /* best effort */ }
|
|
656
|
+
try { fs.rmSync(path.join(manifestsDir(), `snapshots-${snapshot.name}.json`), { force: true }); } catch { /* best effort */ }
|
|
657
|
+
};
|
|
583
658
|
|
|
584
|
-
|
|
659
|
+
try {
|
|
660
|
+
// Wait for all pipeline stages (including btrfs send itself) to exit cleanly
|
|
661
|
+
await Promise.all(pipeline.map((proc) =>
|
|
662
|
+
new Promise((resolve, reject) => {
|
|
663
|
+
proc.on('close', (code) => {
|
|
664
|
+
if (code === 0 || code === null) {
|
|
665
|
+
resolve();
|
|
666
|
+
} else {
|
|
667
|
+
const stderr = Buffer.concat(sendStderr).toString().trim();
|
|
668
|
+
reject(new Error(
|
|
669
|
+
proc === sendChild
|
|
670
|
+
? `btrfs send failed (exit ${code})${stderr ? ': ' + stderr : ''} — is ${subvolPath} a BTRFS subvolume?`
|
|
671
|
+
: `Pipeline stage failed (exit ${code})`
|
|
672
|
+
));
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
proc.on('error', reject);
|
|
676
|
+
})
|
|
677
|
+
));
|
|
678
|
+
|
|
679
|
+
const manifest = await manifestPromise;
|
|
680
|
+
|
|
681
|
+
// A real system stream is orders of magnitude larger than 1 MiB. A tiny
|
|
682
|
+
// stream means the send failed and must never be recorded as a backup.
|
|
683
|
+
if (!isValidBtrfsStreamManifest(manifest)) {
|
|
684
|
+
await abortGhostUpload();
|
|
685
|
+
throw new Error(`btrfs send produced an undersized stream (${manifest?.totalSize ?? 0} bytes) — aborting`);
|
|
686
|
+
}
|
|
585
687
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
688
|
+
// Enrich the manifest with the parent-chain info BEFORE persisting it, and
|
|
689
|
+
// push the enriched copy back to the cloud so a restore from a wiped
|
|
690
|
+
// machine can still resolve the incremental chain.
|
|
691
|
+
manifest.snapshot = snapshot.name;
|
|
692
|
+
manifest.parent = parentSnapshot?.name || null;
|
|
693
|
+
|
|
694
|
+
// Save manifest locally
|
|
695
|
+
const manifestPath = path.join(manifestsDir(), `snapshots-${snapshot.name}.json`);
|
|
696
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
697
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
698
|
+
|
|
699
|
+
// Re-upload the enriched manifest (now carrying the parent link) to the cloud.
|
|
700
|
+
if (manifest.account) {
|
|
701
|
+
const { copyToFile } = await import('../storage/rclone.js');
|
|
702
|
+
const remotePath = `${manifest.account}:${manifest.remoteRoot}/snapshots/${snapshot.name}/__MANIFEST__.json`;
|
|
703
|
+
const res = await copyToFile(manifestPath, remotePath);
|
|
704
|
+
if (!res.ok) journal('snapshots', `could not refresh cloud manifest for ${snapshot.name}`, 'warn');
|
|
705
|
+
}
|
|
590
706
|
|
|
591
|
-
|
|
592
|
-
|
|
707
|
+
console.log(`\n✓ Uploaded ${(manifest.totalSize / (1024 ** 3)).toFixed(2)} GiB to cloud`);
|
|
708
|
+
return manifest;
|
|
709
|
+
} catch (err) {
|
|
710
|
+
await abortGhostUpload();
|
|
711
|
+
throw err;
|
|
712
|
+
}
|
|
593
713
|
}
|
|
594
714
|
|
|
595
715
|
/**
|
|
@@ -604,7 +724,10 @@ async function uploadViaFileCopy({ snapshot, snapshotDir, parentDir, accounts, c
|
|
|
604
724
|
HOME: process.env.HOME,
|
|
605
725
|
PBB_STATE_DIR: stateDir(),
|
|
606
726
|
PBB_CONFIG_FILE: configFile(),
|
|
607
|
-
|
|
727
|
+
// The subprocess must never re-enable BTRFS send — file-copy mode was
|
|
728
|
+
// chosen because the snapshots are NOT subvolumes (rsync mode, or tools
|
|
729
|
+
// missing). Re-detection in the child would resurrect the failing send.
|
|
730
|
+
PBB_DISABLE_BTRFS: '1',
|
|
608
731
|
};
|
|
609
732
|
const cmdArgs = [process.execPath, bin, '_internal_upload', snapshotDir, 'snapshots', snapshot.name, cfg.storage.remoteRoot, String(cfg.storage.chunkSize), outPath];
|
|
610
733
|
const args = process.env.PBB_SUDO_DIRECT === '1' ? cmdArgs : ['-E', ...cmdArgs];
|
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';
|
|
@@ -421,7 +423,7 @@ const main = defineCommand({
|
|
|
421
423
|
if (sub === 'prune') {
|
|
422
424
|
const cfg = loadConfig();
|
|
423
425
|
try {
|
|
424
|
-
const pruned = await pruneSnapshots(cfg,
|
|
426
|
+
const pruned = await pruneSnapshots(cfg, listAccounts(), { privileged: 'interactive' });
|
|
425
427
|
if (pruned.length) console.log(pc.green(`✔ Pruned: ${pruned.join(', ')} (local + cloud).`));
|
|
426
428
|
else console.log(pc.dim('Nothing to prune.'));
|
|
427
429
|
} catch (e) {
|
|
@@ -667,7 +669,7 @@ const main = defineCommand({
|
|
|
667
669
|
});
|
|
668
670
|
}
|
|
669
671
|
s.stop('✔ Upload complete');
|
|
670
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
});
|
|
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
|
+
});
|
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;
|