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.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /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
+ }