sealkeep 0.9.0 → 0.11.0
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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +223 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants, createReadStream } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat, unlink, } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { openForFlush } from "./flush.js";
|
|
6
|
+
import { acquireDurableTicketLock } from "./durable-ticket-lock.js";
|
|
7
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
8
|
+
const TRANSACTION_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
9
|
+
const JOURNAL_VERSION = 1;
|
|
10
|
+
export class ReclaimConflictError extends Error {
|
|
11
|
+
code = "RECLAIM_CONFLICT";
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ReclaimConflictError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function codedError(message, code) {
|
|
18
|
+
const error = new Error(message);
|
|
19
|
+
error.code = code;
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
function errorCode(error) {
|
|
23
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
24
|
+
? String(error.code)
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
function expectedIsValid(value) {
|
|
28
|
+
if (!value || typeof value !== "object")
|
|
29
|
+
return false;
|
|
30
|
+
const candidate = value;
|
|
31
|
+
return Number.isSafeInteger(candidate.bytes)
|
|
32
|
+
&& (candidate.bytes ?? -1) >= 0
|
|
33
|
+
&& typeof candidate.sha256 === "string"
|
|
34
|
+
&& SHA256_RE.test(candidate.sha256);
|
|
35
|
+
}
|
|
36
|
+
function digestMatches(left, right) {
|
|
37
|
+
if (!SHA256_RE.test(left) || !SHA256_RE.test(right))
|
|
38
|
+
return false;
|
|
39
|
+
return timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex"));
|
|
40
|
+
}
|
|
41
|
+
function fingerprintMatches(fingerprint, expected) {
|
|
42
|
+
return fingerprint.bytes === expected.bytes && digestMatches(fingerprint.sha256, expected.sha256);
|
|
43
|
+
}
|
|
44
|
+
function sameIdentity(left, right) {
|
|
45
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
46
|
+
}
|
|
47
|
+
function pathWithin(candidate, root) {
|
|
48
|
+
const rel = relative(root, candidate);
|
|
49
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
50
|
+
}
|
|
51
|
+
async function syncDirectory(path) {
|
|
52
|
+
let handle;
|
|
53
|
+
try {
|
|
54
|
+
handle = await open(path, "r");
|
|
55
|
+
await handle.sync();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// Windows does not permit opening directories. The file fsync still gives
|
|
59
|
+
// the strongest durability primitive Node exposes there.
|
|
60
|
+
if (process.platform !== "win32")
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await handle?.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function ensurePrivateDirectory(path) {
|
|
68
|
+
const target = resolve(path);
|
|
69
|
+
const firstCreated = await mkdir(target, { recursive: true, mode: 0o700 });
|
|
70
|
+
if (!firstCreated)
|
|
71
|
+
return;
|
|
72
|
+
// mkdir({recursive:true}) can create several namespace entries. Persist the
|
|
73
|
+
// parent that names the first new directory and every new directory inode,
|
|
74
|
+
// so a journal cannot outlive the directories needed to reconcile it.
|
|
75
|
+
const first = resolve(firstCreated);
|
|
76
|
+
await syncDirectory(dirname(first));
|
|
77
|
+
let cursor = first;
|
|
78
|
+
await syncDirectory(cursor);
|
|
79
|
+
for (const piece of relative(first, target).split(sep).filter(Boolean)) {
|
|
80
|
+
cursor = join(cursor, piece);
|
|
81
|
+
await syncDirectory(cursor);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function removeDurably(path) {
|
|
85
|
+
try {
|
|
86
|
+
await unlink(path);
|
|
87
|
+
await syncDirectory(dirname(path));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (errorCode(error) !== "ENOENT")
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function writeJsonDurably(path, value, exclusive) {
|
|
95
|
+
await ensurePrivateDirectory(dirname(path));
|
|
96
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
97
|
+
let handle;
|
|
98
|
+
try {
|
|
99
|
+
handle = await open(temporary, "wx", 0o600);
|
|
100
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8");
|
|
101
|
+
await handle.sync();
|
|
102
|
+
await handle.close();
|
|
103
|
+
handle = undefined;
|
|
104
|
+
if (exclusive) {
|
|
105
|
+
await link(temporary, path);
|
|
106
|
+
// Persist the recovery name before removing the private staging name.
|
|
107
|
+
// If power fails between these fsyncs there may be two hard links, but
|
|
108
|
+
// never a window in which the durable file has no name.
|
|
109
|
+
await syncDirectory(dirname(path));
|
|
110
|
+
await unlink(temporary);
|
|
111
|
+
await syncDirectory(dirname(path));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
await rename(temporary, path);
|
|
115
|
+
await syncDirectory(dirname(path));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
await handle?.close().catch(() => undefined);
|
|
120
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function reclaimSourceLockPath(dataDir, sourcePath) {
|
|
124
|
+
const key = createHash("sha256").update(resolve(sourcePath)).digest("hex");
|
|
125
|
+
return join(resolve(dataDir), "reclaim-locks", `${key}.claims`);
|
|
126
|
+
}
|
|
127
|
+
function legacyReclaimSourceLockPath(dataDir, sourcePath) {
|
|
128
|
+
const key = createHash("sha256").update(resolve(sourcePath)).digest("hex");
|
|
129
|
+
return join(resolve(dataDir), "reclaim-locks", `${key}.lock`);
|
|
130
|
+
}
|
|
131
|
+
export async function acquireReclaimSourceLock(dataDir, sourcePath, options = {}) {
|
|
132
|
+
const canonicalDataDir = resolve(dataDir);
|
|
133
|
+
const canonicalSource = resolve(sourcePath);
|
|
134
|
+
const queuePath = reclaimSourceLockPath(canonicalDataDir, canonicalSource);
|
|
135
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
136
|
+
const pollMs = Math.max(1, options.pollMs ?? 25);
|
|
137
|
+
// An abandoned pre-ticket lock is never unlinked automatically. Doing so
|
|
138
|
+
// safely requires conditional unlink-by-inode, which Node does not expose.
|
|
139
|
+
// Its old owner cannot acquire again while the pathname remains; fail closed
|
|
140
|
+
// and let explicit singleton maintenance remove it after inspection.
|
|
141
|
+
const legacyPath = legacyReclaimSourceLockPath(canonicalDataDir, canonicalSource);
|
|
142
|
+
if (await stat(legacyPath).then(() => true, (error) => {
|
|
143
|
+
if (errorCode(error) === "ENOENT")
|
|
144
|
+
return false;
|
|
145
|
+
throw error;
|
|
146
|
+
})) {
|
|
147
|
+
throw codedError(`A legacy reclaim lock requires service-stopped maintenance before this source can be reclaimed: ${canonicalSource}`, "RECLAIM_LOCKED");
|
|
148
|
+
}
|
|
149
|
+
let ticket;
|
|
150
|
+
try {
|
|
151
|
+
ticket = await acquireDurableTicketLock(queuePath, canonicalSource, {
|
|
152
|
+
timeoutMs,
|
|
153
|
+
pollMs,
|
|
154
|
+
signal: options.signal,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (errorCode(error) === "DURABLE_LOCK_TIMEOUT") {
|
|
159
|
+
throw codedError(`Timed out waiting for reclaim lock: ${canonicalSource}`, "RECLAIM_LOCKED");
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
dataDir: canonicalDataDir,
|
|
165
|
+
sourcePath: canonicalSource,
|
|
166
|
+
lockPath: ticket.claimPath,
|
|
167
|
+
token: ticket.token,
|
|
168
|
+
assertOwned: async () => {
|
|
169
|
+
try {
|
|
170
|
+
await ticket.assertOwned();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new ReclaimConflictError(`Source lock ownership was lost: ${canonicalSource}`);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
release: () => ticket.release(),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
async function fingerprintRegularFile(path, io = {}) {
|
|
180
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
181
|
+
const { handle } = await openForFlush(path, noFollow);
|
|
182
|
+
try {
|
|
183
|
+
const before = await handle.stat({ bigint: true });
|
|
184
|
+
if (!before.isFile())
|
|
185
|
+
throw new ReclaimConflictError(`Expected a regular file: ${path}`);
|
|
186
|
+
const hash = createHash("sha256");
|
|
187
|
+
const stream = createReadStream("", { fd: handle.fd, autoClose: false, start: 0 });
|
|
188
|
+
let bytesSinceYield = 0;
|
|
189
|
+
for await (const chunk of stream) {
|
|
190
|
+
hash.update(chunk);
|
|
191
|
+
bytesSinceYield += chunk.length;
|
|
192
|
+
if (bytesSinceYield >= 16 * 1024 * 1024) {
|
|
193
|
+
bytesSinceYield = 0;
|
|
194
|
+
await io.onYield?.();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const after = await handle.stat({ bigint: true });
|
|
198
|
+
if (before.dev !== after.dev
|
|
199
|
+
|| before.ino !== after.ino
|
|
200
|
+
|| before.size !== after.size
|
|
201
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
202
|
+
|| before.ctimeNs !== after.ctimeNs) {
|
|
203
|
+
throw new ReclaimConflictError(`File changed while it was being hashed: ${path}`);
|
|
204
|
+
}
|
|
205
|
+
if (after.size > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
206
|
+
throw new ReclaimConflictError(`File is too large to journal safely: ${path}`);
|
|
207
|
+
}
|
|
208
|
+
const allocated = after.blocks * 512n;
|
|
209
|
+
if (allocated > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
210
|
+
throw new ReclaimConflictError(`File allocation is too large to journal safely: ${path}`);
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
bytes: Number(after.size),
|
|
214
|
+
sha256: hash.digest("hex"),
|
|
215
|
+
dev: after.dev.toString(),
|
|
216
|
+
ino: after.ino.toString(),
|
|
217
|
+
allocatedBytes: Number(allocated),
|
|
218
|
+
links: Number(after.nlink),
|
|
219
|
+
mtimeNs: after.mtimeNs.toString(),
|
|
220
|
+
ctimeNs: after.ctimeNs.toString(),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await handle.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function fingerprintOrNull(path, io = {}) {
|
|
228
|
+
try {
|
|
229
|
+
return await fingerprintRegularFile(path, io);
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (errorCode(error) === "ENOENT")
|
|
233
|
+
return null;
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function assertExpected(actual, expected, label) {
|
|
238
|
+
if (!fingerprintMatches(actual, expected)) {
|
|
239
|
+
throw new ReclaimConflictError(`${label} does not match the expected ${expected.bytes}-byte sha256 ${expected.sha256}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function assertRegularNoFollow(path) {
|
|
243
|
+
const info = await lstat(path);
|
|
244
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
245
|
+
throw new ReclaimConflictError(`Expected a non-symlink regular file: ${path}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function identityRegularNoFollow(path) {
|
|
249
|
+
const info = await lstat(path, { bigint: true });
|
|
250
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
251
|
+
throw new ReclaimConflictError(`Expected a non-symlink regular file: ${path}`);
|
|
252
|
+
}
|
|
253
|
+
return { dev: info.dev.toString(), ino: info.ino.toString() };
|
|
254
|
+
}
|
|
255
|
+
/** Cheap final identity/change check used next to namespace mutations. */
|
|
256
|
+
async function assertFingerprintMetadataUnchanged(path, expected, message) {
|
|
257
|
+
const info = await lstat(path, { bigint: true });
|
|
258
|
+
if (info.isSymbolicLink() || !info.isFile()
|
|
259
|
+
|| info.dev.toString() !== expected.dev
|
|
260
|
+
|| info.ino.toString() !== expected.ino
|
|
261
|
+
|| info.size !== BigInt(expected.bytes)
|
|
262
|
+
|| info.mtimeNs.toString() !== expected.mtimeNs
|
|
263
|
+
|| info.ctimeNs.toString() !== expected.ctimeNs
|
|
264
|
+
|| Number(info.nlink) !== expected.links) {
|
|
265
|
+
throw new ReclaimConflictError(message);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Flush the exact inode whose digest was accepted before publishing its name. */
|
|
269
|
+
async function syncExactRegularFile(path, expected, io = {}) {
|
|
270
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
271
|
+
const { handle } = await openForFlush(path, noFollow);
|
|
272
|
+
try {
|
|
273
|
+
const before = await handle.stat({ bigint: true });
|
|
274
|
+
if (!before.isFile()
|
|
275
|
+
|| before.dev.toString() !== expected.dev
|
|
276
|
+
|| before.ino.toString() !== expected.ino
|
|
277
|
+
|| before.size !== BigInt(expected.bytes)) {
|
|
278
|
+
throw new ReclaimConflictError(`Prepared file changed before fsync: ${path}`);
|
|
279
|
+
}
|
|
280
|
+
await handle.sync();
|
|
281
|
+
await io.afterFileSync?.(path);
|
|
282
|
+
const after = await handle.stat({ bigint: true });
|
|
283
|
+
if (before.dev !== after.dev
|
|
284
|
+
|| before.ino !== after.ino
|
|
285
|
+
|| before.size !== after.size
|
|
286
|
+
|| before.mtimeNs !== after.mtimeNs
|
|
287
|
+
|| before.ctimeNs !== after.ctimeNs
|
|
288
|
+
|| before.nlink !== after.nlink) {
|
|
289
|
+
throw new ReclaimConflictError(`Prepared file changed while it was being fsynced: ${path}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await handle.close();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Publish a private, complete temp file at destination using create-if-absent
|
|
298
|
+
* semantics. Both paths must be on the same filesystem. EEXIST never removes
|
|
299
|
+
* or overwrites the destination.
|
|
300
|
+
*/
|
|
301
|
+
export async function publishTempCreateIfAbsent(tempPath, destinationPath, expected, io = {}) {
|
|
302
|
+
const source = resolve(tempPath);
|
|
303
|
+
const destination = resolve(destinationPath);
|
|
304
|
+
if (source === destination)
|
|
305
|
+
throw new ReclaimConflictError("Temp and destination paths must differ");
|
|
306
|
+
await assertRegularNoFollow(source);
|
|
307
|
+
const measured = await fingerprintRegularFile(source, io);
|
|
308
|
+
assertExpected(measured, expected, `Temporary file ${source}`);
|
|
309
|
+
// A durable directory entry cannot compensate for an unflushed inode. The
|
|
310
|
+
// prepared pointer must survive power loss before the bulky original can be
|
|
311
|
+
// irreversibly unlinked.
|
|
312
|
+
await syncExactRegularFile(source, measured, io);
|
|
313
|
+
await link(source, destination);
|
|
314
|
+
try {
|
|
315
|
+
const published = await fingerprintRegularFile(destination, io);
|
|
316
|
+
if (!sameIdentity(measured, published)) {
|
|
317
|
+
throw new ReclaimConflictError(`Published path is not the prepared file: ${destination}`);
|
|
318
|
+
}
|
|
319
|
+
// Make the destination name durable before dropping the temp name.
|
|
320
|
+
await syncDirectory(dirname(destination));
|
|
321
|
+
await unlink(source);
|
|
322
|
+
await syncDirectory(dirname(source));
|
|
323
|
+
return published;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
// The source hard link is retained on every failure, so no bytes are lost.
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Move an existing file to a unique park path without overwriting either name,
|
|
332
|
+
* then validate its content. On a digest mismatch the function restores the
|
|
333
|
+
* source name when possible and otherwise leaves the parked bytes intact.
|
|
334
|
+
*/
|
|
335
|
+
export async function parkExistingPathNoClobber(sourcePath, parkPath, expected, io = {}) {
|
|
336
|
+
const source = resolve(sourcePath);
|
|
337
|
+
const parked = resolve(parkPath);
|
|
338
|
+
if (source === parked)
|
|
339
|
+
throw new ReclaimConflictError("Source and park paths must differ");
|
|
340
|
+
await assertRegularNoFollow(source);
|
|
341
|
+
const before = await fingerprintRegularFile(source, io);
|
|
342
|
+
await link(source, parked);
|
|
343
|
+
const linked = await fingerprintRegularFile(parked, io);
|
|
344
|
+
if (!sameIdentity(before, linked)) {
|
|
345
|
+
// A namespace race occurred after link(). Do not guess which inode is the
|
|
346
|
+
// user's winner: both source and the deterministic park name are retained.
|
|
347
|
+
throw new ReclaimConflictError(`Parked path is not the source file; both names were retained: ${parked}`);
|
|
348
|
+
}
|
|
349
|
+
// Persist the contents of the exact inode now named by the recovery path,
|
|
350
|
+
// not merely the directory entry. A directory fsync cannot make dirty file
|
|
351
|
+
// data durable. The open-fd pre/post checks also reject a concurrent append.
|
|
352
|
+
await syncExactRegularFile(parked, linked, io);
|
|
353
|
+
await assertFingerprintMetadataUnchanged(parked, linked, `Parked file changed before its durable name was committed; both names were retained: ${parked}`);
|
|
354
|
+
// The parked name must reach stable storage before source is removed.
|
|
355
|
+
await syncDirectory(dirname(parked));
|
|
356
|
+
// fsync can be slow enough for a native writer to recreate the source name.
|
|
357
|
+
// Re-check immediately before unlink; a different inode always wins and both
|
|
358
|
+
// it and the parked bytes remain untouched. Node has no unlinkat-by-fd, so a
|
|
359
|
+
// hostile same-user swap in the syscall-sized gap cannot be eliminated here.
|
|
360
|
+
const sourceBeforeUnlink = await identityRegularNoFollow(source);
|
|
361
|
+
if (!sameIdentity(linked, sourceBeforeUnlink)) {
|
|
362
|
+
throw new ReclaimConflictError(`Source was recreated while parking; both names were retained: ${source}`);
|
|
363
|
+
}
|
|
364
|
+
await assertFingerprintMetadataUnchanged(parked, linked, `Parked file changed before source unlink; both names were retained: ${parked}`);
|
|
365
|
+
await unlink(source);
|
|
366
|
+
await syncDirectory(dirname(source));
|
|
367
|
+
const after = await fingerprintRegularFile(parked, io);
|
|
368
|
+
if (!fingerprintMatches(after, expected)) {
|
|
369
|
+
try {
|
|
370
|
+
await restoreParkedCreateIfAbsent(parked, source);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// A concurrently created source wins. The unexpected parked bytes remain
|
|
374
|
+
// addressable; deleting either side here would lose user data.
|
|
375
|
+
}
|
|
376
|
+
throw new ReclaimConflictError(`Parked file changed or did not match expectation: ${source}`);
|
|
377
|
+
}
|
|
378
|
+
return after;
|
|
379
|
+
}
|
|
380
|
+
/** Restore a parked regular file at destination with create-if-absent semantics. */
|
|
381
|
+
export async function restoreParkedCreateIfAbsent(parkPath, destinationPath, expected, io = {}) {
|
|
382
|
+
const parked = resolve(parkPath);
|
|
383
|
+
const destination = resolve(destinationPath);
|
|
384
|
+
if (parked === destination)
|
|
385
|
+
throw new ReclaimConflictError("Park and destination paths must differ");
|
|
386
|
+
await assertRegularNoFollow(parked);
|
|
387
|
+
const before = await fingerprintRegularFile(parked, io);
|
|
388
|
+
if (expected)
|
|
389
|
+
assertExpected(before, expected, `Parked file ${parked}`);
|
|
390
|
+
await link(parked, destination);
|
|
391
|
+
const restored = await fingerprintRegularFile(destination, io);
|
|
392
|
+
if (!sameIdentity(before, restored)) {
|
|
393
|
+
throw new ReclaimConflictError(`Restored path is not the parked file: ${destination}`);
|
|
394
|
+
}
|
|
395
|
+
// Persist the restored name before releasing the parked recovery name.
|
|
396
|
+
await syncDirectory(dirname(destination));
|
|
397
|
+
await unlink(parked);
|
|
398
|
+
await syncDirectory(dirname(parked));
|
|
399
|
+
return restored;
|
|
400
|
+
}
|
|
401
|
+
function journalDirectory(dataDir) {
|
|
402
|
+
return join(resolve(dataDir), "reclaim-transactions");
|
|
403
|
+
}
|
|
404
|
+
export function reclaimJournalPath(dataDir, transactionId) {
|
|
405
|
+
if (!TRANSACTION_ID_RE.test(transactionId))
|
|
406
|
+
throw new ReclaimConflictError("Invalid reclaim transaction id");
|
|
407
|
+
return join(journalDirectory(dataDir), `${transactionId}.json`);
|
|
408
|
+
}
|
|
409
|
+
export function reclaimTrashPath(dataDir, transactionId, sourcePath) {
|
|
410
|
+
if (!TRANSACTION_ID_RE.test(transactionId))
|
|
411
|
+
throw new ReclaimConflictError("Invalid reclaim transaction id");
|
|
412
|
+
return join(resolve(dataDir), "trash", "reclaim-transactions", transactionId, basename(resolve(sourcePath)));
|
|
413
|
+
}
|
|
414
|
+
function journalWire(transaction) {
|
|
415
|
+
return {
|
|
416
|
+
version: JOURNAL_VERSION,
|
|
417
|
+
id: transaction.id,
|
|
418
|
+
sourcePath: transaction.sourcePath,
|
|
419
|
+
trashPath: transaction.trashPath,
|
|
420
|
+
createdAt: transaction.createdAt,
|
|
421
|
+
updatedAt: transaction.updatedAt,
|
|
422
|
+
stage: transaction.stage,
|
|
423
|
+
original: transaction.original,
|
|
424
|
+
replacement: transaction.replacement,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
async function assertTransactionLock(transaction, lock) {
|
|
428
|
+
if (lock.dataDir !== transaction.dataDir || lock.sourcePath !== transaction.sourcePath) {
|
|
429
|
+
throw new ReclaimConflictError("Reclaim lock does not cover this transaction");
|
|
430
|
+
}
|
|
431
|
+
await lock.assertOwned();
|
|
432
|
+
}
|
|
433
|
+
async function updateJournal(transaction, stage) {
|
|
434
|
+
transaction.stage = stage;
|
|
435
|
+
transaction.updatedAt = new Date().toISOString();
|
|
436
|
+
await writeJsonDurably(transaction.journalPath, journalWire(transaction), false);
|
|
437
|
+
return transaction;
|
|
438
|
+
}
|
|
439
|
+
export async function beginReclaimTransaction(dataDir, lock, input, io = {}) {
|
|
440
|
+
const requestedReplacement = input.replacement ?? null;
|
|
441
|
+
if (!expectedIsValid(input.original) || (requestedReplacement !== null && !expectedIsValid(requestedReplacement))) {
|
|
442
|
+
throw new ReclaimConflictError("Invalid reclaim file descriptor");
|
|
443
|
+
}
|
|
444
|
+
const canonicalDataDir = resolve(dataDir);
|
|
445
|
+
const sourcePath = resolve(input.sourcePath);
|
|
446
|
+
const tempPath = requestedReplacement ? resolve(requestedReplacement.tempPath) : null;
|
|
447
|
+
if (canonicalDataDir !== lock.dataDir || sourcePath !== lock.sourcePath) {
|
|
448
|
+
throw new ReclaimConflictError("Reclaim lock does not cover the requested source");
|
|
449
|
+
}
|
|
450
|
+
await lock.assertOwned();
|
|
451
|
+
if (tempPath && dirname(tempPath) !== dirname(sourcePath)) {
|
|
452
|
+
throw new ReclaimConflictError("Replacement temp must be beside its destination");
|
|
453
|
+
}
|
|
454
|
+
const original = await fingerprintRegularFile(sourcePath, io);
|
|
455
|
+
assertExpected(original, input.original, `Reclaim source ${sourcePath}`);
|
|
456
|
+
if (original.links !== 1) {
|
|
457
|
+
throw new ReclaimConflictError(`Reclaim source has ${original.links} hard links, so deleting this pathname would not truthfully free its allocation: ${sourcePath}`);
|
|
458
|
+
}
|
|
459
|
+
const replacementFingerprint = tempPath && requestedReplacement
|
|
460
|
+
? await fingerprintRegularFile(tempPath, io)
|
|
461
|
+
: null;
|
|
462
|
+
if (replacementFingerprint && requestedReplacement) {
|
|
463
|
+
assertExpected(replacementFingerprint, requestedReplacement, `Reclaim replacement ${tempPath}`);
|
|
464
|
+
}
|
|
465
|
+
const id = input.transactionId ?? randomUUID();
|
|
466
|
+
if (!TRANSACTION_ID_RE.test(id))
|
|
467
|
+
throw new ReclaimConflictError("Invalid reclaim transaction id");
|
|
468
|
+
const createdAt = new Date().toISOString();
|
|
469
|
+
const transaction = {
|
|
470
|
+
version: JOURNAL_VERSION,
|
|
471
|
+
id,
|
|
472
|
+
dataDir: canonicalDataDir,
|
|
473
|
+
journalPath: reclaimJournalPath(canonicalDataDir, id),
|
|
474
|
+
sourcePath,
|
|
475
|
+
trashPath: reclaimTrashPath(canonicalDataDir, id, sourcePath),
|
|
476
|
+
createdAt,
|
|
477
|
+
updatedAt: createdAt,
|
|
478
|
+
stage: "prepared",
|
|
479
|
+
original,
|
|
480
|
+
replacement: replacementFingerprint && tempPath ? {
|
|
481
|
+
...replacementFingerprint,
|
|
482
|
+
tempPath,
|
|
483
|
+
} : null,
|
|
484
|
+
};
|
|
485
|
+
await ensurePrivateDirectory(dirname(transaction.trashPath));
|
|
486
|
+
// Exclusive durable publication is deliberately the final operation here:
|
|
487
|
+
// no source namespace mutation is allowed before the recovery journal exists.
|
|
488
|
+
await writeJsonDurably(transaction.journalPath, journalWire(transaction), true);
|
|
489
|
+
return transaction;
|
|
490
|
+
}
|
|
491
|
+
export async function moveReclaimSource(transaction, lock, io = {}) {
|
|
492
|
+
await assertTransactionLock(transaction, lock);
|
|
493
|
+
await parkExistingPathNoClobber(transaction.sourcePath, transaction.trashPath, transaction.original, io);
|
|
494
|
+
return updateJournal(transaction, "source-moved");
|
|
495
|
+
}
|
|
496
|
+
export async function installReclaimReplacement(transaction, lock, io = {}) {
|
|
497
|
+
await assertTransactionLock(transaction, lock);
|
|
498
|
+
if (!transaction.replacement) {
|
|
499
|
+
throw new ReclaimConflictError("This reclaim transaction has no replacement to install");
|
|
500
|
+
}
|
|
501
|
+
await publishTempCreateIfAbsent(transaction.replacement.tempPath, transaction.sourcePath, transaction.replacement, io);
|
|
502
|
+
return updateJournal(transaction, "replacement-installed");
|
|
503
|
+
}
|
|
504
|
+
export async function markReclaimMetadataCommitted(transaction, lock) {
|
|
505
|
+
await assertTransactionLock(transaction, lock);
|
|
506
|
+
return updateJournal(transaction, "metadata-committed");
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Durable point of no return. Call this only after record metadata and the
|
|
510
|
+
* append-only audit decision have both committed successfully.
|
|
511
|
+
*/
|
|
512
|
+
export async function markReclaimPurgeAuthorized(transaction, lock) {
|
|
513
|
+
await assertTransactionLock(transaction, lock);
|
|
514
|
+
if (transaction.stage !== "metadata-committed") {
|
|
515
|
+
throw new ReclaimConflictError(`Cannot authorize purge from stage ${transaction.stage}`);
|
|
516
|
+
}
|
|
517
|
+
return updateJournal(transaction, "purge-authorized");
|
|
518
|
+
}
|
|
519
|
+
async function removeExactTempDuplicate(transaction, io = {}) {
|
|
520
|
+
if (!transaction.replacement)
|
|
521
|
+
return;
|
|
522
|
+
const temp = await fingerprintOrNull(transaction.replacement.tempPath, io);
|
|
523
|
+
if (!temp || !fingerprintMatches(temp, transaction.replacement))
|
|
524
|
+
return;
|
|
525
|
+
const source = await fingerprintOrNull(transaction.sourcePath, io);
|
|
526
|
+
if (source && sameIdentity(source, temp))
|
|
527
|
+
await removeDurably(transaction.replacement.tempPath);
|
|
528
|
+
}
|
|
529
|
+
async function removeOwnedReplacementTemp(transaction, io = {}) {
|
|
530
|
+
if (!transaction.replacement)
|
|
531
|
+
return;
|
|
532
|
+
const temp = await fingerprintOrNull(transaction.replacement.tempPath, io);
|
|
533
|
+
if (temp && fingerprintMatches(temp, transaction.replacement)) {
|
|
534
|
+
await removeDurably(transaction.replacement.tempPath);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
export async function finalizeReclaimTransaction(transaction, lock, options = {}) {
|
|
538
|
+
await assertTransactionLock(transaction, lock);
|
|
539
|
+
if (transaction.stage !== "purge-authorized") {
|
|
540
|
+
throw new ReclaimConflictError("The staged original cannot be purged before metadata and audit are committed");
|
|
541
|
+
}
|
|
542
|
+
const source = await fingerprintOrNull(transaction.sourcePath, options.io);
|
|
543
|
+
if (transaction.replacement) {
|
|
544
|
+
if (!source)
|
|
545
|
+
throw new ReclaimConflictError(`Installed reclaim replacement is missing: ${transaction.sourcePath}`);
|
|
546
|
+
assertExpected(source, transaction.replacement, `Installed reclaim replacement ${transaction.sourcePath}`);
|
|
547
|
+
}
|
|
548
|
+
else if (source) {
|
|
549
|
+
throw new ReclaimConflictError(`A new source appeared before purge; refusing to delete staged bytes: ${transaction.sourcePath}`);
|
|
550
|
+
}
|
|
551
|
+
const trash = await fingerprintOrNull(transaction.trashPath, options.io);
|
|
552
|
+
if (trash && (!sameIdentity(trash, transaction.original) || !fingerprintMatches(trash, transaction.original))) {
|
|
553
|
+
throw new ReclaimConflictError(`Reclaim staging is not the exact journaled original inode: ${transaction.trashPath}`);
|
|
554
|
+
}
|
|
555
|
+
if (trash && trash.links !== 1) {
|
|
556
|
+
throw new ReclaimConflictError(`Reclaim staging has ${trash.links} hard links, so unlinking it would not truthfully free its allocation: ${transaction.trashPath}`);
|
|
557
|
+
}
|
|
558
|
+
await removeExactTempDuplicate(transaction, options.io);
|
|
559
|
+
const replacementAllocatedBytes = source?.allocatedBytes ?? 0;
|
|
560
|
+
let releasedSourceAllocation = 0;
|
|
561
|
+
if (trash) {
|
|
562
|
+
if (!options.verifyPurgeStillSafe) {
|
|
563
|
+
throw new ReclaimConflictError("Reclaim cannot purge without a fresh external durability proof");
|
|
564
|
+
}
|
|
565
|
+
await options.verifyPurgeStillSafe(transaction);
|
|
566
|
+
await assertTransactionLock(transaction, lock);
|
|
567
|
+
// The provider proof can take minutes. Re-read inode metadata after it so
|
|
568
|
+
// an open native fd cannot append/rewrite between the accepted full hash
|
|
569
|
+
// above and unlink. This cheap check is adjacent to the irreversible call.
|
|
570
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
|
|
571
|
+
const stagedHandle = await open(transaction.trashPath, fsConstants.O_RDONLY | noFollow);
|
|
572
|
+
try {
|
|
573
|
+
const [current, currentPath] = await Promise.all([
|
|
574
|
+
stagedHandle.stat({ bigint: true }),
|
|
575
|
+
lstat(transaction.trashPath, { bigint: true }),
|
|
576
|
+
]);
|
|
577
|
+
const currentAllocated = current.blocks * 512n;
|
|
578
|
+
if (!current.isFile() || !currentPath.isFile()
|
|
579
|
+
|| current.dev !== currentPath.dev || current.ino !== currentPath.ino
|
|
580
|
+
|| current.dev.toString() !== trash.dev
|
|
581
|
+
|| current.ino.toString() !== trash.ino
|
|
582
|
+
|| current.size !== BigInt(trash.bytes)
|
|
583
|
+
|| current.mtimeNs.toString() !== trash.mtimeNs
|
|
584
|
+
|| current.ctimeNs.toString() !== trash.ctimeNs
|
|
585
|
+
|| currentAllocated > BigInt(Number.MAX_SAFE_INTEGER)
|
|
586
|
+
|| Number(currentAllocated) !== transaction.original.allocatedBytes
|
|
587
|
+
|| Number(current.nlink) !== 1 || Number(currentPath.nlink) !== 1) {
|
|
588
|
+
throw new ReclaimConflictError(`Reclaim staging changed during final durability proof: ${transaction.trashPath}`);
|
|
589
|
+
}
|
|
590
|
+
await options.io?.afterPurgeSnapshot?.(transaction.trashPath);
|
|
591
|
+
await unlink(transaction.trashPath);
|
|
592
|
+
await syncDirectory(dirname(transaction.trashPath));
|
|
593
|
+
// The open descriptor observes the inode after its staging name is gone.
|
|
594
|
+
// A hard link created in the final syscall-sized race means the blocks
|
|
595
|
+
// still exist elsewhere, so report zero rather than the logical length.
|
|
596
|
+
const after = await stagedHandle.stat({ bigint: true });
|
|
597
|
+
if (after.dev !== current.dev || after.ino !== current.ino) {
|
|
598
|
+
throw new ReclaimConflictError("The staged source inode changed while its allocation was measured");
|
|
599
|
+
}
|
|
600
|
+
releasedSourceAllocation = after.nlink === 0n ? Number(currentAllocated) : 0;
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
await stagedHandle.close();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// The purge-authorized journal remains durable across this irreversible
|
|
607
|
+
// unlink. If power fails afterwards, reconciliation observes the absent
|
|
608
|
+
// staging path, finishes final metadata, and only then removes the journal.
|
|
609
|
+
// If reconciliation finds an already-absent staging name, no durable
|
|
610
|
+
// post-unlink link-count observation survived the crash. Zero is the only
|
|
611
|
+
// truthful physical-space report in that window.
|
|
612
|
+
const freedBytes = releasedSourceAllocation > 0
|
|
613
|
+
? Math.max(0, releasedSourceAllocation - replacementAllocatedBytes)
|
|
614
|
+
: 0;
|
|
615
|
+
await options.onPurged?.(transaction, freedBytes);
|
|
616
|
+
await removeDurably(transaction.journalPath);
|
|
617
|
+
return { freedBytes };
|
|
618
|
+
}
|
|
619
|
+
function rollbackParkPath(transaction) {
|
|
620
|
+
return join(dirname(transaction.sourcePath), `.${basename(transaction.sourcePath)}.sealkeep-rollback-${transaction.id}`);
|
|
621
|
+
}
|
|
622
|
+
async function trashIsOriginal(transaction, trash) {
|
|
623
|
+
return sameIdentity(trash, transaction.original) || fingerprintMatches(trash, transaction.original);
|
|
624
|
+
}
|
|
625
|
+
async function rollbackUnderLock(transaction, io = {}, callbacks = {}) {
|
|
626
|
+
const source = await fingerprintOrNull(transaction.sourcePath, io);
|
|
627
|
+
const trash = await fingerprintOrNull(transaction.trashPath, io);
|
|
628
|
+
const parkPath = rollbackParkPath(transaction);
|
|
629
|
+
const parked = await fingerprintOrNull(parkPath, io);
|
|
630
|
+
if (parked && (!transaction.replacement || !fingerprintMatches(parked, transaction.replacement))) {
|
|
631
|
+
throw new ReclaimConflictError("Rollback park contains unexpected bytes and was retained");
|
|
632
|
+
}
|
|
633
|
+
if (source && trash && sameIdentity(source, trash)) {
|
|
634
|
+
// A kill between link() and unlink() left two names for the same inode. The
|
|
635
|
+
// source is already restored, including any writes through an open fd.
|
|
636
|
+
await removeDurably(transaction.trashPath);
|
|
637
|
+
await removeOwnedReplacementTemp(transaction, io);
|
|
638
|
+
await removeDurably(transaction.journalPath);
|
|
639
|
+
return parked
|
|
640
|
+
? `removed duplicate staging link; original source was already present; retained pointer recovery copy at ${parkPath}`
|
|
641
|
+
: "removed duplicate staging link; original source was already present";
|
|
642
|
+
}
|
|
643
|
+
if (source && sameIdentity(source, transaction.original) && !trash) {
|
|
644
|
+
await removeOwnedReplacementTemp(transaction, io);
|
|
645
|
+
await removeDurably(transaction.journalPath);
|
|
646
|
+
return parked
|
|
647
|
+
? `original source was never moved; retained pointer recovery copy at ${parkPath}`
|
|
648
|
+
: "original source was never moved";
|
|
649
|
+
}
|
|
650
|
+
if (!trash || !(await trashIsOriginal(transaction, trash))) {
|
|
651
|
+
throw new ReclaimConflictError("Cannot roll back because the journaled original is unavailable");
|
|
652
|
+
}
|
|
653
|
+
if (!source) {
|
|
654
|
+
// Identity, rather than the old digest, intentionally authorizes this
|
|
655
|
+
// restore. An open writer may have appended after the source was parked;
|
|
656
|
+
// those bytes are part of the native transcript and must not be discarded.
|
|
657
|
+
await restoreParkedCreateIfAbsent(transaction.trashPath, transaction.sourcePath, undefined, io);
|
|
658
|
+
await removeOwnedReplacementTemp(transaction, io);
|
|
659
|
+
await removeDurably(transaction.journalPath);
|
|
660
|
+
return parked
|
|
661
|
+
? `restored original from reclaim staging; retained pointer recovery copy at ${parkPath}`
|
|
662
|
+
: "restored original from reclaim staging";
|
|
663
|
+
}
|
|
664
|
+
if (!transaction.replacement || !fingerprintMatches(source, transaction.replacement)) {
|
|
665
|
+
throw new ReclaimConflictError("Source contains unexpected bytes; rollback refused to overwrite it");
|
|
666
|
+
}
|
|
667
|
+
if (parked) {
|
|
668
|
+
if (!sameIdentity(source, parked)) {
|
|
669
|
+
throw new ReclaimConflictError("Both source and rollback park contain distinct files; rollback refused");
|
|
670
|
+
}
|
|
671
|
+
await removeDurably(transaction.sourcePath);
|
|
672
|
+
try {
|
|
673
|
+
await restoreParkedCreateIfAbsent(transaction.trashPath, transaction.sourcePath, undefined, io);
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
try {
|
|
677
|
+
await restoreParkedCreateIfAbsent(parkPath, transaction.sourcePath, transaction.replacement, io);
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
// Both names remain addressable if a contender created source.
|
|
681
|
+
}
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
await removeOwnedReplacementTemp(transaction, io);
|
|
685
|
+
await removeDurably(transaction.journalPath);
|
|
686
|
+
return `finished rollback interrupted between parking and restoring the original; retained pointer recovery copy at ${parkPath}`;
|
|
687
|
+
}
|
|
688
|
+
await parkExistingPathNoClobber(transaction.sourcePath, parkPath, transaction.replacement, io);
|
|
689
|
+
await callbacks.afterReplacementParked?.(parkPath);
|
|
690
|
+
try {
|
|
691
|
+
await restoreParkedCreateIfAbsent(transaction.trashPath, transaction.sourcePath, undefined, io);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
try {
|
|
695
|
+
await restoreParkedCreateIfAbsent(parkPath, transaction.sourcePath, transaction.replacement, io);
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
// Both names remain addressable if a contender created source. Never
|
|
699
|
+
// delete the parked replacement in that case.
|
|
700
|
+
}
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
// Never unlink a parked pointer. Codex may have opened the old pathname
|
|
704
|
+
// before it was parked and can append a resumed turn through that fd after
|
|
705
|
+
// every digest check. Keeping this tiny, deterministic recovery copy is the
|
|
706
|
+
// only fail-closed choice available without cooperation from Codex.
|
|
707
|
+
await removeOwnedReplacementTemp(transaction, io);
|
|
708
|
+
await removeDurably(transaction.journalPath);
|
|
709
|
+
return `restored original from reclaim staging; retained pointer recovery copy at ${parkPath}`;
|
|
710
|
+
}
|
|
711
|
+
export async function rollbackReclaimTransaction(transaction, lock, options = {}) {
|
|
712
|
+
await assertTransactionLock(transaction, lock);
|
|
713
|
+
await rollbackUnderLock(transaction, options.io, {
|
|
714
|
+
afterReplacementParked: options.afterReplacementParked,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
function parseJournal(dataDir, journalPath, raw) {
|
|
718
|
+
let value;
|
|
719
|
+
try {
|
|
720
|
+
value = JSON.parse(raw);
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
throw new ReclaimConflictError(`Invalid reclaim journal JSON: ${journalPath}`);
|
|
724
|
+
}
|
|
725
|
+
if (value.version !== JOURNAL_VERSION
|
|
726
|
+
|| typeof value.id !== "string"
|
|
727
|
+
|| !TRANSACTION_ID_RE.test(value.id)
|
|
728
|
+
|| typeof value.sourcePath !== "string"
|
|
729
|
+
|| !isAbsolute(value.sourcePath)
|
|
730
|
+
|| typeof value.trashPath !== "string"
|
|
731
|
+
|| !isAbsolute(value.trashPath)
|
|
732
|
+
|| typeof value.createdAt !== "string"
|
|
733
|
+
|| typeof value.updatedAt !== "string"
|
|
734
|
+
|| !["prepared", "source-moved", "replacement-installed", "metadata-committed", "purge-authorized"].includes(String(value.stage))
|
|
735
|
+
|| !expectedIsValid(value.original)
|
|
736
|
+
|| typeof value.original.dev !== "string"
|
|
737
|
+
|| typeof value.original.ino !== "string"
|
|
738
|
+
|| !Number.isSafeInteger(value.original.allocatedBytes)
|
|
739
|
+
|| (value.original.allocatedBytes ?? -1) < 0
|
|
740
|
+
|| !Number.isSafeInteger(value.original.links)
|
|
741
|
+
|| (value.original.links ?? 0) < 1
|
|
742
|
+
|| typeof value.original.mtimeNs !== "string"
|
|
743
|
+
|| typeof value.original.ctimeNs !== "string"
|
|
744
|
+
|| (value.replacement !== null && (!expectedIsValid(value.replacement)
|
|
745
|
+
|| typeof value.replacement.dev !== "string"
|
|
746
|
+
|| typeof value.replacement.ino !== "string"
|
|
747
|
+
|| !Number.isSafeInteger(value.replacement.allocatedBytes)
|
|
748
|
+
|| (value.replacement.allocatedBytes ?? -1) < 0
|
|
749
|
+
|| !Number.isSafeInteger(value.replacement.links)
|
|
750
|
+
|| (value.replacement.links ?? 0) < 1
|
|
751
|
+
|| typeof value.replacement.mtimeNs !== "string"
|
|
752
|
+
|| typeof value.replacement.ctimeNs !== "string"
|
|
753
|
+
|| typeof value.replacement.tempPath !== "string"))) {
|
|
754
|
+
throw new ReclaimConflictError(`Invalid reclaim journal shape: ${journalPath}`);
|
|
755
|
+
}
|
|
756
|
+
const canonicalDataDir = resolve(dataDir);
|
|
757
|
+
const expectedJournal = reclaimJournalPath(canonicalDataDir, value.id);
|
|
758
|
+
const sourcePath = resolve(value.sourcePath);
|
|
759
|
+
const expectedTrash = reclaimTrashPath(canonicalDataDir, value.id, sourcePath);
|
|
760
|
+
const tempPath = value.replacement === null
|
|
761
|
+
? null
|
|
762
|
+
: resolve(value.replacement.tempPath);
|
|
763
|
+
if (resolve(journalPath) !== expectedJournal
|
|
764
|
+
|| resolve(value.trashPath) !== expectedTrash
|
|
765
|
+
|| (tempPath !== null && dirname(tempPath) !== dirname(sourcePath))) {
|
|
766
|
+
throw new ReclaimConflictError(`Reclaim journal contains non-deterministic paths: ${journalPath}`);
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
...value,
|
|
770
|
+
sourcePath,
|
|
771
|
+
trashPath: expectedTrash,
|
|
772
|
+
replacement: tempPath === null
|
|
773
|
+
? null
|
|
774
|
+
: { ...value.replacement, tempPath },
|
|
775
|
+
dataDir: canonicalDataDir,
|
|
776
|
+
journalPath: expectedJournal,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
async function completeCommittedUnderLock(transaction, io = {}) {
|
|
780
|
+
let source = await fingerprintOrNull(transaction.sourcePath, io);
|
|
781
|
+
const trash = await fingerprintOrNull(transaction.trashPath, io);
|
|
782
|
+
if (!trash || !(await trashIsOriginal(transaction, trash))) {
|
|
783
|
+
throw new ReclaimConflictError("Committed reclaim is missing its staged original");
|
|
784
|
+
}
|
|
785
|
+
if (source && sameIdentity(source, trash)) {
|
|
786
|
+
await removeDurably(transaction.sourcePath);
|
|
787
|
+
source = null;
|
|
788
|
+
}
|
|
789
|
+
if (!transaction.replacement) {
|
|
790
|
+
if (source)
|
|
791
|
+
throw new ReclaimConflictError("Committed reclaim source contains unexpected bytes");
|
|
792
|
+
return "completed committed source removal staging";
|
|
793
|
+
}
|
|
794
|
+
if (!source) {
|
|
795
|
+
await publishTempCreateIfAbsent(transaction.replacement.tempPath, transaction.sourcePath, transaction.replacement, io);
|
|
796
|
+
}
|
|
797
|
+
else if (!fingerprintMatches(source, transaction.replacement)) {
|
|
798
|
+
throw new ReclaimConflictError("Committed reclaim source contains unexpected bytes");
|
|
799
|
+
}
|
|
800
|
+
await removeExactTempDuplicate(transaction, io);
|
|
801
|
+
return "completed committed replacement before purging staged original";
|
|
802
|
+
}
|
|
803
|
+
export async function reconcileReclaimTransactions(dataDir, options) {
|
|
804
|
+
const canonicalDataDir = resolve(dataDir);
|
|
805
|
+
const directory = journalDirectory(canonicalDataDir);
|
|
806
|
+
const names = await readdir(directory).catch((error) => {
|
|
807
|
+
if (errorCode(error) === "ENOENT")
|
|
808
|
+
return [];
|
|
809
|
+
throw error;
|
|
810
|
+
});
|
|
811
|
+
const outcomes = [];
|
|
812
|
+
for (const name of names.sort()) {
|
|
813
|
+
if (!name.endsWith(".json"))
|
|
814
|
+
continue;
|
|
815
|
+
const journalPath = join(directory, name);
|
|
816
|
+
let transaction;
|
|
817
|
+
try {
|
|
818
|
+
await validateNoFollowPath(journalPath, [canonicalDataDir], { leaf: "file" });
|
|
819
|
+
transaction = parseJournal(canonicalDataDir, journalPath, await readFile(journalPath, "utf8"));
|
|
820
|
+
await validateNoFollowPath(transaction.trashPath, [canonicalDataDir], { leaf: "missing-or-file" });
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
outcomes.push({
|
|
824
|
+
id: name.slice(0, -5),
|
|
825
|
+
status: "conflict",
|
|
826
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
827
|
+
});
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
let lock;
|
|
831
|
+
try {
|
|
832
|
+
lock = await acquireReclaimSourceLock(canonicalDataDir, transaction.sourcePath, options.lock);
|
|
833
|
+
await options.validateTransaction(transaction);
|
|
834
|
+
const metadataCommitted = await options.isMetadataCommitted(transaction);
|
|
835
|
+
const trash = await fingerprintOrNull(transaction.trashPath, options.io);
|
|
836
|
+
try {
|
|
837
|
+
await options.validatePurgeCandidate?.(transaction);
|
|
838
|
+
}
|
|
839
|
+
catch (validationError) {
|
|
840
|
+
// Structural paths were already validated above, so byte rollback is
|
|
841
|
+
// safe here. This separate hook exists for upgrade policy/format gates:
|
|
842
|
+
// an older build may have prepared a source a newer installed agent can
|
|
843
|
+
// no longer resume. Never let that old decision cross the new boundary.
|
|
844
|
+
if (metadataCommitted) {
|
|
845
|
+
if (!trash || !options.clearMetadataCommit)
|
|
846
|
+
throw validationError;
|
|
847
|
+
await options.clearMetadataCommit(transaction);
|
|
848
|
+
}
|
|
849
|
+
const detail = await rollbackUnderLock(transaction, options.io);
|
|
850
|
+
outcomes.push({
|
|
851
|
+
id: transaction.id,
|
|
852
|
+
sourcePath: transaction.sourcePath,
|
|
853
|
+
status: metadataCommitted ? "rolled-back" : "cleaned",
|
|
854
|
+
detail: `${detail}; ${validationError instanceof Error ? validationError.message : String(validationError)}`,
|
|
855
|
+
});
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const trashGrewAfterSnapshot = trash
|
|
859
|
+
&& sameIdentity(trash, transaction.original)
|
|
860
|
+
&& !fingerprintMatches(trash, transaction.original);
|
|
861
|
+
if (metadataCommitted && trashGrewAfterSnapshot) {
|
|
862
|
+
if (!options.clearMetadataCommit) {
|
|
863
|
+
throw new ReclaimConflictError("Original grew after archival; clearing committed metadata is required before rollback");
|
|
864
|
+
}
|
|
865
|
+
await options.clearMetadataCommit(transaction);
|
|
866
|
+
const detail = await rollbackUnderLock(transaction, options.io);
|
|
867
|
+
outcomes.push({ id: transaction.id, sourcePath: transaction.sourcePath, status: "rolled-back", detail });
|
|
868
|
+
}
|
|
869
|
+
else if (metadataCommitted) {
|
|
870
|
+
const source = await fingerprintOrNull(transaction.sourcePath, options.io);
|
|
871
|
+
const temp = transaction.replacement
|
|
872
|
+
? await fingerprintOrNull(transaction.replacement.tempPath, options.io)
|
|
873
|
+
: null;
|
|
874
|
+
const replacementStillNeeded = transaction.replacement
|
|
875
|
+
? !source || Boolean(trash && sameIdentity(source, trash))
|
|
876
|
+
: Boolean(source);
|
|
877
|
+
const cannotCompleteReplacement = transaction.replacement
|
|
878
|
+
? replacementStillNeeded && (!temp || !fingerprintMatches(temp, transaction.replacement))
|
|
879
|
+
: replacementStillNeeded;
|
|
880
|
+
// Before the durable purge decision the original must still exist so
|
|
881
|
+
// any failure can restore it. Once purge-authorized is durable, an
|
|
882
|
+
// absent staging file means the unlink completed before the crash.
|
|
883
|
+
const originalMissingTooEarly = !trash && transaction.stage !== "purge-authorized";
|
|
884
|
+
if (cannotCompleteReplacement || originalMissingTooEarly) {
|
|
885
|
+
if (!options.clearMetadataCommit) {
|
|
886
|
+
throw new ReclaimConflictError("Committed reclaim cannot be completed safely; clearing metadata is required before rollback");
|
|
887
|
+
}
|
|
888
|
+
await options.clearMetadataCommit(transaction);
|
|
889
|
+
const detail = await rollbackUnderLock(transaction, options.io);
|
|
890
|
+
outcomes.push({ id: transaction.id, sourcePath: transaction.sourcePath, status: "rolled-back", detail });
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
let detail = transaction.stage === "purge-authorized" && !trash
|
|
894
|
+
? "recognized an already-purged original"
|
|
895
|
+
: await completeCommittedUnderLock(transaction, options.io);
|
|
896
|
+
try {
|
|
897
|
+
if (transaction.stage !== "purge-authorized") {
|
|
898
|
+
// The metadata mutation can land before its directory fsync
|
|
899
|
+
// reports success, leaving an older journal stage. Normalize it
|
|
900
|
+
// before the audit and irreversible boundary.
|
|
901
|
+
if (transaction.stage !== "metadata-committed") {
|
|
902
|
+
await updateJournal(transaction, "metadata-committed");
|
|
903
|
+
}
|
|
904
|
+
if (!options.ensureAuditCommitted) {
|
|
905
|
+
throw new ReclaimConflictError("Committed reclaim cannot purge without a durable audit callback");
|
|
906
|
+
}
|
|
907
|
+
await options.ensureAuditCommitted(transaction);
|
|
908
|
+
await markReclaimPurgeAuthorized(transaction, lock);
|
|
909
|
+
}
|
|
910
|
+
const finalized = await finalizeReclaimTransaction(transaction, lock, {
|
|
911
|
+
onPurged: options.onPurged,
|
|
912
|
+
verifyPurgeStillSafe: options.verifyPurgeStillSafe,
|
|
913
|
+
io: options.io,
|
|
914
|
+
});
|
|
915
|
+
detail += `; permanently purged ${finalized.freedBytes} local bytes`;
|
|
916
|
+
outcomes.push({ id: transaction.id, sourcePath: transaction.sourcePath, status: "completed", detail });
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
// If the irreversible unlink has not happened, an audit or final
|
|
920
|
+
// verification failure is still fully recoverable. If staging is
|
|
921
|
+
// already absent, the journal must remain for onPurged metadata to
|
|
922
|
+
// finish on the next run; pretending rollback is possible would
|
|
923
|
+
// erase the only durable account of the completed deletion.
|
|
924
|
+
let stagedNow;
|
|
925
|
+
try {
|
|
926
|
+
stagedNow = await fingerprintOrNull(transaction.trashPath, options.io);
|
|
927
|
+
}
|
|
928
|
+
catch (inspectionError) {
|
|
929
|
+
throw new AggregateError([error, inspectionError], "Reclaim failed and staging could not be inspected safely; the journal was retained");
|
|
930
|
+
}
|
|
931
|
+
if (stagedNow && options.clearMetadataCommit) {
|
|
932
|
+
await options.clearMetadataCommit(transaction);
|
|
933
|
+
const rollbackDetail = await rollbackUnderLock(transaction, options.io);
|
|
934
|
+
outcomes.push({ id: transaction.id, sourcePath: transaction.sourcePath, status: "rolled-back", detail: rollbackDetail });
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
throw error;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
const sourceBefore = await fingerprintOrNull(transaction.sourcePath, options.io);
|
|
944
|
+
const trashBefore = await fingerprintOrNull(transaction.trashPath, options.io);
|
|
945
|
+
const wasNeverMoved = Boolean(sourceBefore
|
|
946
|
+
&& fingerprintMatches(sourceBefore, transaction.original)
|
|
947
|
+
&& !trashBefore);
|
|
948
|
+
const detail = await rollbackUnderLock(transaction, options.io);
|
|
949
|
+
outcomes.push({
|
|
950
|
+
id: transaction.id,
|
|
951
|
+
sourcePath: transaction.sourcePath,
|
|
952
|
+
status: wasNeverMoved ? "cleaned" : "rolled-back",
|
|
953
|
+
detail,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch (error) {
|
|
958
|
+
outcomes.push({
|
|
959
|
+
id: transaction.id,
|
|
960
|
+
sourcePath: transaction.sourcePath,
|
|
961
|
+
status: "conflict",
|
|
962
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
finally {
|
|
966
|
+
await lock?.release().catch(() => undefined);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return outcomes;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Resolve a native-session path beneath an allowed root while rejecting
|
|
973
|
+
* symlinks in the root or any existing descendant component. The canonical
|
|
974
|
+
* root must equal its lexical path, so a symlinked native root is not trusted.
|
|
975
|
+
*/
|
|
976
|
+
export async function validateNoFollowPath(candidatePath, roots, options = {}) {
|
|
977
|
+
const candidate = resolve(candidatePath);
|
|
978
|
+
const leafMode = options.leaf ?? "file";
|
|
979
|
+
for (const suppliedRoot of roots) {
|
|
980
|
+
const root = resolve(suppliedRoot);
|
|
981
|
+
if (!pathWithin(candidate, root))
|
|
982
|
+
continue;
|
|
983
|
+
const rootInfo = await lstat(root).catch(() => null);
|
|
984
|
+
if (!rootInfo || rootInfo.isSymbolicLink() || !rootInfo.isDirectory())
|
|
985
|
+
continue;
|
|
986
|
+
const canonicalRoot = await realpath(root).catch(() => null);
|
|
987
|
+
if (!canonicalRoot || canonicalRoot !== root)
|
|
988
|
+
continue;
|
|
989
|
+
const pieces = relative(root, candidate).split(sep).filter(Boolean);
|
|
990
|
+
if (pieces.length === 0)
|
|
991
|
+
continue;
|
|
992
|
+
let cursor = root;
|
|
993
|
+
let valid = true;
|
|
994
|
+
for (let index = 0; index < pieces.length; index += 1) {
|
|
995
|
+
cursor = join(cursor, pieces[index]);
|
|
996
|
+
const isLeaf = index === pieces.length - 1;
|
|
997
|
+
const info = await lstat(cursor).catch((error) => {
|
|
998
|
+
if (errorCode(error) === "ENOENT")
|
|
999
|
+
return null;
|
|
1000
|
+
throw error;
|
|
1001
|
+
});
|
|
1002
|
+
if (!info) {
|
|
1003
|
+
if (isLeaf && leafMode === "missing-or-file")
|
|
1004
|
+
break;
|
|
1005
|
+
valid = false;
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
if (info.isSymbolicLink() || (!isLeaf && !info.isDirectory()) || (isLeaf && !info.isFile())) {
|
|
1009
|
+
valid = false;
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (!valid)
|
|
1014
|
+
continue;
|
|
1015
|
+
const parent = pieces.length === 0 ? root : dirname(candidate);
|
|
1016
|
+
const canonicalParent = await realpath(parent).catch(() => null);
|
|
1017
|
+
if (!canonicalParent || !pathWithin(canonicalParent, canonicalRoot))
|
|
1018
|
+
continue;
|
|
1019
|
+
if (leafMode === "file") {
|
|
1020
|
+
const canonicalCandidate = await realpath(candidate).catch(() => null);
|
|
1021
|
+
if (!canonicalCandidate || !pathWithin(canonicalCandidate, canonicalRoot))
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
return candidate;
|
|
1025
|
+
}
|
|
1026
|
+
throw new ReclaimConflictError(`Path is not a no-follow regular file under an allowed root: ${candidate}`);
|
|
1027
|
+
}
|