@pithy-sh/cli 0.1.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +72 -0
  3. package/scripts/templateManifest.ts +49 -0
  4. package/scripts/tsconfig.json +26 -0
  5. package/scripts/vendorTemplate.ts +84 -0
  6. package/scripts/verifyPack.ts +88 -0
  7. package/src/audit/cliAudit.ts +406 -0
  8. package/src/bin.ts +111 -0
  9. package/src/capabilities/add.ts +288 -0
  10. package/src/capabilities/addBootstrap.ts +275 -0
  11. package/src/capabilities/catalog.ts +175 -0
  12. package/src/capabilities/compose.ts +39 -0
  13. package/src/capabilities/configConstants.ts +74 -0
  14. package/src/capabilities/configImports.ts +397 -0
  15. package/src/capabilities/eject.ts +331 -0
  16. package/src/capabilities/emailProvisioner.ts +346 -0
  17. package/src/capabilities/entitlementGap.ts +70 -0
  18. package/src/capabilities/entryExports.ts +162 -0
  19. package/src/capabilities/flow.ts +550 -0
  20. package/src/capabilities/hostRegistry.ts +368 -0
  21. package/src/capabilities/loadFailure.ts +208 -0
  22. package/src/capabilities/manifests.ts +238 -0
  23. package/src/capabilities/mediaProvisioner.ts +471 -0
  24. package/src/capabilities/mintSecrets.ts +306 -0
  25. package/src/capabilities/paymentsProvisioner.ts +207 -0
  26. package/src/capabilities/prerequisites.ts +168 -0
  27. package/src/capabilities/r2Bucket.ts +113 -0
  28. package/src/capabilities/reconcile.ts +1483 -0
  29. package/src/capabilities/remove.ts +597 -0
  30. package/src/capabilities/requiredOptions.ts +92 -0
  31. package/src/capabilities/rotateSecrets.ts +305 -0
  32. package/src/capabilities/secrets.ts +178 -0
  33. package/src/capabilities/secretsDispatcher.ts +29 -0
  34. package/src/capabilities/secretsProvisioner.ts +389 -0
  35. package/src/capabilities/storageProvisioner.ts +414 -0
  36. package/src/capabilities/supportProvisioner.ts +515 -0
  37. package/src/capabilities/testersLoader.ts +52 -0
  38. package/src/capabilities/testersProvisioner.ts +236 -0
  39. package/src/capabilities/turnstileProvisioner.ts +347 -0
  40. package/src/capabilities/vectorProvisioner.ts +260 -0
  41. package/src/ci/fileModes.ts +223 -0
  42. package/src/ci/sourceFiles.ts +200 -0
  43. package/src/ci/workflowDrivers.ts +524 -0
  44. package/src/cloudflare/accountAnswer.ts +110 -0
  45. package/src/cloudflare/config.ts +685 -0
  46. package/src/cloudflare/storeId.ts +129 -0
  47. package/src/commands/add.ts +372 -0
  48. package/src/commands/alias.ts +205 -0
  49. package/src/commands/dashboard.ts +651 -0
  50. package/src/commands/deploy.ts +150 -0
  51. package/src/commands/dev.ts +37 -0
  52. package/src/commands/doctor.ts +2059 -0
  53. package/src/commands/email.ts +425 -0
  54. package/src/commands/env.ts +155 -0
  55. package/src/commands/feature.ts +359 -0
  56. package/src/commands/init.ts +538 -0
  57. package/src/commands/media.ts +303 -0
  58. package/src/commands/migrate.ts +129 -0
  59. package/src/commands/payments.ts +336 -0
  60. package/src/commands/provision.ts +368 -0
  61. package/src/commands/remove.ts +151 -0
  62. package/src/commands/secrets.ts +652 -0
  63. package/src/commands/seed.ts +229 -0
  64. package/src/commands/storage.ts +309 -0
  65. package/src/commands/support.ts +331 -0
  66. package/src/commands/testers.ts +1020 -0
  67. package/src/commands/token.ts +364 -0
  68. package/src/commands/turnstile.ts +271 -0
  69. package/src/commands/ui.ts +222 -0
  70. package/src/commands/upgrade.ts +517 -0
  71. package/src/commands/vector.ts +390 -0
  72. package/src/commands/worker.ts +295 -0
  73. package/src/dashboard/api.ts +323 -0
  74. package/src/dashboard/connect.ts +758 -0
  75. package/src/dashboard/contract.ts +289 -0
  76. package/src/dashboard/grant.ts +124 -0
  77. package/src/dashboard/registry.ts +519 -0
  78. package/src/dashboard/resolveTarget.ts +119 -0
  79. package/src/dev/delivery.ts +174 -0
  80. package/src/dev/devLogin.ts +155 -0
  81. package/src/dev/devLoginTargets.ts +91 -0
  82. package/src/dev/env.ts +206 -0
  83. package/src/dev/hostWorkers.ts +290 -0
  84. package/src/dev/keys.ts +111 -0
  85. package/src/dev/logging.ts +87 -0
  86. package/src/dev/openUrl.ts +75 -0
  87. package/src/dev/orchestrator.ts +1014 -0
  88. package/src/dev/ports.ts +220 -0
  89. package/src/dev/readyWatch.ts +142 -0
  90. package/src/dev/state.ts +90 -0
  91. package/src/devSecrets/bootstrapVars.ts +265 -0
  92. package/src/devSecrets/devVars.ts +240 -0
  93. package/src/devSecrets/edit.ts +256 -0
  94. package/src/devSecrets/file.ts +277 -0
  95. package/src/devSecrets/generate.ts +428 -0
  96. package/src/devSecrets/location.ts +80 -0
  97. package/src/devSecrets/mode.ts +71 -0
  98. package/src/devSecrets/records.ts +30 -0
  99. package/src/devSecrets/report.ts +99 -0
  100. package/src/devSecrets/seed.ts +344 -0
  101. package/src/devSecrets/store.ts +262 -0
  102. package/src/devSecrets/targets.ts +204 -0
  103. package/src/dispatch.ts +147 -0
  104. package/src/docs/catalog.ts +246 -0
  105. package/src/docs/writeCatalog.ts +45 -0
  106. package/src/doctor/cloudflare.ts +287 -0
  107. package/src/doctor/devPreferences.ts +155 -0
  108. package/src/doctor/devSecrets.ts +464 -0
  109. package/src/doctor/devVars.ts +414 -0
  110. package/src/doctor/devVarsLocal.ts +138 -0
  111. package/src/doctor/environments.ts +155 -0
  112. package/src/doctor/health.ts +354 -0
  113. package/src/doctor/localDelivery.ts +91 -0
  114. package/src/doctor/portsRegistry.ts +252 -0
  115. package/src/doctor/projectName.ts +584 -0
  116. package/src/doctor/secretBindings.ts +166 -0
  117. package/src/doctor/settings.ts +274 -0
  118. package/src/doctor/settingsSources.ts +202 -0
  119. package/src/doctor/workerName.ts +174 -0
  120. package/src/doctor/wranglerVars.ts +33 -0
  121. package/src/feature/bindings.ts +93 -0
  122. package/src/feature/create.ts +179 -0
  123. package/src/feature/destroy.ts +160 -0
  124. package/src/feature/devConfig.ts +201 -0
  125. package/src/feature/identity.ts +100 -0
  126. package/src/feature/manifest.ts +132 -0
  127. package/src/feature/ports.ts +615 -0
  128. package/src/feature/provision.ts +362 -0
  129. package/src/feature/sync.ts +148 -0
  130. package/src/feature/worktree.ts +282 -0
  131. package/src/help/groups.ts +47 -0
  132. package/src/help/rootUsage.ts +135 -0
  133. package/src/main.ts +73 -0
  134. package/src/migrations/ledger.ts +129 -0
  135. package/src/migrations/registry.ts +47 -0
  136. package/src/migrations/run.ts +1066 -0
  137. package/src/notifier/check.ts +129 -0
  138. package/src/notifier/installer.ts +48 -0
  139. package/src/notifier/notify.ts +152 -0
  140. package/src/notifier/state.ts +248 -0
  141. package/src/notifier/version.ts +59 -0
  142. package/src/platform/editor.ts +333 -0
  143. package/src/platform/rc.ts +118 -0
  144. package/src/platform/shell.ts +83 -0
  145. package/src/project/appBindings.ts +184 -0
  146. package/src/project/appWorkflows.ts +266 -0
  147. package/src/project/applyDomains.ts +166 -0
  148. package/src/project/askDomains.ts +220 -0
  149. package/src/project/atomic.ts +466 -0
  150. package/src/project/bindingEntries.ts +425 -0
  151. package/src/project/config.ts +701 -0
  152. package/src/project/dashboard.ts +118 -0
  153. package/src/project/deploy.ts +364 -0
  154. package/src/project/devVars.ts +113 -0
  155. package/src/project/domainPrompt.ts +191 -0
  156. package/src/project/domains.ts +386 -0
  157. package/src/project/envInventory.ts +356 -0
  158. package/src/project/environment.ts +125 -0
  159. package/src/project/extensions.ts +69 -0
  160. package/src/project/jsonc.ts +289 -0
  161. package/src/project/packageManager.ts +238 -0
  162. package/src/project/readOptionalFile.ts +342 -0
  163. package/src/project/rollback.ts +145 -0
  164. package/src/project/scaffold.ts +1088 -0
  165. package/src/project/templateFiles.ts +53 -0
  166. package/src/project/verifyDeploy.ts +230 -0
  167. package/src/project/versionMetadata.ts +77 -0
  168. package/src/project/workerAddress.ts +176 -0
  169. package/src/project/workerCommand.ts +564 -0
  170. package/src/project/workerIdentity.ts +50 -0
  171. package/src/project/workerManifest.ts +135 -0
  172. package/src/project/workerScaffold.ts +289 -0
  173. package/src/project/workerScope.ts +394 -0
  174. package/src/project/workers.ts +86 -0
  175. package/src/project/workflows.ts +281 -0
  176. package/src/project/wrangler.ts +168 -0
  177. package/src/provision/confirm.ts +86 -0
  178. package/src/provision/environment.ts +407 -0
  179. package/src/provision/featureConfig.ts +98 -0
  180. package/src/provision/mode.ts +62 -0
  181. package/src/provision/pendingSecrets.ts +96 -0
  182. package/src/provision/resources.ts +126 -0
  183. package/src/provision/secretBindings.ts +149 -0
  184. package/src/provision/store.ts +33 -0
  185. package/src/provision/unprovisioned.ts +114 -0
  186. package/src/provision/wranglerEnv.ts +220 -0
  187. package/src/rootFlags.ts +48 -0
  188. package/src/seed/drivers.ts +423 -0
  189. package/src/seed/media.ts +187 -0
  190. package/src/seed/plan.ts +137 -0
  191. package/src/seed/prepare.ts +224 -0
  192. package/src/seed/registry.ts +25 -0
  193. package/src/seed/run.ts +793 -0
  194. package/src/seed/safety.ts +206 -0
  195. package/src/terminal/logger.ts +42 -0
  196. package/src/terminal/output.ts +64 -0
  197. package/src/terminal/style.ts +132 -0
  198. package/src/test-utils/doctorHarness.ts +190 -0
  199. package/src/test-utils/migrateHarness.ts +126 -0
  200. package/src/test-utils/seedHarness.ts +173 -0
  201. package/src/test-utils/tempRepo.ts +45 -0
  202. package/src/tokens/config.ts +16 -0
  203. package/src/tokens/engine.ts +345 -0
  204. package/src/tokens/mintedTokens.ts +233 -0
  205. package/src/tokens/sinks.ts +84 -0
  206. package/src/ui/flow.ts +451 -0
  207. package/src/ui/react.ts +112 -0
  208. package/src/ui/routeAllowlist.ts +208 -0
  209. package/src/ui/scaffold.ts +113 -0
  210. package/src/ui/screenStyles.ts +127 -0
  211. package/src/ui/stubs.ts +135 -0
  212. package/src/ui/templates.ts +52 -0
  213. package/src/ui/wire.ts +311 -0
  214. package/src/ui/workerUi.ts +172 -0
  215. package/templates/starter/.dev.secrets.example.jsonc +43 -0
  216. package/templates/starter/.dev.vars.example +30 -0
  217. package/templates/starter/apps/api/package.json +22 -0
  218. package/templates/starter/apps/api/pithy.config.ts +65 -0
  219. package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
  220. package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
  221. package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
  222. package/templates/starter/apps/api/src/index.ts +8 -0
  223. package/templates/starter/apps/api/tsconfig.json +26 -0
  224. package/templates/starter/apps/api/wrangler.jsonc +68 -0
  225. package/templates/starter/biome.template.jsonc +75 -0
  226. package/templates/starter/gitignore +37 -0
  227. package/templates/starter/package.json +28 -0
  228. package/templates/starter/pithy.config.ts +67 -0
  229. package/templates/starter/plugins/no-console.grit +25 -0
  230. package/templates/starter/plugins/no-process-io.grit +25 -0
  231. package/templates/starter/tsconfig.json +14 -0
  232. package/templates/starter/tsconfig.tools.json +30 -0
  233. package/templates/starter/vitest.config.ts +124 -0
  234. package/templates/starter/vitest.workers.config.ts +26 -0
@@ -0,0 +1,466 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { randomBytes } from "node:crypto";
5
+ import type { Stats } from "node:fs";
6
+ import type { FileHandle } from "node:fs/promises";
7
+ import { lstat, open, readdir, readlink, rename, unlink } from "node:fs/promises";
8
+ import { basename, dirname, isAbsolute, join, parse, sep } from "node:path";
9
+ import { ConflictError, InternalError, NotFoundError, PithyError } from "@pithy-sh/core/src/error/pithyError";
10
+
11
+ /** How many links deep a walk may go before it is called a loop. Well past anything deliberate. */
12
+ const MAX_LINK_DEPTH = 16;
13
+
14
+ /** Bytes of randomness in a temp file's name. 64 bits: nothing to guess, nothing to plant at. */
15
+ const TEMP_SUFFIX_BYTES = 8;
16
+
17
+ /** The tail every temp file this module writes carries, and the only shape {@link sweepStaleTemps} removes. */
18
+ const TEMP_SUFFIX = ".tmp";
19
+
20
+ /**
21
+ * How old a temp file has to be before it is treated as a corpse rather than a write in flight. A write
22
+ * here is a few milliseconds of one small file; a minute is not slow, it is dead.
23
+ */
24
+ const STALE_TEMP_MS = 60_000;
25
+
26
+ /** Options for {@link writeFileAtomic}. */
27
+ export interface AtomicWriteOptions {
28
+ /**
29
+ * The mode a **newly created** file lands with — for the files that hold credentials, where the umask
30
+ * is not a permission policy. Also the **ceiling** for one that already exists: a target of ours that is
31
+ * tighter keeps its own mode, because that is the adopter's decision and a write is not the moment to
32
+ * overrule it; one that is wider does not, because nobody meant a credential file to be widened. A
33
+ * target somebody else owns has its mode ignored either way — see {@link adoptableModeOf}.
34
+ */
35
+ mode?: number;
36
+ }
37
+
38
+ /**
39
+ * Write `content` to `path` atomically: write to a sibling `.tmp` file first, then rename.
40
+ *
41
+ * **The rename replaces the target, so everything the target *was* has to be carried onto the temp file
42
+ * first.** Two things were not, and both failed silently.
43
+ *
44
+ * Its **mode**. `pithy init` chmods `.dev.vars` to 0600, and the first `pithy add` or `pithy token mint
45
+ * --store dev-vars` wrote through here and handed it back the temp file's 0644 — at exactly the moment
46
+ * it started holding `CLOUDFLARE_API_TOKEN` and `SECRETS_ENCRYPTION_KEYS`. An existing target's mode is
47
+ * kept; `options.mode` is the mode for creating one that is not there yet. Kept only from a target that is
48
+ * *ours* and only where it is no *wider* than asked for, though — a mode read off a file is an instruction
49
+ * taken from a file, and pre-creating `.dev.vars` at 0644 is one line of work for anyone who can write the
50
+ * directory. See {@link adoptableModeOf}.
51
+ *
52
+ * Its **link**. `apps/<worker>/.dev.vars` used to be a symlink to the project's shared file, and a rename
53
+ * over a symlink does not follow it — it deletes it and leaves a private regular file holding a stale copy.
54
+ * Nothing repaired that afterwards: the wiring then correctly saw a regular file and reported it `kept`
55
+ * forever, so the worker silently stopped seeing every secret the shared file gained. That share is gone
56
+ * and every Worker's file is generated now (#154), but a link at a path written here is still the adopter's
57
+ * to make and the failure would be the same. A link is resolved and written *through* — a dangling one has
58
+ * its destination created rather than the link replaced — and a chain that loops is refused rather than
59
+ * followed.
60
+ *
61
+ * **The temp file is somewhere nobody could have got to first.** Its name was `${target}.tmp`: a path
62
+ * anyone able to write the project directory could work out and plant a symlink at. The write followed
63
+ * that link, chmod'd its destination, and the rename then installed the link permanently over the target
64
+ * — so `.dev.vars` and every later write of it went wherever the planter chose. There was no race to win.
65
+ * The name now carries {@link TEMP_SUFFIX_BYTES} random bytes, and the file is created **exclusively**, so
66
+ * anything already at the path fails the open instead of being written through.
67
+ *
68
+ * **And the temp file is only ever touched through the descriptor that created it.** The exclusive create
69
+ * closed the *write* half of that and left the *chmod* half open: `chmod(tmp, mode)` resolves the name a
70
+ * second time, so swapping the temp file for a symlink in the window after the open chmod'd the link's
71
+ * destination instead — an arbitrary chmod on any file the invoking user owns, which for a mode of 0666 is
72
+ * an arbitrary disclosure. A path-based operation after a path-based create is the shape of the bug, every
73
+ * time. The handle is held: `fchmod` and the write both go through the descriptor, so there is no second
74
+ * resolution of the name to win. What remains path-based is the `rename` itself, which Node gives no
75
+ * descriptor-relative form of — so before it runs, the inode at the name is checked against the inode the
76
+ * bytes actually went into ({@link ensureUnswapped}). That turns a reliable swap into the same narrow race
77
+ * the walk below already documents; it does not close it.
78
+ *
79
+ * **And the link is only followed when we could have made it.** Closing the temp path moved that same
80
+ * escape one step earlier rather than shutting it: plant the link at `.dev.vars` instead of at its temp
81
+ * file and a live `CLOUDFLARE_API_TOKEN` lands outside the project exactly as before. See
82
+ * {@link resolveWritePath} for why the rule is ownership and not location — the short of it is that the
83
+ * link this must follow points outside the project too.
84
+ *
85
+ * **What a killed run leaves is reclaimed.** An unguessable temp name means every interrupted write leaves
86
+ * a *distinct* file holding the whole plaintext of what it was writing, where the old fixed name was at
87
+ * least overwritten by the next run. `*.tmp` keeps those out of git and out of `npm pack`; it does not keep
88
+ * them off the disk. A finished write sweeps its target's stale siblings ({@link sweepStaleTemps}). Not an
89
+ * exit handler: SIGKILL, an OOM kill and a pulled power cord are exactly the cases that leave one, and none
90
+ * of them run a handler.
91
+ */
92
+ export async function writeFileAtomic(path: string, content: string, options?: AtomicWriteOptions): Promise<void> {
93
+ const target = await resolveWritePath(path);
94
+ const mode = (await adoptableModeOf(target, options?.mode)) ?? options?.mode;
95
+ const tmp = `${target}.${randomBytes(TEMP_SUFFIX_BYTES).toString("hex")}${TEMP_SUFFIX}`;
96
+
97
+ let handle: FileHandle;
98
+ try {
99
+ // `wx` is O_CREAT|O_EXCL: the file is always brand new, so the mode here is the mode it is *born*
100
+ // with rather than one it is widened to afterwards. Both halves matter. Exclusivity is the guard —
101
+ // a planted file or symlink fails the open rather than being followed. Creating restricted is the
102
+ // window — a file created at the umask default and tightened later spends an interval holding a
103
+ // plaintext credential world-readable, and a crashed run's leftover would have kept its own mode
104
+ // through O_CREAT.
105
+ handle = await open(tmp, "wx", mode ?? 0o666);
106
+ } catch (err) {
107
+ // Nothing is unlinked here: we did not create it, so removing it would be a write to the very path
108
+ // we just refused — and if the open failed for any other reason, that file is somebody else's.
109
+ throw writeFailure(target, tmp, err);
110
+ }
111
+
112
+ let written: Stats;
113
+ try {
114
+ // `fchmod`, before a byte is written and through the descriptor rather than the name. Before,
115
+ // because the umask can only clear bits and may have cleared one the target actually had, so this
116
+ // is what makes the mode exact — and doing it while the file is still empty means the content never
117
+ // exists at a mode wider than asked for. Through the descriptor, because the name is the attacker's
118
+ // to change and the inode is not.
119
+ if (mode !== undefined) await handle.chmod(mode);
120
+ await handle.writeFile(content);
121
+ // `fstat` before the close. This is the inode the bytes are in, and the only way to ask later
122
+ // whether the name still refers to it.
123
+ written = await handle.stat();
124
+ await handle.close();
125
+ } catch (err) {
126
+ await handle.close().catch(() => {});
127
+ await unlink(tmp).catch(() => {});
128
+ throw writeFailure(target, tmp, err);
129
+ }
130
+
131
+ await ensureUnswapped(tmp, target, written);
132
+ try {
133
+ await rename(tmp, target);
134
+ } catch (err) {
135
+ await unlink(tmp).catch(() => {});
136
+ throw writeFailure(target, tmp, err);
137
+ }
138
+ // After the rename, never before: the bytes are already in place, so a sweep that cannot run costs the
139
+ // caller nothing. It never throws for the same reason.
140
+ await sweepStaleTemps(target);
141
+ }
142
+
143
+ /**
144
+ * Refuse to rename a temp file that is no longer the one this write created.
145
+ *
146
+ * `rename` is the last path-based step and there is no descriptor-relative form of it in Node, so the
147
+ * name is resolved once more here whatever we do. Comparing the inode at the name against the inode the
148
+ * bytes went into means a swap has to land inside the gap between this check and the rename rather than
149
+ * anywhere in the whole write — the same narrow race {@link ensureOurs} already records, not a closure of
150
+ * it. Left unchecked it is not a race at all: replace the temp file with a symlink any time before the
151
+ * rename and the link gets installed over the target, which is exactly the escape the random name and the
152
+ * exclusive create were meant to end.
153
+ *
154
+ * The window that remains is accepted, not overlooked: `docs/ACCEPTED-LIMITS.md`, "The inode check →
155
+ * `rename` window".
156
+ */
157
+ async function ensureUnswapped(tmp: string, target: string, written: Stats): Promise<void> {
158
+ const now = await lstat(tmp).catch(() => undefined);
159
+ if (now !== undefined && now.dev === written.dev && now.ino === written.ino) return;
160
+ // Nothing is unlinked. Whatever is at that name now is not ours to remove — and our own inode already
161
+ // is unlinked, because replacing the name is what did it, so the close above reclaimed it and the
162
+ // plaintext with it.
163
+ throw new ConflictError({
164
+ message: `Refusing to write ${target}: its temporary file ${tmp} was replaced while it was being written.`,
165
+ action: "Run this again. If nothing of yours could have done that, treat it as hostile.",
166
+ detail: now === undefined ? `${tmp} no longer exists.` : `${tmp} is a different inode than the one written.`,
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Turn a `node:fs` failure into the error contract. `--json` callers parse that contract, so a raw errno
172
+ * escaping it is unreadable to them — and the two that actually happen here each have something to say.
173
+ * The paths go in `message` because the operator has to look at them; the errno stays in `detail`.
174
+ */
175
+ function writeFailure(target: string, tmp: string, err: unknown): PithyError {
176
+ if (err instanceof PithyError) return err;
177
+ const code = errnoOf(err);
178
+ const detail = `${code ?? "unknown error"} while writing ${tmp}.`;
179
+ if (code === "EEXIST") {
180
+ return new ConflictError(
181
+ {
182
+ message: `Refusing to write ${target}: something is already at its temporary file ${tmp}.`,
183
+ action: "Delete it and run this again. If you did not put it there, treat it as hostile.",
184
+ detail,
185
+ },
186
+ { cause: err },
187
+ );
188
+ }
189
+ if (code === "ENOENT") {
190
+ return new NotFoundError(
191
+ {
192
+ message: `Cannot write ${target}: the directory it is in does not exist.`,
193
+ action: "Create the directory, or fix the symlink pointing into it.",
194
+ detail,
195
+ },
196
+ { cause: err },
197
+ );
198
+ }
199
+ if (code === "ENOTDIR") {
200
+ // What the walk now hands the kernel rather than collapsing: `file.txt/../out.txt`. The generic
201
+ // message told the adopter to check that the file was writable, which is not the thing in the way.
202
+ return new ConflictError(
203
+ {
204
+ message: `Cannot write ${target}: something on the way to it is not a directory.`,
205
+ action: "Check the path — a file is standing where a directory has to be.",
206
+ detail,
207
+ },
208
+ { cause: err },
209
+ );
210
+ }
211
+ return new InternalError(
212
+ {
213
+ message: `Could not write ${target}.`,
214
+ action: "Check the file and its directory are writable.",
215
+ detail,
216
+ },
217
+ { cause: err },
218
+ );
219
+ }
220
+
221
+ /**
222
+ * The `errno` string off a `node:fs` rejection, without asserting a shape onto an unknown value.
223
+ *
224
+ * Exported for `scaffold.ts`, which has the same job on the delete side: a raw errno reaching a `--json`
225
+ * caller is unparseable output, and the errno is what says which failure it was.
226
+ */
227
+ export function errnoOf(err: unknown): string | undefined {
228
+ if (typeof err !== "object" || err === null) return undefined;
229
+ const code: unknown = Reflect.get(err, "code");
230
+ return typeof code === "string" ? code : undefined;
231
+ }
232
+
233
+ /**
234
+ * The permission bits worth carrying onto the temp file, or undefined when there are none to take.
235
+ *
236
+ * `lstat`, not `stat`: `path` comes out of {@link resolveWritePath} with every link already followed and
237
+ * checked, so a link here would be one that appeared since — not one to read a mode through.
238
+ *
239
+ * **Never wider than `requested`.** Adopting the target's mode is what keeps an adopter's deliberate 0400,
240
+ * but it is also an instruction taken from a file, and whoever wrote the file wrote the instruction.
241
+ * Someone who can write the project directory but not read the 0600 file in it — the position every attack
242
+ * here is launched from — pre-creates `.dev.vars` at 0644, and the freshly minted `CLOUDFLARE_API_TOKEN`
243
+ * lands world-readable with the write reporting nothing wrong. So a mode is adopted only when its bits are
244
+ * a subset of the ones asked for: tightening is the adopter's to do, widening is not something they can
245
+ * have meant. `0o7777`, so a setuid bit is a widening too.
246
+ *
247
+ * A caller that names no mode names no ceiling, and nothing is refused. Those are `wrangler.jsonc` and
248
+ * `package.json` — files holding no credential, whose existing mode is the whole question (#146). The
249
+ * files that carry a secret all state the mode they need.
250
+ *
251
+ * **And only from a file we own**, which is the narrower rule the ceiling leaves standing: a foreign 0400
252
+ * is not a widening, and it is still an instruction from a file we did not write.
253
+ *
254
+ * Deliberately stricter than {@link ensureOurs}, which allows root. A root-owned *link* sends a write
255
+ * somewhere root chose, and root can read anything of ours regardless — nothing is given away. A root-owned
256
+ * *mode* of 0666 gives it to everybody else, which is not root's to hand out on our behalf.
257
+ *
258
+ * The same limit as {@link ensureOurs} applies: a platform with no uid model — Windows — has nothing to
259
+ * compare and adopts from anyone. The ceiling holds there, and it is the half that stops a widening.
260
+ *
261
+ * What the ownership check and the ceiling together still leave — a file we own, pre-positioned by someone
262
+ * who can write the directory — is accepted rather than overlooked: `docs/ACCEPTED-LIMITS.md`, "A
263
+ * pre-positioned file we already own" and "Windows has no uid model".
264
+ */
265
+ async function adoptableModeOf(path: string, requested: number | undefined): Promise<number | undefined> {
266
+ try {
267
+ const entry = await lstat(path);
268
+ const us = process.geteuid?.();
269
+ if (us !== undefined && entry.uid !== us) return undefined;
270
+ const mode = entry.mode & 0o7777;
271
+ if (requested !== undefined && (mode & ~requested) !== 0) return undefined;
272
+ return mode;
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ /** Root can already read and write anything of ours, so a link it made redirects nothing it did not have. */
279
+ const ROOT_UID = 0;
280
+
281
+ /**
282
+ * Refuse a symlink somebody else made.
283
+ *
284
+ * This is the whole containment rule, and it is about the link's **owner**, not its destination.
285
+ *
286
+ * **Nothing in this repository makes a symlink any more.** The shared `.dev.vars` linked into each
287
+ * `apps/<worker>/` is generated per Worker instead (#154); `scripts/worktree.ts` links nothing and says so;
288
+ * the dev secrets file is found by name outside every checkout rather than linked into one (#156). So there
289
+ * is no arrangement of ours left for this to recognize. Every link the walk can meet is the adopter's own,
290
+ * on their machine, for a reason they did not tell us — beside a planted `.dev.vars` → `/tmp/loot`, which
291
+ * is indistinguishable from it by destination. Location cannot classify either: the writes that land in
292
+ * `<config>/<project>/` are outside every checkout by design, so there is no project root to contain to,
293
+ * and one that existed would refuse the adopter's link along with the planted one.
294
+ *
295
+ * Nor is replacing a link the safe way out. A rename over one deletes it and leaves a private regular file
296
+ * holding a stale copy, silently and permanently — that is #146, and it is why the choice here is follow or
297
+ * refuse.
298
+ *
299
+ * What actually differs is who made the link. A legitimate one is made as the developer running this
300
+ * command, whether by their own hand or by ours. A planted one is made by a different uid: someone who can
301
+ * write a directory in the project but cannot read the 0600 file in it, which is exactly the position this
302
+ * attack is launched from. `symlink(2)` stamps the creating uid on the link and only root may `chown` it
303
+ * afterwards, so that owner is not forgeable by the planter.
304
+ *
305
+ * Where the same uid made the link, it is ours by definition and there is nothing left to defend: anything
306
+ * running as us can already read every file we could write.
307
+ *
308
+ * Two limits, stated rather than papered over. A platform with no uid model — Windows — has nothing to
309
+ * compare and is not protected by this. And the check and the `readlink` after it are separate syscalls,
310
+ * so a planter who can win a window that narrow is not stopped; closing that needs `openat`, which Node
311
+ * does not expose for a directory-relative walk.
312
+ *
313
+ * Both are accepted, and the threat model that decides how much they matter is written down with them:
314
+ * `docs/ACCEPTED-LIMITS.md`, "The `lstat` → `readlink` window" and "Windows has no uid model".
315
+ */
316
+ function ensureOurs(link: string, uid: number, requested: string): void {
317
+ const us = process.geteuid?.();
318
+ if (us === undefined || uid === us || uid === ROOT_UID) return;
319
+ throw new ConflictError({
320
+ message: `Refusing to write ${requested}: ${link} is a symlink somebody else owns.`,
321
+ action: "Delete it and run this again. If you did not put it there, treat it as hostile.",
322
+ detail: `The link is owned by uid ${uid}; this process runs as uid ${us}.`,
323
+ });
324
+ }
325
+
326
+ /** The non-empty parts of a path below its root. `parse().root` is what makes this correct on Windows too. */
327
+ function partsOf(path: string): string[] {
328
+ return path.slice(parse(path).root.length).split(sep).filter(Boolean);
329
+ }
330
+
331
+ /**
332
+ * Where the write actually lands — every component resolved, and every symlink on the way checked by
333
+ * {@link ensureOurs}.
334
+ *
335
+ * The walk is component by component rather than a `readlink` on the last one, because a link three
336
+ * directories up carries a write out of the project just as completely as a link at the target, and the
337
+ * target reads as an ordinary file the whole time. `apps/` was the shape that did it (#147), and asking
338
+ * only about the final component is what let it through.
339
+ *
340
+ * A dangling link resolves to the path it names, so the write creates that file and the link keeps
341
+ * pointing at it. A missing directory resolves to the literal path below it and the write reports it. A
342
+ * cycle is refused: following it never ends, and picking one link in it to overwrite would be a guess at
343
+ * what the caller meant.
344
+ *
345
+ * **Nothing below a component the kernel could not walk through is normalized.** The walk used to hand the
346
+ * remainder to `join`, which collapses `..` *lexically* — `missing/../apps/.dev.vars` came back as
347
+ * `apps/.dev.vars`, a path the kernel would have refused outright and, worse, one whose surviving
348
+ * components were then traversed by the open with no ownership check on them at all. Plant the link at
349
+ * `apps` and it is followed. Do not lexically normalize a path you are about to hand to a syscall — that is
350
+ * the same mistake as following a link because the name looked fine.
351
+ *
352
+ * A component that is *there* and is not a directory is the same case, and was missed by the first fix:
353
+ * `file.txt/..` is ENOTDIR to the kernel every single time, and collapsing it produced a path the caller
354
+ * never named and this walk never checked. A typo reaches it; no attacker is required. So the rule is not
355
+ * "past the first missing component" but past the first one that cannot be walked *through*: components are
356
+ * then appended verbatim, `..` included, and the syscall judges the path it was actually given.
357
+ */
358
+ async function resolveWritePath(path: string): Promise<string> {
359
+ const absolute = isAbsolute(path) ? path : join(process.cwd(), path);
360
+ let resolved = parse(absolute).root;
361
+ let pending = partsOf(absolute);
362
+ let hops = 0;
363
+ /**
364
+ * Set the moment a component cannot be walked through — it is not there, or it is not a directory.
365
+ * Nothing after it can be resolved, and the last component of an ordinary write sets it harmlessly.
366
+ */
367
+ let opaque = false;
368
+
369
+ while (pending.length > 0) {
370
+ const part = pending.shift() as string;
371
+ if (part === ".") continue;
372
+ if (opaque) {
373
+ // Verbatim, not `join`. `join` would collapse a `..` here against something the kernel cannot walk
374
+ // through, inventing a path the caller never named and would never have reached.
375
+ resolved = `${resolved}${sep}${part}`;
376
+ continue;
377
+ }
378
+ if (part === "..") {
379
+ // Safe only here, above the first unwalkable component: every directory to the left has been walked
380
+ // and every link in it expanded, so `resolved` is physical and its parent is the kernel's parent.
381
+ resolved = dirname(resolved);
382
+ continue;
383
+ }
384
+
385
+ const next = join(resolved, part);
386
+ let link: string;
387
+ try {
388
+ const entry = await lstat(next);
389
+ if (!entry.isSymbolicLink()) {
390
+ resolved = next;
391
+ // A file, a socket, a device: real, and nothing below it exists to reach. Whatever follows is the
392
+ // kernel's ENOTDIR to give, not ours to normalize away.
393
+ opaque = !entry.isDirectory();
394
+ continue;
395
+ }
396
+ ensureOurs(next, entry.uid, path);
397
+ link = await readlink(next);
398
+ } catch (err) {
399
+ if (err instanceof PithyError) throw err;
400
+ // Nothing here, or it stopped being a link between the two calls. Either way there is no link left
401
+ // to follow: the rest is taken literally and the write reports whatever it finds.
402
+ resolved = next;
403
+ opaque = true;
404
+ continue;
405
+ }
406
+
407
+ hops += 1;
408
+ if (hops > MAX_LINK_DEPTH) {
409
+ throw new InternalError({
410
+ message: `Refusing to write ${path}: its symlink chain never ends.`,
411
+ action: "Fix the link — something points back at itself.",
412
+ detail: `More than ${MAX_LINK_DEPTH} symlinks deep from ${path}.`,
413
+ });
414
+ }
415
+ // A relative link is read from the directory holding it, which `resolved` already is. `..` inside one
416
+ // is resolved after the hop, against where the link landed — the same order the kernel walks.
417
+ if (isAbsolute(link)) resolved = parse(link).root;
418
+ pending = [...partsOf(link), ...pending];
419
+ }
420
+ return resolved;
421
+ }
422
+
423
+ /**
424
+ * Delete the stale temp files of `target` — what runs killed between the create and the rename left holding
425
+ * a full copy of whatever was being written, `.dev.vars` included.
426
+ *
427
+ * Four conditions, each one load-bearing:
428
+ *
429
+ * - **Exactly the name this module writes**: `<target>.<16 hex>.tmp`. Not `<target>.tmp` — that was the old
430
+ * fixed name, and it is also a plausible file of an adopter's own. We remove our litter, not theirs.
431
+ * - **A regular file.** A symlink planted at a temp name stays: unlinking it is a write to the path the
432
+ * exclusive open just refused, and a directory there is not ours to touch either.
433
+ * - **Ours.** Another uid's file is not litter we may collect.
434
+ * - **Old.** A concurrent write is a live temp file with a fresh mtime, and deleting one would break it.
435
+ *
436
+ * Best effort throughout: this runs after a successful rename, so nothing it does or fails to do changes
437
+ * what the caller got. What it cannot reclaim is a leftover beside a target nothing writes again.
438
+ */
439
+ async function sweepStaleTemps(target: string): Promise<void> {
440
+ const us = process.geteuid?.();
441
+ const directory = dirname(target);
442
+ const prefix = `${basename(target)}.`;
443
+ const width = prefix.length + TEMP_SUFFIX_BYTES * 2 + TEMP_SUFFIX.length;
444
+ const cutoff = Date.now() - STALE_TEMP_MS;
445
+
446
+ let entries: string[];
447
+ try {
448
+ entries = await readdir(directory);
449
+ } catch {
450
+ return;
451
+ }
452
+
453
+ for (const name of entries) {
454
+ if (name.length !== width || !name.startsWith(prefix) || !name.endsWith(TEMP_SUFFIX)) continue;
455
+ if (!/^[0-9a-f]+$/.test(name.slice(prefix.length, -TEMP_SUFFIX.length))) continue;
456
+ const stale = join(directory, name);
457
+ try {
458
+ const entry = await lstat(stale);
459
+ if (!entry.isFile() || entry.mtimeMs > cutoff) continue;
460
+ if (us !== undefined && entry.uid !== us) continue;
461
+ await unlink(stale);
462
+ } catch {
463
+ // Already gone, or not ours to remove. The write this followed still succeeded either way.
464
+ }
465
+ }
466
+ }