@spacefast/common 0.0.13 → 0.0.18

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 (154) hide show
  1. package/dist/agents/client-registry.d.ts +22 -0
  2. package/dist/agents/client-registry.js +41 -0
  3. package/dist/agents/connect-targets.d.ts +51 -6
  4. package/dist/agents/connect-targets.js +93 -28
  5. package/dist/brand.d.ts +0 -11
  6. package/dist/brand.js +0 -20
  7. package/dist/config/domains.d.ts +15 -6
  8. package/dist/config/domains.js +24 -10
  9. package/dist/config/postgres-budget.d.ts +3 -0
  10. package/dist/config/postgres-budget.js +6 -1
  11. package/dist/config/postgres-budget.json +2 -1
  12. package/dist/contracts/abuse.js +1 -1
  13. package/dist/contracts/access.d.ts +149 -44
  14. package/dist/contracts/access.js +94 -8
  15. package/dist/contracts/activity.js +5 -1
  16. package/dist/contracts/api-keys.js +9 -9
  17. package/dist/contracts/archives.js +11 -11
  18. package/dist/contracts/beta.js +1 -1
  19. package/dist/contracts/builds.js +14 -14
  20. package/dist/contracts/channels.js +2 -2
  21. package/dist/contracts/comments.d.ts +86 -15
  22. package/dist/contracts/comments.js +116 -20
  23. package/dist/contracts/common.d.ts +12 -12
  24. package/dist/contracts/common.js +33 -30
  25. package/dist/contracts/continuation.js +5 -5
  26. package/dist/contracts/data-source-routes.js +1 -1
  27. package/dist/contracts/device-auth.d.ts +6 -6
  28. package/dist/contracts/device-auth.js +12 -12
  29. package/dist/contracts/docs.d.ts +10 -10
  30. package/dist/contracts/domains.js +39 -33
  31. package/dist/contracts/enums.d.ts +2 -10
  32. package/dist/contracts/enums.js +0 -2
  33. package/dist/contracts/error-code-meta.d.ts +116 -47
  34. package/dist/contracts/error-code-meta.js +37 -16
  35. package/dist/contracts/error-codes.d.ts +7 -4
  36. package/dist/contracts/error-codes.js +46 -21
  37. package/dist/contracts/events.d.ts +10 -3
  38. package/dist/contracts/events.js +33 -5
  39. package/dist/contracts/execution.d.ts +26 -0
  40. package/dist/contracts/execution.js +21 -0
  41. package/dist/contracts/feature-lifecycle.d.ts +21 -24
  42. package/dist/contracts/feature-lifecycle.js +28 -26
  43. package/dist/contracts/features.js +4 -4
  44. package/dist/contracts/functions.d.ts +8 -140
  45. package/dist/contracts/functions.js +9 -19
  46. package/dist/contracts/git.d.ts +1 -1
  47. package/dist/contracts/grants.d.ts +96 -79
  48. package/dist/contracts/grants.js +130 -43
  49. package/dist/contracts/ids.d.ts +2 -2
  50. package/dist/contracts/ids.js +2 -4
  51. package/dist/contracts/internal.js +2 -2
  52. package/dist/contracts/mcp.d.ts +17 -556
  53. package/dist/contracts/mcp.js +9 -103
  54. package/dist/contracts/me.js +3 -4
  55. package/dist/contracts/notifications.d.ts +108 -0
  56. package/dist/contracts/notifications.js +54 -0
  57. package/dist/contracts/oauth-resources.d.ts +18 -1
  58. package/dist/contracts/oauth-resources.js +22 -2
  59. package/dist/contracts/operations.d.ts +2 -50
  60. package/dist/contracts/operations.js +0 -19
  61. package/dist/contracts/pages.d.ts +8 -4
  62. package/dist/contracts/pages.js +16 -3
  63. package/dist/contracts/platform.js +10 -8
  64. package/dist/contracts/problem-document.d.ts +14 -0
  65. package/dist/contracts/problem-document.js +35 -0
  66. package/dist/contracts/push-new.d.ts +21 -6
  67. package/dist/contracts/push-new.js +3 -7
  68. package/dist/contracts/resources.d.ts +8 -7
  69. package/dist/contracts/resources.js +9 -6
  70. package/dist/contracts/runtime-api.d.ts +162 -92
  71. package/dist/contracts/runtime-api.js +99 -27
  72. package/dist/contracts/runtime-app.d.ts +41 -11
  73. package/dist/contracts/runtime-app.js +22 -1
  74. package/dist/contracts/runtime-db.d.ts +424 -0
  75. package/dist/contracts/runtime-db.js +108 -0
  76. package/dist/contracts/runtime-storage.d.ts +120 -0
  77. package/dist/contracts/runtime-storage.js +40 -0
  78. package/dist/contracts/sf-config-v1.d.ts +49 -1
  79. package/dist/contracts/sf-config-v1.js +2 -2
  80. package/dist/contracts/sites.d.ts +1 -0
  81. package/dist/contracts/sites.js +20 -12
  82. package/dist/contracts/space-config.d.ts +62 -22
  83. package/dist/contracts/space-config.js +173 -47
  84. package/dist/contracts/spaces.d.ts +79 -45
  85. package/dist/contracts/spaces.js +66 -50
  86. package/dist/contracts/superadmin-spaces.d.ts +13 -2
  87. package/dist/contracts/superadmin-spaces.js +15 -0
  88. package/dist/contracts/superadmin-tenants.js +4 -4
  89. package/dist/contracts/superadmin.d.ts +4 -0
  90. package/dist/contracts/superadmin.js +14 -1
  91. package/dist/contracts/tags.d.ts +17 -17
  92. package/dist/contracts/tags.js +1 -1
  93. package/dist/contracts/teams.js +25 -16
  94. package/dist/contracts/theme-json.js +1 -1
  95. package/dist/contracts/transfers.js +4 -4
  96. package/dist/contracts/variables.js +3 -3
  97. package/dist/contracts/zero.d.ts +204 -338
  98. package/dist/contracts/zero.js +94 -98
  99. package/dist/dashboard-paths/index.d.ts +16 -1
  100. package/dist/dashboard-paths/index.js +18 -0
  101. package/dist/docs/agent-handoff-document.d.ts +2 -2
  102. package/dist/docs/agent-handoff-document.js +8 -7
  103. package/dist/docs/agent-prose.d.ts +22 -3
  104. package/dist/docs/agent-prose.js +221 -51
  105. package/dist/docs/agent-setup.d.ts +13 -10
  106. package/dist/docs/agent-setup.js +279 -53
  107. package/dist/docs/agent-solutions.d.ts +6 -6
  108. package/dist/docs/agent-solutions.js +23 -21
  109. package/dist/docs/catalog.d.ts +29 -74
  110. package/dist/docs/catalog.js +65 -92
  111. package/dist/docs/error-docs.js +293 -221
  112. package/dist/docs/skill-distribution.d.ts +2 -0
  113. package/dist/docs/skill-distribution.js +7 -4
  114. package/dist/docs/skills.d.ts +546 -0
  115. package/dist/docs/skills.js +340 -0
  116. package/dist/utils/browser-credential.d.ts +19 -0
  117. package/dist/utils/browser-credential.js +62 -0
  118. package/dist/utils/comment-avatars.d.ts +7 -0
  119. package/dist/utils/comment-avatars.js +42 -0
  120. package/dist/utils/content-type.d.ts +3 -3
  121. package/dist/utils/content-type.js +38 -61
  122. package/dist/utils/domain-diagnostics.d.ts +5 -5
  123. package/dist/utils/domain-diagnostics.js +21 -10
  124. package/dist/utils/local-space-state.d.ts +78 -4
  125. package/dist/utils/local-space-state.js +383 -82
  126. package/dist/utils/one-shot-replay.d.ts +14 -0
  127. package/dist/utils/one-shot-replay.js +21 -0
  128. package/dist/utils/page-preview.d.ts +7 -0
  129. package/dist/utils/page-preview.js +9 -0
  130. package/dist/utils/pages.d.ts +2 -11
  131. package/dist/utils/pages.js +58 -14
  132. package/dist/utils/publish-form-data.d.ts +22 -0
  133. package/dist/utils/publish-form-data.js +26 -0
  134. package/dist/utils/publish-policy.js +20 -4
  135. package/dist/utils/query-keys.d.ts +7 -4
  136. package/dist/utils/query-keys.js +13 -4
  137. package/dist/utils/runtime-paths.d.ts +3 -0
  138. package/dist/utils/runtime-paths.js +7 -0
  139. package/dist/utils/secure-local-file.d.ts +73 -0
  140. package/dist/utils/secure-local-file.js +1373 -0
  141. package/dist/utils/space-config.d.ts +13 -4
  142. package/dist/utils/space-config.js +123 -15
  143. package/dist/utils/storage-policy.d.ts +20 -0
  144. package/dist/utils/storage-policy.fixtures.json +174 -0
  145. package/dist/utils/storage-policy.js +193 -0
  146. package/dist/utils/upload-session.d.ts +12 -1
  147. package/dist/utils/upload-session.js +37 -2
  148. package/dist/utils/version-file-browser.d.ts +13 -0
  149. package/dist/utils/version-file-browser.js +59 -0
  150. package/package.json +19 -1
  151. package/dist/contracts/mounts.d.ts +0 -142
  152. package/dist/contracts/mounts.js +0 -56
  153. package/dist/utils/asset-fingerprint.d.ts +0 -64
  154. package/dist/utils/asset-fingerprint.js +0 -620
@@ -0,0 +1,1373 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ /* oxlint-disable eslint/no-await-in-loop -- Secure traversal and identity validation must preserve filesystem component order. */
4
+ import { constants } from "node:fs";
5
+ import { copyFile, chmod, link, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, unlink, } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ export class SecureLocalFileError extends Error {
9
+ name = "SecureLocalFileError";
10
+ }
11
+ export class SecureLocalFileConflictError extends SecureLocalFileError {
12
+ name = "SecureLocalFileConflictError";
13
+ }
14
+ function writeConflict(message) {
15
+ return new SecureLocalFileConflictError(message);
16
+ }
17
+ function validationError(message) {
18
+ return new SecureLocalFileError(message);
19
+ }
20
+ function commonAncestor(left, right) {
21
+ const leftRoot = path.parse(left).root;
22
+ const rightRoot = path.parse(right).root;
23
+ if (process.platform === "win32"
24
+ ? leftRoot.toLowerCase() !== rightRoot.toLowerCase()
25
+ : leftRoot !== rightRoot) {
26
+ return path.dirname(right);
27
+ }
28
+ const leftParts = left.slice(leftRoot.length).split(path.sep).filter(Boolean);
29
+ const rightParts = right.slice(rightRoot.length).split(path.sep).filter(Boolean);
30
+ const common = [];
31
+ for (let index = 0; index < Math.min(leftParts.length, rightParts.length); index += 1) {
32
+ const leftPart = leftParts[index];
33
+ const rightPart = rightParts[index];
34
+ if (leftPart === undefined ||
35
+ rightPart === undefined ||
36
+ (process.platform === "win32"
37
+ ? leftPart.toLowerCase() !== rightPart.toLowerCase()
38
+ : leftPart !== rightPart)) {
39
+ break;
40
+ }
41
+ common.push(leftPart);
42
+ }
43
+ return path.join(leftRoot, ...common);
44
+ }
45
+ export function secureOutputLocation(trustedPath, outputPath) {
46
+ const trusted = path.resolve(trustedPath);
47
+ const output = path.resolve(outputPath);
48
+ const relativeToTrusted = path.relative(trusted, output);
49
+ const withinTrusted = relativeToTrusted !== ".." &&
50
+ !relativeToTrusted.startsWith(`..${path.sep}`) &&
51
+ !path.isAbsolute(relativeToTrusted);
52
+ const root = withinTrusted ? trusted : commonAncestor(trusted, path.dirname(output));
53
+ return { root, relativePath: path.relative(root, output) };
54
+ }
55
+ function noFollowDirectoryFlags() {
56
+ return constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
57
+ }
58
+ function noFollowCreateFlags() {
59
+ return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
60
+ }
61
+ function portableCreateFlags() {
62
+ return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
63
+ }
64
+ function portableReadFlags() {
65
+ return constants.O_RDONLY;
66
+ }
67
+ function isMissing(error) {
68
+ return error.code === "ENOENT";
69
+ }
70
+ function isAlreadyExists(error) {
71
+ return error.code === "EEXIST";
72
+ }
73
+ function descriptorPath(directory, child) {
74
+ const base = `/proc/self/fd/${directory.fd}`;
75
+ return child === undefined ? base : path.join(base, child);
76
+ }
77
+ function supportsDescriptorRelativeOutput() {
78
+ return (process.platform === "linux" &&
79
+ typeof constants.O_DIRECTORY === "number" &&
80
+ typeof constants.O_NOFOLLOW === "number");
81
+ }
82
+ function assertSafeComponent(component) {
83
+ if (!component || component === "." || component === ".." || component.includes(path.sep)) {
84
+ throw validationError(`Refusing unsafe output path component: ${component || "(empty)"}`);
85
+ }
86
+ }
87
+ async function directoryIdentity(directoryPath, handle) {
88
+ const stats = await handle.stat({ bigint: true });
89
+ if (!stats.isDirectory()) {
90
+ throw validationError(`Refusing non-directory output ancestor: ${directoryPath}`);
91
+ }
92
+ return {
93
+ path: directoryPath,
94
+ dev: stats.dev,
95
+ ino: stats.ino,
96
+ };
97
+ }
98
+ async function openDirectory(directoryPath) {
99
+ const pathStats = await lstat(directoryPath);
100
+ if (pathStats.isSymbolicLink()) {
101
+ throw validationError(`Refusing symlinked output ancestor: ${directoryPath}`);
102
+ }
103
+ if (!pathStats.isDirectory()) {
104
+ throw validationError(`Refusing non-directory output ancestor: ${directoryPath}`);
105
+ }
106
+ try {
107
+ const handle = await open(directoryPath, noFollowDirectoryFlags());
108
+ return { handle, identity: await directoryIdentity(directoryPath, handle) };
109
+ }
110
+ catch (error) {
111
+ if (error.code === "ELOOP") {
112
+ throw validationError(`Refusing symlinked output ancestor: ${directoryPath}`);
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+ async function openRoot(root, create) {
118
+ const parsed = path.parse(root);
119
+ let visible = parsed.root;
120
+ let current = await openDirectory(parsed.root);
121
+ const identities = [current.identity];
122
+ try {
123
+ for (const component of root.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
124
+ const next = await openChildDirectory(current.handle, visible, component, create);
125
+ await current.handle.close();
126
+ current = next;
127
+ visible = path.join(visible, component);
128
+ identities.push(next.identity);
129
+ }
130
+ return { handle: current.handle, identities };
131
+ }
132
+ catch (error) {
133
+ await current.handle.close();
134
+ throw error;
135
+ }
136
+ }
137
+ async function openChildDirectory(parent, visibleParent, component, create) {
138
+ assertSafeComponent(component);
139
+ const childPath = descriptorPath(parent, component);
140
+ if (create) {
141
+ try {
142
+ await mkdir(childPath);
143
+ }
144
+ catch (error) {
145
+ if (!isAlreadyExists(error))
146
+ throw error;
147
+ }
148
+ }
149
+ const opened = await openDirectory(childPath);
150
+ return {
151
+ handle: opened.handle,
152
+ identity: await directoryIdentity(path.join(visibleParent, component), opened.handle),
153
+ };
154
+ }
155
+ async function validateDirectoryBindings(identities) {
156
+ for (const identity of identities) {
157
+ let stats;
158
+ try {
159
+ stats = await lstat(identity.path, { bigint: true });
160
+ }
161
+ catch (error) {
162
+ if (isMissing(error)) {
163
+ throw validationError(`Output ancestor changed during write: ${identity.path}`);
164
+ }
165
+ throw error;
166
+ }
167
+ if (stats.isSymbolicLink() ||
168
+ !stats.isDirectory() ||
169
+ stats.dev !== identity.dev ||
170
+ stats.ino !== identity.ino) {
171
+ throw validationError(`Output ancestor changed during write: ${identity.path}`);
172
+ }
173
+ }
174
+ }
175
+ async function visibleDirectoryIdentity(directoryPath) {
176
+ let stats;
177
+ try {
178
+ stats = await lstat(directoryPath, { bigint: true });
179
+ }
180
+ catch (error) {
181
+ if (isMissing(error)) {
182
+ throw validationError(`Output ancestor changed during write: ${directoryPath}`);
183
+ }
184
+ throw error;
185
+ }
186
+ if (stats.isSymbolicLink()) {
187
+ throw validationError(`Refusing symlinked output ancestor: ${directoryPath}`);
188
+ }
189
+ if (!stats.isDirectory()) {
190
+ throw validationError(`Refusing non-directory output ancestor: ${directoryPath}`);
191
+ }
192
+ return {
193
+ path: directoryPath,
194
+ dev: stats.dev,
195
+ ino: stats.ino,
196
+ };
197
+ }
198
+ async function openPortableRoot(root, create) {
199
+ const parsed = path.parse(root);
200
+ let visible = parsed.root;
201
+ const identities = [await visibleDirectoryIdentity(visible)];
202
+ for (const component of root.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
203
+ assertSafeComponent(component);
204
+ visible = path.join(visible, component);
205
+ if (create) {
206
+ try {
207
+ await mkdir(visible);
208
+ }
209
+ catch (error) {
210
+ if (!isAlreadyExists(error))
211
+ throw error;
212
+ }
213
+ }
214
+ identities.push(await visibleDirectoryIdentity(visible));
215
+ }
216
+ return identities;
217
+ }
218
+ async function canonicalPortableWriteRoot(rootInput) {
219
+ const missingComponents = [];
220
+ let existingAncestor = path.resolve(rootInput);
221
+ while (true) {
222
+ try {
223
+ return path.join(await realpath(existingAncestor), ...missingComponents);
224
+ }
225
+ catch (error) {
226
+ if (!isMissing(error))
227
+ throw error;
228
+ const parent = path.dirname(existingAncestor);
229
+ if (parent === existingAncestor)
230
+ throw error;
231
+ missingComponents.unshift(path.basename(existingAncestor));
232
+ existingAncestor = parent;
233
+ }
234
+ }
235
+ }
236
+ async function openPortableChildDirectory(identities, visibleParent, component, create) {
237
+ assertSafeComponent(component);
238
+ const childPath = path.join(visibleParent, component);
239
+ // A missing directory is the normal "file not found" result for secure
240
+ // reads. Probe before identity capture so callers can handle ENOENT. If the
241
+ // directory disappears after this probe, visibleDirectoryIdentity still
242
+ // fails closed because an observed ancestor binding changed mid-read.
243
+ if (!create) {
244
+ await lstat(childPath);
245
+ }
246
+ if (create) {
247
+ try {
248
+ await mkdir(childPath);
249
+ }
250
+ catch (error) {
251
+ if (!isAlreadyExists(error))
252
+ throw error;
253
+ }
254
+ }
255
+ const identity = await visibleDirectoryIdentity(childPath);
256
+ identities.push(identity);
257
+ await validateDirectoryBindings(identities);
258
+ return childPath;
259
+ }
260
+ function validateRelativePath(relativePathInput, operation) {
261
+ const relativePath = path.normalize(relativePathInput);
262
+ if (path.isAbsolute(relativePath) ||
263
+ relativePath === ".." ||
264
+ relativePath.startsWith(`..${path.sep}`)) {
265
+ throw validationError(`Refusing to ${operation} outside ${operation === "read" ? "local root" : "output directory"}: ${relativePathInput}`);
266
+ }
267
+ return relativePath;
268
+ }
269
+ function lockName(leaf) {
270
+ const identity = process.platform === "win32" || process.platform === "darwin"
271
+ ? leaf.normalize("NFC").toLowerCase()
272
+ : leaf;
273
+ const digest = createHash("sha256").update(identity).digest("hex").slice(0, 24);
274
+ return `.spacefast-${digest}.lock`;
275
+ }
276
+ const LEGACY_LEAF_LOCK_STALE_MS = 2 * 60_000;
277
+ function serializeLeafLockOwner(owner) {
278
+ const { phase, ...identity } = owner;
279
+ return `${phase === "active" ? "A" : "R"}${JSON.stringify(identity)}\n`;
280
+ }
281
+ function parseLeafLockOwner(raw) {
282
+ try {
283
+ const phase = raw[0] === "A" ? "active" : raw[0] === "R" ? "released" : null;
284
+ const value = JSON.parse(raw.slice(1));
285
+ if (phase &&
286
+ Number.isSafeInteger(value.pid) &&
287
+ (value.pid ?? 0) > 0 &&
288
+ typeof value.nonce === "string" &&
289
+ value.nonce.length > 0 &&
290
+ typeof value.processStartIdentity === "string" &&
291
+ value.processStartIdentity.length > 0 &&
292
+ typeof value.createdAt === "string" &&
293
+ (value.temporaryName === undefined ||
294
+ (typeof value.temporaryName === "string" &&
295
+ /^\.spacefast-[0-9a-f-]{36}\.tmp$/.test(value.temporaryName)))) {
296
+ return { ...value, phase };
297
+ }
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ return null;
303
+ }
304
+ const PROCESS_IDENTITY_TIMEOUT_MS = 5_000;
305
+ let selfStartIdentity;
306
+ function commandStdout(command, args) {
307
+ return new Promise((resolve) => {
308
+ execFile(command, args, {
309
+ encoding: "utf8",
310
+ killSignal: "SIGKILL",
311
+ timeout: PROCESS_IDENTITY_TIMEOUT_MS,
312
+ }, (error, stdout) => resolve(error ? null : stdout));
313
+ });
314
+ }
315
+ function processStartIdentity(pid) {
316
+ if (pid === process.pid) {
317
+ selfStartIdentity ??= resolveProcessStartIdentity(pid);
318
+ return selfStartIdentity;
319
+ }
320
+ return resolveProcessStartIdentity(pid);
321
+ }
322
+ async function resolveProcessStartIdentity(pid) {
323
+ if (process.platform === "linux") {
324
+ const stat = await readFile(`/proc/${pid}/stat`, "utf8").catch(() => null);
325
+ if (!stat)
326
+ return null;
327
+ const commandEnd = stat.lastIndexOf(")");
328
+ if (commandEnd < 0)
329
+ return null;
330
+ const startTicks = stat
331
+ .slice(commandEnd + 2)
332
+ .trim()
333
+ .split(/\s+/)[19];
334
+ return startTicks ? `linux:${startTicks}` : null;
335
+ }
336
+ if (process.platform === "darwin") {
337
+ const output = await commandStdout("/bin/ps", ["-o", "lstart=", "-p", String(pid)]);
338
+ const started = output?.trim();
339
+ return started ? `darwin:${started}` : null;
340
+ }
341
+ if (process.platform === "win32") {
342
+ const output = await commandStdout("powershell.exe", [
343
+ "-NoProfile",
344
+ "-NonInteractive",
345
+ "-Command",
346
+ `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`,
347
+ ]);
348
+ const started = output?.trim();
349
+ return started ? `win32:${started}` : null;
350
+ }
351
+ return null;
352
+ }
353
+ function processIsAlive(pid) {
354
+ try {
355
+ process.kill(pid, 0);
356
+ return true;
357
+ }
358
+ catch (error) {
359
+ return error.code !== "ESRCH";
360
+ }
361
+ }
362
+ async function leafLockRecordIsStale(record) {
363
+ // The original lock format was an empty exclusive file. It carries no owner
364
+ // identity, so a short age fence is the only safe compatibility signal: a
365
+ // fresh legacy writer stays exclusive, while crash wreckage can recover.
366
+ if (record?.kind === "legacy") {
367
+ return Date.now() - record.modifiedAtMs > LEGACY_LEAF_LOCK_STALE_MS;
368
+ }
369
+ // A malformed non-empty record is not proof that no writer owns it. New lock
370
+ // records are atomically published, so corruption still needs explicit recovery.
371
+ if (record?.kind !== "owned")
372
+ return false;
373
+ const { owner } = record;
374
+ if (owner.phase === "released")
375
+ return true;
376
+ if (!processIsAlive(owner.pid))
377
+ return true;
378
+ const currentStartIdentity = await processStartIdentity(owner.pid);
379
+ return currentStartIdentity !== null && currentStartIdentity !== owner.processStartIdentity;
380
+ }
381
+ async function readLeafLockRecord(filePath) {
382
+ let file;
383
+ try {
384
+ file = await open(filePath, supportsDescriptorRelativeOutput()
385
+ ? constants.O_RDONLY | constants.O_NOFOLLOW
386
+ : portableReadFlags());
387
+ }
388
+ catch (error) {
389
+ return isMissing(error) ? { kind: "missing" } : { kind: "invalid" };
390
+ }
391
+ try {
392
+ const metadata = await file.stat();
393
+ const visibleMetadata = await lstat(filePath).catch(() => null);
394
+ if (!metadata.isFile() ||
395
+ !visibleMetadata ||
396
+ visibleMetadata.isSymbolicLink() ||
397
+ !visibleMetadata.isFile() ||
398
+ visibleMetadata.dev !== metadata.dev ||
399
+ visibleMetadata.ino !== metadata.ino) {
400
+ return { kind: "invalid" };
401
+ }
402
+ const raw = await file.readFile("utf8");
403
+ const owner = parseLeafLockOwner(raw);
404
+ if (owner)
405
+ return { kind: "owned", owner };
406
+ return raw.length === 0
407
+ ? { kind: "legacy", modifiedAtMs: metadata.mtimeMs }
408
+ : { kind: "invalid" };
409
+ }
410
+ finally {
411
+ await file.close();
412
+ }
413
+ }
414
+ async function publishLeafLock(lockPath, owner) {
415
+ const candidatePath = `${lockPath}.candidate.${owner.pid}.${owner.nonce}`;
416
+ try {
417
+ const candidate = await open(candidatePath, supportsDescriptorRelativeOutput() ? noFollowCreateFlags() : portableCreateFlags(), 0o600);
418
+ try {
419
+ await candidate.writeFile(serializeLeafLockOwner(owner), "utf8");
420
+ await candidate.sync();
421
+ }
422
+ finally {
423
+ await candidate.close();
424
+ }
425
+ await link(candidatePath, lockPath);
426
+ }
427
+ finally {
428
+ await unlink(candidatePath).catch(() => undefined);
429
+ }
430
+ }
431
+ async function cleanupAbandonedTemporary(lockPath, owner) {
432
+ if (!owner.temporaryName)
433
+ return;
434
+ const temporaryPath = path.join(path.dirname(lockPath), owner.temporaryName);
435
+ const metadata = await lstat(temporaryPath).catch(() => null);
436
+ if (metadata?.isFile() && !metadata.isSymbolicLink()) {
437
+ await unlink(temporaryPath).catch(() => undefined);
438
+ }
439
+ }
440
+ async function leafLockArtifacts(lockPath) {
441
+ const directory = path.dirname(lockPath);
442
+ const lockLeaf = path.basename(lockPath);
443
+ const displacedPrefix = `${lockLeaf}.displaced.`;
444
+ return (await readdir(directory).catch(() => []))
445
+ .filter((candidate) => candidate === lockLeaf || candidate.startsWith(displacedPrefix))
446
+ .map((candidate) => path.join(directory, candidate));
447
+ }
448
+ async function cleanupStaleLeafLockCandidates(lockPath) {
449
+ const directory = path.dirname(lockPath);
450
+ const candidatePrefix = `${path.basename(lockPath)}.candidate.`;
451
+ for (const candidate of await readdir(directory).catch(() => [])) {
452
+ if (!candidate.startsWith(candidatePrefix))
453
+ continue;
454
+ const candidatePath = path.join(directory, candidate);
455
+ const embeddedPid = Number(candidate.slice(candidatePrefix.length).split(".", 1)[0]);
456
+ const record = await readLeafLockRecord(candidatePath);
457
+ if ((Number.isSafeInteger(embeddedPid) && embeddedPid > 0 && !processIsAlive(embeddedPid)) ||
458
+ (record.kind !== "missing" && (await leafLockRecordIsStale(record)))) {
459
+ await unlink(candidatePath).catch(() => undefined);
460
+ }
461
+ }
462
+ }
463
+ async function unlinkOwnedLeafLockArtifact(lockPath, artifactPath, nonce) {
464
+ const retiredPath = `${lockPath}.displaced.${process.pid}.${randomUUID()}`;
465
+ try {
466
+ await rename(artifactPath, retiredPath);
467
+ }
468
+ catch (error) {
469
+ if (isMissing(error))
470
+ return;
471
+ throw error;
472
+ }
473
+ const retired = await readLeafLockRecord(retiredPath);
474
+ if (retired.kind === "owned" && retired.owner.nonce === nonce) {
475
+ await unlink(retiredPath).catch(() => undefined);
476
+ return;
477
+ }
478
+ // The pathname changed after its owner was inspected. Restore the exact inode
479
+ // when the name is still free; if another lock is already visible, keep this
480
+ // one displaced so contenders detect it instead of deleting a fresh owner.
481
+ let restored = false;
482
+ try {
483
+ await link(retiredPath, artifactPath);
484
+ restored = true;
485
+ }
486
+ catch (error) {
487
+ if (isMissing(error))
488
+ return;
489
+ if (!isAlreadyExists(error))
490
+ throw error;
491
+ }
492
+ if (restored)
493
+ await unlink(retiredPath).catch(() => undefined);
494
+ }
495
+ async function unlinkOwnedLeafLockArtifacts(lockPath, nonce) {
496
+ for (const artifactPath of await leafLockArtifacts(lockPath)) {
497
+ await unlinkOwnedLeafLockArtifact(lockPath, artifactPath, nonce);
498
+ }
499
+ }
500
+ async function hasCompetingDisplacedLock(lockPath, nonce) {
501
+ for (const artifactPath of await leafLockArtifacts(lockPath)) {
502
+ if (artifactPath === lockPath)
503
+ continue;
504
+ const record = await readLeafLockRecord(artifactPath);
505
+ if (record.kind === "missing")
506
+ continue;
507
+ if (record.kind === "owned" && record.owner.nonce === nonce)
508
+ continue;
509
+ if (!(await leafLockRecordIsStale(record)))
510
+ return true;
511
+ if (record?.kind === "owned") {
512
+ await cleanupAbandonedTemporary(lockPath, record.owner);
513
+ }
514
+ await unlink(artifactPath).catch(() => undefined);
515
+ }
516
+ return false;
517
+ }
518
+ const LEAF_LOCK_RETRY_LIMIT = 8;
519
+ const LEAF_LOCK_RETRY_DELAY_MS = 5;
520
+ async function acquireLeafLock(lockPath, temporaryPath) {
521
+ await cleanupStaleLeafLockCandidates(lockPath);
522
+ const currentProcessStartIdentity = (await processStartIdentity(process.pid)) ?? `${process.platform}:unverified`;
523
+ const owner = {
524
+ pid: process.pid,
525
+ nonce: randomUUID(),
526
+ processStartIdentity: currentProcessStartIdentity,
527
+ createdAt: new Date().toISOString(),
528
+ ...(temporaryPath ? { temporaryName: path.basename(temporaryPath) } : {}),
529
+ phase: "active",
530
+ };
531
+ for (let attempt = 0; attempt < LEAF_LOCK_RETRY_LIMIT; attempt += 1) {
532
+ let published = false;
533
+ let ownerHandle = null;
534
+ try {
535
+ await publishLeafLock(lockPath, owner);
536
+ published = true;
537
+ ownerHandle = await open(lockPath, supportsDescriptorRelativeOutput()
538
+ ? constants.O_RDWR | constants.O_NOFOLLOW
539
+ : constants.O_RDWR);
540
+ const identity = await ownerHandle.stat({ bigint: true });
541
+ const visibleIdentity = await lstat(lockPath, { bigint: true });
542
+ const currentOwner = parseLeafLockOwner(await ownerHandle.readFile("utf8"));
543
+ if (visibleIdentity.isSymbolicLink() ||
544
+ !visibleIdentity.isFile() ||
545
+ visibleIdentity.dev !== identity.dev ||
546
+ visibleIdentity.ino !== identity.ino ||
547
+ currentOwner?.nonce !== owner.nonce ||
548
+ (await hasCompetingDisplacedLock(lockPath, owner.nonce))) {
549
+ await unlinkOwnedLeafLockArtifacts(lockPath, owner.nonce);
550
+ throw writeConflict(`Output file is already being modified: ${lockPath}`);
551
+ }
552
+ let released = false;
553
+ const acquiredHandle = ownerHandle;
554
+ return async () => {
555
+ if (released)
556
+ return;
557
+ released = true;
558
+ // Move the canonical name into unique cleanup custody while its inode
559
+ // is still active. Once that atomic rename happens a new owner may
560
+ // publish, but cleanup only ever unlinks the retired name it inspected.
561
+ await unlinkOwnedLeafLockArtifact(lockPath, lockPath, owner.nonce).catch(() => undefined);
562
+ try {
563
+ // A delayed stale reclaimer may already hold this inode at a displaced
564
+ // name. Mark it complete through the handle so that artifact can be
565
+ // reclaimed even though this process remains alive.
566
+ const releasedWrite = await acquiredHandle.write(Buffer.from("R"), 0, 1, 0);
567
+ if (releasedWrite.bytesWritten === 1)
568
+ await acquiredHandle.sync();
569
+ }
570
+ catch {
571
+ // Best-effort recovery cleanup, as above.
572
+ }
573
+ finally {
574
+ await cleanupStaleLeafLockCandidates(lockPath).catch(() => undefined);
575
+ await acquiredHandle.close().catch(() => undefined);
576
+ }
577
+ };
578
+ }
579
+ catch (error) {
580
+ await ownerHandle?.close().catch(() => undefined);
581
+ if (published) {
582
+ await unlinkOwnedLeafLockArtifacts(lockPath, owner.nonce);
583
+ if (isMissing(error)) {
584
+ throw writeConflict(`Output file is already being modified: ${lockPath}`);
585
+ }
586
+ throw error;
587
+ }
588
+ if (!isAlreadyExists(error))
589
+ throw error;
590
+ }
591
+ const current = await readLeafLockRecord(lockPath);
592
+ if (current.kind === "missing") {
593
+ await delay(LEAF_LOCK_RETRY_DELAY_MS);
594
+ continue;
595
+ }
596
+ if (!(await leafLockRecordIsStale(current))) {
597
+ throw writeConflict(`Output file is already being modified: ${lockPath}`);
598
+ }
599
+ const displacedPath = `${lockPath}.displaced.${process.pid}.${randomUUID()}`;
600
+ try {
601
+ await rename(lockPath, displacedPath);
602
+ }
603
+ catch (error) {
604
+ if (isMissing(error)) {
605
+ await delay(LEAF_LOCK_RETRY_DELAY_MS);
606
+ continue;
607
+ }
608
+ throw error;
609
+ }
610
+ const displaced = await readLeafLockRecord(displacedPath);
611
+ if (!(await leafLockRecordIsStale(displaced))) {
612
+ // A fresh owner won between our stale read and rename. Put its exact inode
613
+ // back when possible; every contender also scans displaced names before
614
+ // entering, so a live writer is never silently unlocked.
615
+ try {
616
+ await link(displacedPath, lockPath);
617
+ }
618
+ catch (error) {
619
+ if (isMissing(error)) {
620
+ // Its owner completed cleanup while we inspected the displaced inode.
621
+ // Nothing remains to restore; compete again for the now-current name.
622
+ await delay(LEAF_LOCK_RETRY_DELAY_MS);
623
+ continue;
624
+ }
625
+ if (!isAlreadyExists(error))
626
+ throw error;
627
+ }
628
+ throw writeConflict(`Output file is already being modified: ${lockPath}`);
629
+ }
630
+ if (displaced?.kind === "owned") {
631
+ await cleanupAbandonedTemporary(lockPath, displaced.owner);
632
+ }
633
+ await unlink(displacedPath).catch(() => undefined);
634
+ await delay(LEAF_LOCK_RETRY_DELAY_MS);
635
+ }
636
+ throw writeConflict(`Output file is already being modified: ${lockPath}`);
637
+ }
638
+ function matchesEntryIdentity(stats, expected) {
639
+ return (stats.dev === expected.dev &&
640
+ stats.ino === expected.ino &&
641
+ stats.mode === expected.mode &&
642
+ stats.ctimeNs === expected.ctimeNs);
643
+ }
644
+ async function assertSafeLeaf(leafPath, overwrite) {
645
+ try {
646
+ const stats = await lstat(leafPath);
647
+ if (stats.isSymbolicLink()) {
648
+ throw validationError(`Refusing symlinked output file: ${leafPath}`);
649
+ }
650
+ if (!stats.isFile()) {
651
+ throw validationError(`Refusing non-file output path: ${leafPath}`);
652
+ }
653
+ if (!overwrite) {
654
+ throw validationError(`Refusing to overwrite existing file: ${leafPath}`);
655
+ }
656
+ }
657
+ catch (error) {
658
+ if (isMissing(error))
659
+ return;
660
+ throw error;
661
+ }
662
+ }
663
+ async function assertExpectedLeaf(leafPath, expectedContent) {
664
+ if (expectedContent === undefined)
665
+ return;
666
+ let current;
667
+ try {
668
+ const file = await open(leafPath, supportsDescriptorRelativeOutput()
669
+ ? constants.O_RDONLY | constants.O_NOFOLLOW
670
+ : portableReadFlags());
671
+ try {
672
+ const metadata = await file.stat();
673
+ if (!metadata.isFile()) {
674
+ throw validationError(`Refusing non-file output path: ${leafPath}`);
675
+ }
676
+ current = await file.readFile();
677
+ }
678
+ finally {
679
+ await file.close();
680
+ }
681
+ }
682
+ catch (error) {
683
+ if (!isMissing(error))
684
+ throw error;
685
+ current = null;
686
+ }
687
+ const expected = expectedContent === null
688
+ ? null
689
+ : Buffer.isBuffer(expectedContent)
690
+ ? expectedContent
691
+ : Buffer.from(expectedContent);
692
+ if (current === null ? expected !== null : expected === null || !current.equals(expected)) {
693
+ throw writeConflict(`Output file changed during write: ${leafPath}`);
694
+ }
695
+ }
696
+ async function commitStagedFile(input) {
697
+ const leafPath = descriptorPath(input.parent, input.leaf);
698
+ const backupPath = descriptorPath(input.parent, `.spacefast-${randomUUID()}.rollback`);
699
+ const lockPath = descriptorPath(input.parent, lockName(input.leaf));
700
+ let backupCreated = false;
701
+ let committed = false;
702
+ const releaseLock = await acquireLeafLock(lockPath, input.temporaryPath);
703
+ try {
704
+ await input.beforeCommit?.();
705
+ await validateDirectoryBindings(input.identities);
706
+ await assertExpectedLeaf(leafPath, input.expectedContent);
707
+ await assertSafeLeaf(leafPath, input.overwrite);
708
+ if (input.overwrite) {
709
+ try {
710
+ await link(leafPath, backupPath);
711
+ backupCreated = true;
712
+ }
713
+ catch (error) {
714
+ if (!isMissing(error))
715
+ throw error;
716
+ }
717
+ await input.afterBackup?.();
718
+ await rename(input.temporaryPath, leafPath);
719
+ committed = true;
720
+ }
721
+ else {
722
+ try {
723
+ await link(input.temporaryPath, leafPath);
724
+ }
725
+ catch (error) {
726
+ if (isAlreadyExists(error)) {
727
+ throw validationError(`Refusing to overwrite existing file: ${leafPath}`);
728
+ }
729
+ throw error;
730
+ }
731
+ committed = true;
732
+ await unlink(input.temporaryPath);
733
+ }
734
+ await input.parent.sync();
735
+ await validateDirectoryBindings(input.identities);
736
+ if (backupCreated) {
737
+ await unlink(backupPath);
738
+ backupCreated = false;
739
+ await input.parent.sync();
740
+ }
741
+ }
742
+ catch (error) {
743
+ if (committed) {
744
+ if (backupCreated) {
745
+ await rename(backupPath, leafPath);
746
+ backupCreated = false;
747
+ }
748
+ else {
749
+ await unlink(leafPath).catch((cleanupError) => {
750
+ if (!isMissing(cleanupError))
751
+ throw cleanupError;
752
+ });
753
+ }
754
+ await input.parent.sync();
755
+ }
756
+ throw error;
757
+ }
758
+ finally {
759
+ if (backupCreated) {
760
+ await unlink(backupPath).catch((error) => {
761
+ if (!isMissing(error))
762
+ throw error;
763
+ });
764
+ }
765
+ await releaseLock();
766
+ }
767
+ }
768
+ async function commitPortableStagedFile(input) {
769
+ const parentPath = path.dirname(input.leafPath);
770
+ const backupPath = path.join(parentPath, `.spacefast-${randomUUID()}.rollback`);
771
+ const lockPath = path.join(parentPath, lockName(path.basename(input.leafPath)));
772
+ let backupCreated = false;
773
+ let committed = false;
774
+ const releaseLock = await acquireLeafLock(lockPath, input.temporaryPath);
775
+ try {
776
+ await input.beforeCommit?.();
777
+ await validateDirectoryBindings(input.identities);
778
+ await assertExpectedLeaf(input.leafPath, input.expectedContent);
779
+ await assertSafeLeaf(input.leafPath, input.overwrite);
780
+ if (input.overwrite) {
781
+ if (process.platform === "win32") {
782
+ try {
783
+ await rename(input.leafPath, backupPath);
784
+ backupCreated = true;
785
+ }
786
+ catch (error) {
787
+ if (!isMissing(error))
788
+ throw error;
789
+ }
790
+ await validateDirectoryBindings(input.identities);
791
+ }
792
+ else {
793
+ try {
794
+ await link(input.leafPath, backupPath);
795
+ backupCreated = true;
796
+ }
797
+ catch (error) {
798
+ if (!isMissing(error))
799
+ throw error;
800
+ }
801
+ }
802
+ await input.afterBackup?.();
803
+ await rename(input.temporaryPath, input.leafPath);
804
+ committed = true;
805
+ }
806
+ else {
807
+ try {
808
+ await link(input.temporaryPath, input.leafPath);
809
+ }
810
+ catch (error) {
811
+ if (isAlreadyExists(error)) {
812
+ throw validationError(`Refusing to overwrite existing file: ${input.leafPath}`);
813
+ }
814
+ if (error.code !== "EPERM" &&
815
+ error.code !== "ENOTSUP") {
816
+ throw error;
817
+ }
818
+ try {
819
+ await copyFile(input.temporaryPath, input.leafPath, constants.COPYFILE_EXCL);
820
+ }
821
+ catch (copyError) {
822
+ if (isAlreadyExists(copyError)) {
823
+ throw validationError(`Refusing to overwrite existing file: ${input.leafPath}`);
824
+ }
825
+ await unlink(input.leafPath).catch((cleanupError) => {
826
+ if (!isMissing(cleanupError))
827
+ throw cleanupError;
828
+ });
829
+ throw copyError;
830
+ }
831
+ }
832
+ committed = true;
833
+ await unlink(input.temporaryPath);
834
+ }
835
+ await validateDirectoryBindings(input.identities);
836
+ if (backupCreated) {
837
+ await unlink(backupPath);
838
+ backupCreated = false;
839
+ }
840
+ }
841
+ catch (error) {
842
+ if (committed) {
843
+ if (backupCreated) {
844
+ await rename(backupPath, input.leafPath);
845
+ backupCreated = false;
846
+ }
847
+ else {
848
+ await unlink(input.leafPath).catch((cleanupError) => {
849
+ if (!isMissing(cleanupError))
850
+ throw cleanupError;
851
+ });
852
+ }
853
+ }
854
+ else if (backupCreated) {
855
+ await rename(backupPath, input.leafPath);
856
+ backupCreated = false;
857
+ }
858
+ throw error;
859
+ }
860
+ finally {
861
+ if (backupCreated) {
862
+ await rename(backupPath, input.leafPath).catch(async (error) => {
863
+ if (!isMissing(error))
864
+ throw error;
865
+ });
866
+ }
867
+ await releaseLock();
868
+ }
869
+ }
870
+ async function secureWriteFilePortable(options) {
871
+ // The caller explicitly trusts the root. Canonicalize that boundary once so
872
+ // supported system aliases such as macOS `/var -> /private/var` do not look
873
+ // like attacker-controlled children; every path below it is still walked
874
+ // component-by-component without following symlinks.
875
+ const root = await canonicalPortableWriteRoot(options.root);
876
+ const relativePath = validateRelativePath(options.relativePath, "write");
877
+ const components = relativePath.split(path.sep);
878
+ const leaf = components.pop();
879
+ if (!leaf) {
880
+ throw validationError(`Refusing invalid output file path: ${options.relativePath}`);
881
+ }
882
+ assertSafeComponent(leaf);
883
+ const identities = await openPortableRoot(root, true);
884
+ if (options.rootMode !== undefined) {
885
+ await chmod(root, options.rootMode);
886
+ }
887
+ let visibleParent = root;
888
+ for (const component of components) {
889
+ if (!component || component === ".")
890
+ continue;
891
+ visibleParent = await openPortableChildDirectory(identities, visibleParent, component, true);
892
+ }
893
+ const temporaryPath = path.join(visibleParent, `.spacefast-${randomUUID()}.tmp`);
894
+ let temporaryPresent = true;
895
+ try {
896
+ await validateDirectoryBindings(identities);
897
+ const temporary = await open(temporaryPath, portableCreateFlags(), 0o600);
898
+ try {
899
+ if (options.data !== undefined) {
900
+ await temporary.writeFile(options.data);
901
+ }
902
+ else {
903
+ const writer = options.write;
904
+ if (!writer) {
905
+ throw validationError("Secure output write source disappeared before staging.");
906
+ }
907
+ await writer(temporary);
908
+ }
909
+ await temporary.chmod(options.mode ?? 0o600);
910
+ await temporary.sync();
911
+ }
912
+ finally {
913
+ await temporary.close();
914
+ }
915
+ await commitPortableStagedFile({
916
+ leafPath: path.join(visibleParent, leaf),
917
+ temporaryPath,
918
+ overwrite: options.overwrite === true,
919
+ expectedContent: options.expectedContent,
920
+ identities,
921
+ ...(options.beforeCommit ? { beforeCommit: options.beforeCommit } : {}),
922
+ ...(options.afterBackup ? { afterBackup: options.afterBackup } : {}),
923
+ });
924
+ temporaryPresent = false;
925
+ }
926
+ finally {
927
+ if (temporaryPresent) {
928
+ await unlink(temporaryPath).catch((error) => {
929
+ if (!isMissing(error))
930
+ throw error;
931
+ });
932
+ }
933
+ }
934
+ }
935
+ /**
936
+ * Writes beneath an explicitly trusted root without following output-tree
937
+ * symlinks. Linux uses descriptor-relative operations; other supported
938
+ * platforms use exclusive staging plus before-and-after ancestor identity
939
+ * validation and fail closed if any binding changes.
940
+ */
941
+ export async function secureWriteFile(options) {
942
+ if ((options.data === undefined) === (options.write === undefined)) {
943
+ throw validationError("Secure output requires exactly one data or write source.");
944
+ }
945
+ if (!supportsDescriptorRelativeOutput()) {
946
+ return secureWriteFilePortable(options);
947
+ }
948
+ const root = path.resolve(options.root);
949
+ const relativePath = validateRelativePath(options.relativePath, "write");
950
+ const components = relativePath.split(path.sep);
951
+ const leaf = components.pop();
952
+ if (!leaf) {
953
+ throw validationError(`Refusing invalid output file path: ${options.relativePath}`);
954
+ }
955
+ assertSafeComponent(leaf);
956
+ const openedRoot = await openRoot(root, true);
957
+ if (options.rootMode !== undefined) {
958
+ await openedRoot.handle.chmod(options.rootMode);
959
+ }
960
+ let parent = openedRoot.handle;
961
+ const identities = [...openedRoot.identities];
962
+ let visibleParent = root;
963
+ let temporaryPath = null;
964
+ try {
965
+ for (const component of components) {
966
+ if (!component || component === ".")
967
+ continue;
968
+ const next = await openChildDirectory(parent, visibleParent, component, true);
969
+ await parent.close();
970
+ parent = next.handle;
971
+ visibleParent = path.join(visibleParent, component);
972
+ identities.push(next.identity);
973
+ }
974
+ const temporaryName = `.spacefast-${randomUUID()}.tmp`;
975
+ temporaryPath = descriptorPath(parent, temporaryName);
976
+ const temporary = await open(temporaryPath, noFollowCreateFlags(), 0o600);
977
+ try {
978
+ if (options.data !== undefined) {
979
+ await temporary.writeFile(options.data);
980
+ }
981
+ else {
982
+ const writer = options.write;
983
+ if (!writer) {
984
+ throw validationError("Secure output write source disappeared before staging.");
985
+ }
986
+ await writer(temporary);
987
+ }
988
+ await temporary.chmod(options.mode ?? 0o600);
989
+ await temporary.sync();
990
+ }
991
+ finally {
992
+ await temporary.close();
993
+ }
994
+ await commitStagedFile({
995
+ parent,
996
+ leaf,
997
+ temporaryPath,
998
+ overwrite: options.overwrite === true,
999
+ expectedContent: options.expectedContent,
1000
+ identities,
1001
+ ...(options.beforeCommit ? { beforeCommit: options.beforeCommit } : {}),
1002
+ ...(options.afterBackup ? { afterBackup: options.afterBackup } : {}),
1003
+ });
1004
+ temporaryPath = null;
1005
+ }
1006
+ finally {
1007
+ if (temporaryPath) {
1008
+ await unlink(temporaryPath).catch((error) => {
1009
+ if (!isMissing(error))
1010
+ throw error;
1011
+ });
1012
+ }
1013
+ await parent.close();
1014
+ }
1015
+ }
1016
+ export async function secureReadFile(options) {
1017
+ if (!supportsDescriptorRelativeOutput()) {
1018
+ return secureReadFilePortable(options);
1019
+ }
1020
+ const root = path.resolve(options.root);
1021
+ const relativePath = validateRelativePath(options.relativePath, "read");
1022
+ const components = relativePath.split(path.sep);
1023
+ const leaf = components.pop();
1024
+ if (!leaf) {
1025
+ throw validationError(`Refusing invalid local file path: ${options.relativePath}`);
1026
+ }
1027
+ assertSafeComponent(leaf);
1028
+ const openedRoot = await openRoot(root, false);
1029
+ let parent = openedRoot.handle;
1030
+ const identities = [...openedRoot.identities];
1031
+ let visibleParent = root;
1032
+ try {
1033
+ for (const component of components) {
1034
+ if (!component || component === ".")
1035
+ continue;
1036
+ const next = await openChildDirectory(parent, visibleParent, component, false);
1037
+ await parent.close();
1038
+ parent = next.handle;
1039
+ visibleParent = path.join(visibleParent, component);
1040
+ identities.push(next.identity);
1041
+ }
1042
+ await validateDirectoryBindings(identities);
1043
+ const leafPath = descriptorPath(parent, leaf);
1044
+ let file;
1045
+ try {
1046
+ file = await open(leafPath, constants.O_RDONLY | constants.O_NOFOLLOW);
1047
+ }
1048
+ catch (error) {
1049
+ if (error.code === "ELOOP") {
1050
+ throw validationError(`Refusing symlinked local file: ${path.join(visibleParent, leaf)}`);
1051
+ }
1052
+ throw error;
1053
+ }
1054
+ try {
1055
+ const metadata = await file.stat();
1056
+ if (!metadata.isFile()) {
1057
+ throw validationError(`Refusing non-file local path: ${path.join(visibleParent, leaf)}`);
1058
+ }
1059
+ if (metadata.nlink !== 1) {
1060
+ const writerLock = await lstat(descriptorPath(parent, lockName(leaf))).catch(() => null);
1061
+ if (writerLock) {
1062
+ throw writeConflict(`Local file is being replaced: ${path.join(visibleParent, leaf)}`);
1063
+ }
1064
+ throw validationError(`Refusing hard-linked local file: ${path.join(visibleParent, leaf)}`);
1065
+ }
1066
+ const content = await file.readFile(options.encoding ? { encoding: options.encoding } : {});
1067
+ await validateDirectoryBindings(identities);
1068
+ return content;
1069
+ }
1070
+ finally {
1071
+ await file.close();
1072
+ }
1073
+ }
1074
+ finally {
1075
+ await parent.close();
1076
+ }
1077
+ }
1078
+ async function secureReadFilePortable(options) {
1079
+ const root = await realpath(path.resolve(options.root));
1080
+ const relativePath = validateRelativePath(options.relativePath, "read");
1081
+ const components = relativePath.split(path.sep);
1082
+ const leaf = components.pop();
1083
+ if (!leaf) {
1084
+ throw validationError(`Refusing invalid local file path: ${options.relativePath}`);
1085
+ }
1086
+ assertSafeComponent(leaf);
1087
+ const identities = await openPortableRoot(root, false);
1088
+ let visibleParent = root;
1089
+ for (const component of components) {
1090
+ if (!component || component === ".")
1091
+ continue;
1092
+ visibleParent = await openPortableChildDirectory(identities, visibleParent, component, false);
1093
+ }
1094
+ await validateDirectoryBindings(identities);
1095
+ const leafPath = path.join(visibleParent, leaf);
1096
+ const pathStats = await lstat(leafPath);
1097
+ if (pathStats.isSymbolicLink()) {
1098
+ throw validationError(`Refusing symlinked local file: ${leafPath}`);
1099
+ }
1100
+ const file = await open(leafPath, portableReadFlags());
1101
+ try {
1102
+ const metadata = await file.stat({ bigint: true });
1103
+ if (!metadata.isFile()) {
1104
+ throw validationError(`Refusing non-file local path: ${leafPath}`);
1105
+ }
1106
+ if (metadata.nlink !== 1n) {
1107
+ const writerLock = await lstat(path.join(visibleParent, lockName(leaf))).catch(() => null);
1108
+ if (writerLock) {
1109
+ throw writeConflict(`Local file is being replaced: ${leafPath}`);
1110
+ }
1111
+ throw validationError(`Refusing hard-linked local file: ${leafPath}`);
1112
+ }
1113
+ const content = await file.readFile(options.encoding ? { encoding: options.encoding } : {});
1114
+ await validateDirectoryBindings(identities);
1115
+ const finalStats = await lstat(leafPath, { bigint: true });
1116
+ if (finalStats.isSymbolicLink() ||
1117
+ !finalStats.isFile() ||
1118
+ finalStats.dev !== metadata.dev ||
1119
+ finalStats.ino !== metadata.ino) {
1120
+ throw validationError(`Local file changed during read: ${leafPath}`);
1121
+ }
1122
+ return content;
1123
+ }
1124
+ finally {
1125
+ await file.close();
1126
+ }
1127
+ }
1128
+ /**
1129
+ * Removes only the exact non-directory leaf identity previously inspected.
1130
+ * The same leaf lock used by secureWriteFile covers the identity check and
1131
+ * rename, so a cooperating writer cannot replace the leaf between them.
1132
+ */
1133
+ export async function secureRemoveLocalEntry(options) {
1134
+ const relativePath = validateRelativePath(options.relativePath, "write");
1135
+ const components = relativePath.split(path.sep);
1136
+ const leaf = components.pop();
1137
+ if (!leaf) {
1138
+ throw validationError(`Refusing invalid output file path: ${options.relativePath}`);
1139
+ }
1140
+ assertSafeComponent(leaf);
1141
+ if (!supportsDescriptorRelativeOutput()) {
1142
+ const root = await realpath(path.resolve(options.root));
1143
+ const identities = await openPortableRoot(root, false);
1144
+ let visibleParent = root;
1145
+ for (const component of components) {
1146
+ if (!component || component === ".")
1147
+ continue;
1148
+ visibleParent = await openPortableChildDirectory(identities, visibleParent, component, false);
1149
+ }
1150
+ const leafPath = path.join(visibleParent, leaf);
1151
+ const retiredPath = path.join(visibleParent, `.spacefast-${randomUUID()}.retired`);
1152
+ const releaseLock = await acquireLeafLock(path.join(visibleParent, lockName(leaf)));
1153
+ try {
1154
+ await validateDirectoryBindings(identities);
1155
+ let stats;
1156
+ try {
1157
+ stats = await lstat(leafPath, { bigint: true });
1158
+ }
1159
+ catch (error) {
1160
+ if (isMissing(error))
1161
+ return false;
1162
+ throw error;
1163
+ }
1164
+ if (!matchesEntryIdentity(stats, options.expectedIdentity))
1165
+ return false;
1166
+ if (stats.isDirectory()) {
1167
+ throw validationError(`Refusing to remove directory output path: ${leafPath}`);
1168
+ }
1169
+ await rename(leafPath, retiredPath);
1170
+ await validateDirectoryBindings(identities);
1171
+ await unlink(retiredPath);
1172
+ await validateDirectoryBindings(identities);
1173
+ return true;
1174
+ }
1175
+ finally {
1176
+ await releaseLock();
1177
+ }
1178
+ }
1179
+ const root = path.resolve(options.root);
1180
+ const openedRoot = await openRoot(root, false);
1181
+ let parent = openedRoot.handle;
1182
+ const identities = [...openedRoot.identities];
1183
+ let visibleParent = root;
1184
+ try {
1185
+ for (const component of components) {
1186
+ if (!component || component === ".")
1187
+ continue;
1188
+ const next = await openChildDirectory(parent, visibleParent, component, false);
1189
+ await parent.close();
1190
+ parent = next.handle;
1191
+ visibleParent = path.join(visibleParent, component);
1192
+ identities.push(next.identity);
1193
+ }
1194
+ const leafPath = descriptorPath(parent, leaf);
1195
+ const retiredName = `.spacefast-${randomUUID()}.retired`;
1196
+ const retiredPath = descriptorPath(parent, retiredName);
1197
+ const releaseLock = await acquireLeafLock(descriptorPath(parent, lockName(leaf)));
1198
+ try {
1199
+ await validateDirectoryBindings(identities);
1200
+ let stats;
1201
+ try {
1202
+ stats = await lstat(leafPath, { bigint: true });
1203
+ }
1204
+ catch (error) {
1205
+ if (isMissing(error))
1206
+ return false;
1207
+ throw error;
1208
+ }
1209
+ if (!matchesEntryIdentity(stats, options.expectedIdentity))
1210
+ return false;
1211
+ if (stats.isDirectory()) {
1212
+ throw validationError(`Refusing to remove directory output path: ${path.join(visibleParent, leaf)}`);
1213
+ }
1214
+ await rename(leafPath, retiredPath);
1215
+ await parent.sync();
1216
+ await validateDirectoryBindings(identities);
1217
+ await unlink(retiredPath);
1218
+ await parent.sync();
1219
+ await validateDirectoryBindings(identities);
1220
+ return true;
1221
+ }
1222
+ finally {
1223
+ await releaseLock();
1224
+ }
1225
+ }
1226
+ finally {
1227
+ await parent.close();
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Creates one new directory leaf beneath a trusted root. The returned rollback
1232
+ * removes only the exact directory inode created by this call.
1233
+ */
1234
+ export async function secureCreateDirectory(options) {
1235
+ const root = path.resolve(options.root);
1236
+ const relativePath = validateRelativePath(options.relativePath, "write");
1237
+ const components = relativePath.split(path.sep).filter((component) => component !== ".");
1238
+ const leaf = components.pop();
1239
+ if (!leaf) {
1240
+ throw validationError(`Refusing invalid output directory path: ${options.relativePath}`);
1241
+ }
1242
+ assertSafeComponent(leaf);
1243
+ if (!supportsDescriptorRelativeOutput()) {
1244
+ const portableRoot = await realpath(root);
1245
+ const identities = await openPortableRoot(portableRoot, false);
1246
+ let visibleParent = portableRoot;
1247
+ for (const component of components) {
1248
+ visibleParent = await openPortableChildDirectory(identities, visibleParent, component, false);
1249
+ }
1250
+ await validateDirectoryBindings(identities);
1251
+ const createdPath = path.join(visibleParent, leaf);
1252
+ try {
1253
+ await mkdir(createdPath, { mode: options.mode ?? 0o755 });
1254
+ }
1255
+ catch (error) {
1256
+ if (isAlreadyExists(error)) {
1257
+ throw validationError(`Target already exists: ${createdPath}`);
1258
+ }
1259
+ throw error;
1260
+ }
1261
+ const createdIdentity = await visibleDirectoryIdentity(createdPath);
1262
+ await validateDirectoryBindings(identities);
1263
+ let completed = false;
1264
+ return {
1265
+ path: path.join(root, relativePath),
1266
+ validate: async () => {
1267
+ await validateDirectoryBindings(identities);
1268
+ const current = await visibleDirectoryIdentity(createdPath);
1269
+ if (current.dev !== createdIdentity.dev || current.ino !== createdIdentity.ino) {
1270
+ throw validationError(`Created output directory changed: ${createdPath}`);
1271
+ }
1272
+ },
1273
+ commit: async () => {
1274
+ if (completed)
1275
+ return;
1276
+ await validateDirectoryBindings(identities);
1277
+ const current = await visibleDirectoryIdentity(createdPath);
1278
+ if (current.dev !== createdIdentity.dev || current.ino !== createdIdentity.ino) {
1279
+ throw validationError(`Created output directory changed: ${createdPath}`);
1280
+ }
1281
+ completed = true;
1282
+ },
1283
+ rollback: async () => {
1284
+ if (completed)
1285
+ return;
1286
+ completed = true;
1287
+ await validateDirectoryBindings(identities);
1288
+ const current = await visibleDirectoryIdentity(createdPath);
1289
+ if (current.dev !== createdIdentity.dev || current.ino !== createdIdentity.ino) {
1290
+ throw validationError(`Created output directory changed: ${createdPath}`);
1291
+ }
1292
+ await rm(createdPath, { recursive: true });
1293
+ await validateDirectoryBindings(identities);
1294
+ },
1295
+ };
1296
+ }
1297
+ const openedRoot = await openRoot(root, false);
1298
+ let parent = openedRoot.handle;
1299
+ const identities = [...openedRoot.identities];
1300
+ let visibleParent = root;
1301
+ try {
1302
+ for (const component of components) {
1303
+ const next = await openChildDirectory(parent, visibleParent, component, false);
1304
+ await parent.close();
1305
+ parent = next.handle;
1306
+ visibleParent = path.join(visibleParent, component);
1307
+ identities.push(next.identity);
1308
+ }
1309
+ await validateDirectoryBindings(identities);
1310
+ const createdPath = descriptorPath(parent, leaf);
1311
+ try {
1312
+ await mkdir(createdPath, { mode: options.mode ?? 0o755 });
1313
+ }
1314
+ catch (error) {
1315
+ if (isAlreadyExists(error)) {
1316
+ throw validationError(`Target already exists: ${path.join(visibleParent, leaf)}`);
1317
+ }
1318
+ throw error;
1319
+ }
1320
+ const openedCreated = await openDirectory(createdPath);
1321
+ const createdIdentity = await directoryIdentity(path.join(visibleParent, leaf), openedCreated.handle);
1322
+ let completed = false;
1323
+ const validateCreatedDirectory = async () => {
1324
+ await validateDirectoryBindings(identities);
1325
+ const currentIdentity = await directoryIdentity(createdIdentity.path, openedCreated.handle);
1326
+ const visibleIdentity = await visibleDirectoryIdentity(createdIdentity.path);
1327
+ if (currentIdentity.dev !== createdIdentity.dev ||
1328
+ currentIdentity.ino !== createdIdentity.ino ||
1329
+ visibleIdentity.dev !== createdIdentity.dev ||
1330
+ visibleIdentity.ino !== createdIdentity.ino) {
1331
+ throw validationError(`Created output directory changed: ${createdIdentity.path}`);
1332
+ }
1333
+ };
1334
+ return {
1335
+ path: path.join(root, relativePath),
1336
+ validate: async () => {
1337
+ if (completed) {
1338
+ throw validationError(`Created output directory handle is closed: ${createdIdentity.path}`);
1339
+ }
1340
+ await validateCreatedDirectory();
1341
+ },
1342
+ commit: async () => {
1343
+ if (completed)
1344
+ return;
1345
+ await validateCreatedDirectory();
1346
+ completed = true;
1347
+ await openedCreated.handle.close();
1348
+ await parent.close();
1349
+ },
1350
+ rollback: async () => {
1351
+ try {
1352
+ if (completed)
1353
+ return;
1354
+ completed = true;
1355
+ await validateDirectoryBindings(identities);
1356
+ await validateCreatedDirectory();
1357
+ await openedCreated.handle.close();
1358
+ await rm(createdPath, { recursive: true });
1359
+ await validateDirectoryBindings(identities);
1360
+ }
1361
+ finally {
1362
+ await openedCreated.handle.close().catch(() => { });
1363
+ await parent.close();
1364
+ }
1365
+ },
1366
+ };
1367
+ }
1368
+ catch (error) {
1369
+ // Any created-directory handle is closed in the branch that owns it.
1370
+ await parent.close();
1371
+ throw error;
1372
+ }
1373
+ }