@edgehero/pi-dispatch 1.10.3 → 2.0.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 (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +29 -2
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +363 -13
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1348 -326
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
package/src/env-file.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  * Deliberately dependency-free (node:fs only, and only in the thin wrapper): it must stay importable
18
18
  * from any future setup command without dragging worker config or queue deps along.
19
19
  */
20
- import { chmodSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
20
+ import { chmodSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
21
21
 
22
22
  /**
23
23
  * Pure transform over .env TEXT: set `key` to `value` only where nothing is set yet.
@@ -33,15 +33,344 @@ import { chmodSync, readFileSync, renameSync, statSync, writeFileSync } from "no
33
33
  * the cost of wrongly leaving a key alone (operator sets it by hand) is a fraction of the cost of
34
34
  * wrongly overwriting one.
35
35
  */
36
- export function setEnvKeyIfEmpty(text, key, value) {
36
+ /**
37
+ * Replace a commented line with `KEY=value`, KEEPING the original line above it as a comment when it said
38
+ * anything, and dropping it when it did not.
39
+ *
40
+ * WHAT THE KEEPING IS FOR, restated after issue #392 moved the shipped file's documentation (issue #394):
41
+ * a line being replaced may carry the operator's own note -- `# PI_LOGS_DIR=/old # the NFS one` -- and
42
+ * both transforms replace a line whole, so without this an `up` that fills four commented keys silently
43
+ * deletes four lines of somebody's reference. That is still true of a hand-written `.env`.
44
+ *
45
+ * It is no longer true of `.env.example`, which now documents each key in comment lines ABOVE it and
46
+ * leaves the key's own line bare (`# PI_LOGS_DIR=`). Keeping THAT line preserved nothing and left every
47
+ * `up` deployment carrying four stubs whose only purpose was to carry text they no longer carry. So a bare
48
+ * commented key -- nothing after the `=` but whitespace -- is dropped, and anything else is kept. The
49
+ * documentation above the key is untouched either way: only the key's own line is ever replaced.
50
+ *
51
+ * The obvious alternative, carrying the inline `# ...` onto the new line, was written first and then
52
+ * REJECTED: `deploy/worker.service` feeds this file to systemd through `EnvironmentFile=`, whose parser
53
+ * recognises a comment only at the start of a line, so `PI_LOGS_DIR=/srv/logs # where records land`
54
+ * sets that variable to the path PLUS the sentence. On this one key that is the difference between the run
55
+ * history landing in the right directory and the worker creating a directory named after a sentence. The
56
+ * wrapper scripts source the file with shell semantics, where the same line is harmless, so the two
57
+ * consumers disagree and the safe shape is the one both read identically: a value with nothing after it.
58
+ *
59
+ * Only a line that was ALREADY a comment is kept. Replacing a set line means replacing a real value, and
60
+ * copying the old one up as a comment would leave a fragment of what was replaced behind, on the path
61
+ * whose own docblock warns it will happily overwrite a live credential.
62
+ */
63
+ /** A commented key that says nothing: `# KEY=`, with only whitespace after the `=` and no inline note. */
64
+ const BARE_COMMENTED_KEY = /^[ \t]*#[ \t]*(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*=[ \t]*$/;
65
+
66
+ function replacementLines(key, value, bare, wasComment, opts) {
67
+ const rendered = renderEnvValue(value, opts);
68
+ const keep = wasComment && !BARE_COMMENTED_KEY.test(bare);
69
+ return keep ? [bare, `${key}=${rendered}`] : [`${key}=${rendered}`];
70
+ }
71
+
72
+ /**
73
+ * A value rendered so that BOTH consumers of this file read back exactly what was written.
74
+ *
75
+ * Measured rather than assumed, in `/bin/sh`, `/bin/bash` and `/bin/zsh` through the same
76
+ * `set -a; . ./.env; set +a` the wrapper scripts use:
77
+ *
78
+ * - bare `KEY=/a b/c.json` -> the shell splits at the space, the key ends up EMPTY, and the tail is
79
+ * RUN as a command by the service account. A deployment folder with a space in its name is ordinary
80
+ * on macOS, which is exactly the platform whose wrapper sources this file.
81
+ * - bare `KEY=/a #2/c.json` -> truncated at the `#`, and a path that does not exist is written with a ✓.
82
+ * - `KEY="/x$HOME/y"` -> the shell EXPANDS `$HOME`; double quotes are not enough.
83
+ * - `KEY='/x$HOME/y'` -> exact, in all three, and systemd's `EnvironmentFile=` parser has a
84
+ * single-quote state too.
85
+ *
86
+ * So anything outside a conservative unquoted set is single-quoted. A value containing a single quote is
87
+ * REFUSED rather than escaped: the shells want `'\''` and systemd's parser does not understand it, so no
88
+ * one rendering is read identically by both, and inventing one would be the kind of cleverness that ships
89
+ * a path nobody can read back.
90
+ *
91
+ * THE SET IS THE READER'S OWN, `UNQUOTED_PLAIN` below (PR #478's gate). The writer had a set of its own that wrote a
92
+ * leading `=` or a `:=` bare, which the reader refuses (zsh expands both), so `up` wrote a line doctor then called
93
+ * unread. One set, so what is written bare is exactly what is read back as plain, and everything else is quoted.
94
+ */
95
+
96
+ /**
97
+ * cmd's own bare set, derived from `deploy/worker-env-wrapper.cmd` rather than borrowed from the POSIX
98
+ * one, which is a distinction this got wrong once. That loader is
99
+ * `for /f "usebackq eol=# tokens=1,* delims==" %%A in (".env") do set "%%A=%%B"`, the QUOTED `set` form,
100
+ * which preserves spaces exactly. So a space is fine there and `C:\Program Files\...` needs no quoting,
101
+ * while the POSIX predicate would have refused all four keys on the commonest Windows layout there is.
102
+ *
103
+ * What cmd cannot be shown to carry is `cmdValueRefusal`'s list (issue #470): a line break or another control
104
+ * character, a `"`, a `%`, a `!`, a `^`, a character outside ASCII, and an `=` at the start of the value. Each
105
+ * row there says whether it is cmd's documented behaviour or a cautious refusal. `'` is ordinary there.
106
+ */
107
+ export function renderEnvValue(value, { platform = process.platform } = {}) {
108
+ const v = String(value);
109
+ // The Windows loader keeps surrounding quotes as part of the value ("Values MUST be UNQUOTED", its own
110
+ // header), so nothing is ever quoted for it: a value it can take is written bare, and one it cannot is
111
+ // refused rather than dressed in quotes that become part of a path.
112
+ if (platform === "win32") {
113
+ const bad = cmdValueRefusal(v);
114
+ if (bad !== null) throw new Error(`cannot write this value into a .env on Windows: it contains ${bad}, and the .cmd wrapper (deploy/worker-env-wrapper.cmd) cannot be shown to read that back as written. Choose a value without it, or give the service this key through its own environment (pi-dispatch service install --env-setup)`);
115
+ return v;
116
+ }
117
+ if (UNQUOTED_PLAIN.test(v)) return v;
118
+ if (v.includes("'") || /[\n\r]/.test(v)) throw new Error(`cannot write this value into a .env safely: ${v.includes("'") ? "it contains a single quote" : "it contains a newline"}`);
119
+ // Gate round 2 of PR #478: a control or invisible character is read alike by every loader inside single quotes, but
120
+ // the reader never vouches for it (`QUOTED_CONTROL`: printing it rewrites a terminal), so writing it quoted gave doctor
121
+ // a line it calls unread. Refused instead, naming the character, never the value.
122
+ const hidden = invisibleCharacter(v);
123
+ if (hidden !== null) throw new Error(`cannot write this value into a .env safely: it contains ${hidden}, which doctor would not show back. Remove it`);
124
+ return `'${v}'`;
125
+ }
126
+
127
+ /**
128
+ * HOW THE WINDOWS WRAPPER READS THIS FILE, and what the writer refuses there (issue #470). The loader is one line of
129
+ * `deploy/worker-env-wrapper.cmd`, run under a bare `setlocal`:
130
+ *
131
+ * for /f "usebackq eol=# tokens=1,* delims==" %%A in (".env") do set "%%A=%%B"
132
+ *
133
+ * Nobody can run cmd.exe where this is tested, so every row is either DOCUMENTED (Microsoft's `for /?`, `set /?` and
134
+ * `cmd /?` texts), COMMUNITY (the batch parser's phase model and set's quote handling, as the long-standing write-ups
135
+ * of cmd's parser record them, not Microsoft), or CAUTIOUS: behaviour this command cannot confirm, so it is refused by
136
+ * name rather than guessed. A refusal names the line and the character, never the value.
137
+ *
138
+ * THE LINE (for /f)
139
+ * `delims==` REPLACES the default space and tab DOCUMENTED. A name is everything before the first `=`,
140
+ * its blanks included
141
+ * `eol=#`: a line whose first character is `#` DOCUMENTED. Skipped. It replaces the default `;`, so a `;`
142
+ * line is a variable named `;...`, not a comment
143
+ * `tokens=1,*`: the value is the rest of the line DOCUMENTED. `=` inside it survives (base64, API keys)
144
+ * an empty line skipped, and assigns nothing either way
145
+ * CRLF COMMUNITY: for /f drops the CR before the LF (relied on
146
+ * since #447, and CRLF is the platform's own ending)
147
+ * a CR anywhere else, a NUL, a Ctrl-Z (0x1A) CAUTIOUS, file-wide: whether for /f ends a line, stops the
148
+ * file or keeps the byte is not documented, so no line below
149
+ * can be vouched for
150
+ * a line whose `set "..."` exceeds 8191 characters CAUTIOUS, file-wide: 8191 is cmd's documented command-line
151
+ * limit, and whether it binds after FOR substitution is not
152
+ *
153
+ * THE NAME (set, for the key being written)
154
+ * `webhook_secret=` for WEBHOOK_SECRET DOCUMENTED: Windows variable names ignore case, so the line
155
+ * assigns (or, empty, removes) the key. REFUSED by name: the
156
+ * line scan here is case-sensitive, and a Windows line should
157
+ * spell the key as the service reads it
158
+ * `WEBHOOK_SECRET` with no `=` DOCUMENTED: `set "WEBHOOK_SECRET="` removes the key.
159
+ * REFUSED by name
160
+ * `WEBHOOK_SECRET =x` (a blank before the `=`) COMMUNITY: the name keeps the blank, so the service gets no
161
+ * WEBHOOK_SECRET. REFUSED by name
162
+ * ` WEBHOOK_SECRET=x` (indented), a BOM, a `"` for /f keeps the leading blanks in the name (DOCUMENTED, the
163
+ * around the name `delims==` row); what `set` makes of them is not, so
164
+ * CAUTIOUS: REFUSED by name
165
+ * `=WEBHOOK_SECRET=x` (a leading `=`) COMMUNITY: for /f skips leading delimiters, so this sets the
166
+ * key where no line scan sees it. REFUSED by name
167
+ * a `!` in any name, a name starting with `/` CAUTIOUS, file-wide: with delayed expansion on (see `!`
168
+ * below) a name can expand into the key's, and `set` might
169
+ * read `/A` or `/P` as its switch
170
+ * a `^` in any name CAUTIOUS, file-wide: on a line delayed expansion touches
171
+ * (one with a `!`, the value's included) the caret is an
172
+ * escape and is removed, so `WEBHOOK_SECRE^T=evil!` sets
173
+ * WEBHOOK_SECRET (COMMUNITY: the phase model)
174
+ * a character outside ASCII in any name CAUTIOUS, file-wide: how `set` folds case beyond ASCII
175
+ * (blanks, quotes and a BOM around it set aside) is not documented, and Unicode folds `ı` to I and `ſ` to
176
+ * S, so `Pı_BACKENDS` may be PI_BACKENDS
177
+ * `export WEBHOOK_SECRET=x` a variable named `export WEBHOOK_SECRET`, so the key is not
178
+ * set (`noOpMisread` says so)
179
+ *
180
+ * THE VALUE (the line the wrapper takes for the key, and every value written: `cmdValueRefusal`)
181
+ * `& | < > ( )` COMMUNITY: FOR variables are substituted AFTER the line is
182
+ * parsed for operators and quotes, so these are text
183
+ * (`for %%a in ("a&b") do echo %%~a` prints `a&b`). Carried
184
+ * a trailing blank COMMUNITY: the quoted `set` keeps it. Carried, as written
185
+ * `"` COMMUNITY: by the same phase order it cannot end the command,
186
+ * and `set "..."` keeps the text up to the LAST quote, which
187
+ * is the wrapper's own. CAUTIOUS all the same: the wrapper's
188
+ * header forbids quotes, and set's quote rule is not
189
+ * Microsoft's documentation
190
+ * `%` COMMUNITY: percent expansion runs before FOR substitution,
191
+ * so it is text. CAUTIOUS all the same, as it has always been
192
+ * `!` DOCUMENTED dependence: with delayed expansion on, `!NAME!`
193
+ * expands and a lone `!` is removed. `cmd /?` documents the
194
+ * registry's DelayedExpansion value turning it on, and a bare
195
+ * `setlocal` leaves it as it was. REFUSED
196
+ * `^` COMMUNITY: an escape only on a line delayed expansion
197
+ * touches. CAUTIOUS
198
+ * a character outside ASCII CAUTIOUS: for /f decodes the file in the console code page,
199
+ * not UTF-8, and the wrapper sets none
200
+ * a control character but TAB CAUTIOUS
201
+ * an `=` at the start of the value (`K==v`) COMMUNITY: the remainder starts after the run of delimiters,
202
+ * so the `=` is lost. CAUTIOUS
203
+ * empty DOCUMENTED: `set "K="` removes K, so the service has no key
204
+ *
205
+ * Linux and macOS never reach any of this: it runs only for the cmd loader (win32).
206
+ */
207
+ const CMD_LINE_MAX = 8191;
208
+
209
+ /** Why a value cannot be shown to reach the service intact through the cmd wrapper, as words, or `null`. */
210
+ function cmdValueRefusal(v) {
211
+ if (v.startsWith("=")) return "an = at the start, which for /f drops with the = after the name";
212
+ const c = /[\x00-\x08\x0a-\x1f\x7f"%!^]|[^\x00-\x7f]/u.exec(v)?.[0];
213
+ if (c === undefined) return null;
214
+ if (c === "\n" || c === "\r") return "a line break";
215
+ if (c === '"') return "a double quote";
216
+ if (c === "%") return "a % (cmd's expansion character)";
217
+ if (c === "!") return "a ! (cmd's delayed-expansion character, on whenever the registry's DelayedExpansion value is set)";
218
+ if (c === "^") return "a ^ (cmd's escape character)";
219
+ if (c.codePointAt(0) > 0x7f) return "a character outside ASCII, which cmd reads in the console code page rather than as UTF-8";
220
+ return `a control character (U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`;
221
+ }
222
+
223
+ /**
224
+ * The cmd wrapper's reading of this text for `key` (see the table above): `hazard`, the first line this command
225
+ * cannot vouch for (file-wide, or one that names the key other than as a plain `KEY=` line), as `{ line, what, fix }`;
226
+ * and `taken`, the last plain `KEY=...` line, as `{ line, value }` with the value exactly as the line holds it.
227
+ */
228
+ function cmdReading(text, key) {
229
+ const lines = text.split("\n");
230
+ const upper = (s) => s.replace(/[a-z]+/g, (m) => m.toUpperCase());
231
+ const K = upper(key);
232
+ let hazard = null;
233
+ let taken;
234
+ const note = (line, what, fix) => {
235
+ hazard ??= { line, what, fix };
236
+ };
237
+ for (let i = 0; i < lines.length; i++) {
238
+ const last = i === lines.length - 1;
239
+ // The CR of a CRLF ending goes; a CR at the very end of the file stays in the line, where it is a control
240
+ // character in the value if the line is the key's.
241
+ const l = !last && lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
242
+ const n = i + 1;
243
+ const inner = last && l.endsWith("\r") ? l.slice(0, -1) : l;
244
+ if (inner.includes("\r")) note(n, "has a carriage return (CR) that is not part of a CRLF line ending, and whether the .cmd wrapper's for /f reads it as a line break is not documented", "remove the CR, or save the file with CRLF (or LF) line endings");
245
+ if (l.includes("\0")) note(n, "has a NUL byte, and whether the .cmd wrapper's for /f reads past it is not documented", "remove the NUL byte");
246
+ if (l.includes("\x1a")) note(n, "has a Ctrl-Z byte (0x1A), which cmd may read as the end of the file", "remove the byte");
247
+ if (Buffer.byteLength(l, "utf8") + 6 > CMD_LINE_MAX) note(n, `is ${Buffer.byteLength(l, "utf8")} bytes long, and the .cmd wrapper's set "..." for it would pass cmd's ${CMD_LINE_MAX}-character command-line limit`, "shorten that value, or move the large content into a file and put its path in the .env");
248
+ const body = l.replace(/^=+/, "");
249
+ if (body === "" || body[0] === "#") continue;
250
+ const eq = body.indexOf("=");
251
+ const name = eq === -1 ? body : body.slice(0, eq);
252
+ // Where the name is, said truthfully: a line with no `=` is ALL name to for /f (its %%B is empty).
253
+ const inName = eq === -1 ? "in its name (the whole line, since it has no =)" : "before its first =";
254
+ if (name.includes("!")) note(n, `has a ! ${inName}, and with delayed expansion on (the registry's DelayedExpansion value) the .cmd wrapper's set can expand that into another variable's name`, "remove the ! from that line");
255
+ if (name.includes("^")) note(n, `has a ^ ${inName}, which cmd removes as an escape on a line delayed expansion touches (one with a !, the value's included), so the .cmd wrapper's set can read that name as another variable's`, "remove the ^ from that line");
256
+ if (/[^\x00-\x7f]/u.test(name.replace(/^[ \t\r\ufeff"]+|[ \t\r\ufeff"]+$/g, ""))) note(n, `has a character outside ASCII ${inName}, and how the .cmd wrapper's set folds the case of such a name (\`ı\` to I, \`ſ\` to S) is not documented, so which variable that line sets cannot be confirmed`, "spell the name in ASCII, or remove the line");
257
+ if (/^[ \t"]*\//.test(name)) note(n, "starts with /, which the .cmd wrapper's set might read as its /A or /P switch", "remove the / at the start of that line");
258
+ const internal = envFileWrapperInternal(l, { loader: "cmd" });
259
+ if (internal !== null) note(n, WRAPPER_INTERNAL_WHAT(internal.name), WRAPPER_INTERNAL_FIX);
260
+ if (upper(name.replace(/^[ \t\r\ufeff"]+|[ \t\r\ufeff"]+$/g, "")) !== K) continue;
261
+ if (name !== key && upper(name) === K) note(n, `sets ${name}, which the .cmd wrapper's set reads as ${key}, since Windows variable names ignore case`, `write the name as ${key}, or remove the line`);
262
+ else if (/^[A-Za-z0-9_]+[ \t]+$/.test(name)) note(n, `has a blank between ${key} and the =, which the .cmd wrapper's set keeps in the name, so the variable that line sets is not ${key}`, "remove the blank before the =");
263
+ else if (name !== key) note(n, `spells ${key} with a blank, a quote, a CR or a byte-order mark beside the name, which the .cmd wrapper's for /f keeps as part of the name (it splits only on =), so whether that line sets ${key} cannot be confirmed`, `write ${key}=value at the very start of the line`);
264
+ else if (eq === -1) note(n, `is ${key} with no =, which the .cmd wrapper reads as set "${key}=", removing ${key}`, `write ${key}=value, or remove the line`);
265
+ else if (l[0] === "=") note(n, `starts with =, which the .cmd wrapper's for /f skips, so that line sets ${key}`, "remove the = at the start of the line");
266
+ else taken = { line: n, value: body.slice(eq + 1) };
267
+ }
268
+ return { hazard, taken };
269
+ }
270
+
271
+ /** A `cmdReading` hazard as the writer says it: the line, what is there, and the fix. */
272
+ function cmdHazardSentence(h) {
273
+ return `line ${h.line} ${h.what}. To fix it, ${h.fix}`;
274
+ }
275
+
276
+ /**
277
+ * Does this text carry a line for `key` whose value is EMPTY for every loader of this file?
278
+ *
279
+ * ONE HELPER because `up` and `doctor` answered it separately and disagreed (issue #365). The reader this
280
+ * replaced returned values only, so a key whose last assignment was empty came back identical to a key the
281
+ * file never mentions, and the one thing both callers need here -- that a LINE EXISTS and its value is
282
+ * blank -- was gone before either of them saw it. Doctor fell through to "is unset, so the worker ignores
283
+ * it" for a file that makes the worker refuse to start, while `up`, reading the same file, said the value
284
+ * is empty. One run, both sentences. `readEnvAssignments` keeps the distinction, and this helper is
285
+ * derived from it rather than deciding the question a second time.
286
+ *
287
+ * WHY BLANK IS NOT UNSET, measured in sh, bash and zsh: `set -a; . ./.env` on a `KEY=` line SETS and
288
+ * EXPORTS `KEY=""` -- it does not leave the key absent. `deploy/worker-env-wrapper.sh` does exactly that,
289
+ * so a blank line reaches the worker as an empty string, `config.mjs` keeps it (`??`, not `||`) for
290
+ * `PI_PAUSE_WINDOWS_FILE` and `PI_SCOPED_LIMITS_FILE`, and the unconditional loader at `start.mjs` throws.
291
+ *
292
+ * BOTH POSIX LOADERS ARE ASKED, so a key that is blank for one and set for another is not blank: a bare
293
+ * `KEY=` with a later `export KEY=/v.json` is empty under `EnvironmentFile=` and configured under the POSIX
294
+ * wrapper, and calling that "blank" would tell half the operators their configured key is empty. A loader
295
+ * that does not see the key at all does not vote, which is what keeps a systemd-only shape from reading as
296
+ * blank because the other loader ignored it. The cmd wrapper is left out for the reason given at the call
297
+ * below, and this header said "EVERY LOADER" for a round while the code asked two of three.
298
+ *
299
+ * `up` IS THE CALLER, and doctor is not: doctor answers the same question per SUBJECT, with the platform's
300
+ * own loader and the vouch beside it, because its answer becomes a refusal rather than a sentence.
301
+ */
302
+ export function envKeyIsBlank(text, key) {
303
+ // DERIVED from the reader rather than re-deciding it. The regex pre-check this used to carry existed only
304
+ // because the old reader deleted an empty key, so "no value" and "no line" arrived identical; the reader
305
+ // now returns a record per assignment and the distinction is in it (issue #384).
306
+ // THE TWO POSIX LOADERS, and the omission is deliberate. The cmd wrapper splits on the first `=` and
307
+ // keeps what follows verbatim, so `K=""` is two quote characters to it where systemd and a sourcing shell
308
+ // both read nothing. The CONSEQUENCE is the same either way -- a path named `""` does not exist and the
309
+ // boot refuses on it exactly as an empty one does -- but the word "empty" is only true of the POSIX
310
+ // readings, and this predicate answers a question about emptiness rather than about usability.
311
+ const readings = ["systemd", "shell"].map((loader) => readEnvAssignments(text, [key], { loader })[key]).filter((r) => r !== undefined);
312
+ if (readings.length === 0) return false;
313
+ // `blank`, not `value === ""`, and the difference is every shape this file cannot vouch for. A reading
314
+ // that is not plain carries no value at all, so asking about the value here said "not empty" for a key
315
+ // whose line is visibly empty -- on any CRLF file, and on any file with one stray line in it.
316
+ return readings.every((r) => r.blank);
317
+ }
318
+
319
+ /**
320
+ * Which lines of this `.env` text are part of a value above them for `loader` (inside a multi-line quote or a
321
+ * continuation), as a boolean per LF line. A `#` line there is no comment: to a shell it is part of the joined line, so
322
+ * `X=a\` + `#;PI_EGRESS=0` sets PI_EGRESS (measured in bash, gate round 2).
323
+ */
324
+ export function envFileValueLines(text, { loader = "systemd" } = {}) {
325
+ return quoteSpans(String(text ?? "").split("\n"), loader).inside;
326
+ }
327
+
328
+ /**
329
+ * Which of these lines are part of a value above them rather than lines of their own, to ANY loader of the file: inside
330
+ * a multi-line quoted value or a continuation, for systemd or for a sourcing shell (issue #447, gate round 2). The
331
+ * writers never take such a line for the key's, nor a `# KEY=` comment there for a place to write it: replacing a
332
+ * comment inside a quoted value put the key into that value (measured on systemd 259).
333
+ */
334
+ function valueLines(lines) {
335
+ const bare = lines.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
336
+ const systemd = quoteSpans(lines, "systemd").inside;
337
+ const shell = quoteSpans(lines, "shell").inside;
338
+ // TWO QUESTIONS, and answering both with "any loader" overwrote an operator's secret (gate round 3). WHERE TO
339
+ // WRITE: never into a line any loader reads as value (`any`). IS THE KEY ALREADY SET: a line counts as set unless
340
+ // EVERY loader reads it as value (`all`), because `NOTE=see "the docs` carries its quote only in a shell, so the
341
+ // WEBHOOK_SECRET line below it is set for systemd, and skipping it appended a new random secret over it.
342
+ return { any: bare.map((_, i) => systemd[i] || shell[i]), all: bare.map((_, i) => systemd[i] && shell[i]) };
343
+ }
344
+
345
+ /** The line ending a line appended to this text gets: CRLF when the file already uses it, LF otherwise (gate round 3). */
346
+ function appendEol(text) {
347
+ return /\r\n|\r$/.test(text) ? "\r\n" : "\n";
348
+ }
349
+
350
+ export function setEnvKeyIfEmpty(text, key, value, opts) {
37
351
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
- const setRe = new RegExp(`^\\s*${escaped}\\s*=(.*)$`);
39
- const commentRe = new RegExp(`^\\s*#\\s*${escaped}\\s*=`);
352
+ // `export ` is part of the SET shape here, and that is a never-clobber decision rather than dotenv
353
+ // pedantry: the wrapper scripts source this file with `set -a; . ./.env`, where `export KEY=value` is an
354
+ // ordinary assignment the operator made. Without it the key reads as absent, a second assignment is
355
+ // appended, and the shell takes the LAST one -- so filling four "empty" keys would replace four values
356
+ // the operator set, in one pass, with no prompt. `readEnvAssignments` is deliberately stricter about what
357
+ // it will VOUCH for: there, declining to claim a value costs a fuller warning, where missing one HERE
358
+ // costs the value itself.
359
+ // `[ \t]` and `[^\n]`, never `\s` and `.` (issue #447, gate round 1): `\s` also matches a NBSP, a vertical tab or a
360
+ // form feed, which make the line an assignment no loader takes (systemd drops it as invalid), so this took such a
361
+ // line for the key's and said "already set" about a key nothing sets; `.` stops at a CR, U+2028 or U+2029, so a
362
+ // set value carrying one read as no line at all and a second assignment was appended, clobbering the operator's.
363
+ const setRe = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${escaped}[ \\t]*=([^\\n]*)$`);
364
+ const commentRe = new RegExp(`^[ \\t]*#[ \\t]*(?:export[ \\t]+)?${escaped}[ \\t]*=`);
40
365
 
41
366
  const lines = text.split("\n");
42
- // Replace a line wholesale, keeping a CRLF file's trailing \r so the file stays one convention.
367
+ const insideValue = valueLines(lines);
368
+ // Replace a line wholesale, keeping a CRLF file's trailing \r so the file stays one convention, and
369
+ // keeping any inline `# ...` documentation the line carried (see `trailingComment`).
43
370
  const replaceLine = (i) => {
44
- lines[i] = `${key}=${value}${lines[i].endsWith("\r") ? "\r" : ""}`;
371
+ const crlf = lines[i].endsWith("\r") ? "\r" : "";
372
+ const bare = crlf === "" ? lines[i] : lines[i].slice(0, -1);
373
+ lines.splice(i, 1, ...replacementLines(key, value, bare, commentRe.test(bare), opts).map((l) => `${l}${crlf}`));
45
374
  return lines.join("\n");
46
375
  };
47
376
 
@@ -50,23 +379,28 @@ export function setEnvKeyIfEmpty(text, key, value) {
50
379
  // empty set line; a later commented duplicate must not win over it.
51
380
  let firstEmpty = -1;
52
381
  for (let i = 0; i < lines.length; i++) {
382
+ if (insideValue.all[i]) continue;
53
383
  const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
54
384
  const m = line.match(setRe);
55
385
  if (!m) continue;
56
- if (m[1].trim() !== "") return text;
57
- if (firstEmpty === -1) firstEmpty = i;
386
+ // Blanks are space and tab, as for every loader here: JS `trim()` also strips a form feed, a NBSP or U+2028, which
387
+ // systemd keeps as the value, so a key set to one read as empty and was overwritten (issue #447, gate round 1).
388
+ if (m[1].replace(/^[ \t]+|[ \t]+$/g, "") !== "") return text;
389
+ if (firstEmpty === -1 && !insideValue.any[i]) firstEmpty = i;
58
390
  }
59
391
  if (firstEmpty !== -1) return replaceLine(firstEmpty);
60
392
 
61
393
  // Pass 2: a commented-out `# KEY=` line (only reachable when no set line exists at all).
62
394
  for (let i = 0; i < lines.length; i++) {
395
+ if (insideValue.any[i]) continue;
63
396
  const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
64
397
  if (commentRe.test(line)) return replaceLine(i);
65
398
  }
66
399
 
67
- // Pass 3: no trace of the key — append at the end, on its own line.
68
- const base = text === "" || text.endsWith("\n") ? text : `${text}\n`;
69
- return `${base}${key}=${value}\n`;
400
+ // Pass 3: no trace of the key, so append it at the end, on its own line, in the file's own line ending.
401
+ const eol = appendEol(text);
402
+ const base = text === "" || text.endsWith("\n") ? text : text.endsWith("\r") ? `${text}\n` : `${text}${eol}`;
403
+ return `${base}${key}=${renderEnvValue(value, opts)}${eol}`;
70
404
  }
71
405
 
72
406
  /**
@@ -88,35 +422,451 @@ export function setEnvKeyIfEmpty(text, key, value) {
88
422
  * line, the untouched remainder is never re-serialized. A matched line with stray whitespace
89
423
  * (`KEY = old`) is normalised to canonical `KEY=value` — it is being rewritten anyway.
90
424
  */
91
- export function setEnvKey(text, key, value) {
425
+ export function setEnvKey(text, key, value, opts) {
92
426
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
93
- const setRe = new RegExp(`^\\s*${escaped}\\s*=`);
94
- const commentRe = new RegExp(`^\\s*#\\s*${escaped}\\s*=`);
427
+ // `export ` counts as set here too, for the reason `setEnvKeyIfEmpty` gives.
428
+ const setRe = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${escaped}[ \\t]*=`);
429
+ const commentRe = new RegExp(`^[ \\t]*#[ \\t]*(?:export[ \\t]+)?${escaped}[ \\t]*=`);
95
430
 
96
431
  const lines = text.split("\n");
432
+ const insideValue = valueLines(lines);
433
+ let appendOnly = false;
97
434
  const replaceLine = (i) => {
98
- lines[i] = `${key}=${value}${lines[i].endsWith("\r") ? "\r" : ""}`;
435
+ const crlf = lines[i].endsWith("\r") ? "\r" : "";
436
+ const bare = crlf === "" ? lines[i] : lines[i].slice(0, -1);
437
+ lines.splice(i, 1, ...replacementLines(key, value, bare, commentRe.test(bare), opts).map((l) => `${l}${crlf}`));
99
438
  return lines.join("\n");
100
439
  };
101
440
 
102
441
  // Pass 1: the FIRST set line, whatever its value — this is the overwrite discipline. Already
103
442
  // exactly `KEY=value` (modulo the CRLF tail) → the input object back, so the wrapper skips the write.
104
443
  for (let i = 0; i < lines.length; i++) {
444
+ if (insideValue.all[i]) continue;
105
445
  const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
106
446
  if (!setRe.test(line)) continue;
107
- if (line === `${key}=${value}`) return text;
447
+ // A set line some loader reads as value is not a place to write: the new line goes at the end instead, where the
448
+ // read-back in `updateEnvFile` decides whether every reading of it is the value written.
449
+ if (insideValue.any[i]) {
450
+ appendOnly = true;
451
+ break;
452
+ }
453
+ if (line === `${key}=${renderEnvValue(value, opts)}`) return text;
108
454
  return replaceLine(i);
109
455
  }
110
456
 
111
457
  // Pass 2: a commented-out `# KEY=` line (only reachable when no set line exists at all).
112
- for (let i = 0; i < lines.length; i++) {
458
+ if (!appendOnly) {
459
+ for (let i = 0; i < lines.length; i++) {
460
+ if (insideValue.any[i]) continue;
461
+ const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
462
+ if (commentRe.test(line)) return replaceLine(i);
463
+ }
464
+ }
465
+
466
+ // Pass 3: no trace of the key, so append it at the end, on its own line, in the file's own line ending.
467
+ const eol = appendEol(text);
468
+ const base = text === "" || text.endsWith("\n") ? text : text.endsWith("\r") ? `${text}\n` : `${text}${eol}`;
469
+ return `${base}${key}=${renderEnvValue(value, opts)}${eol}`;
470
+ }
471
+
472
+ /**
473
+ * Why this `.env` content cannot be EDITED without changing bytes nobody meant to change, or `null` (issue #447, gate
474
+ * round 1): a file that is not valid UTF-8 decodes with U+FFFD in place of the bad bytes, and writing it back would
475
+ * replace them. Text (a test's seam) has nothing to lose. Exported so a caller about to edit (the setup wizard) can
476
+ * refuse before it runs anything else, under exactly the writer's rule.
477
+ */
478
+ export function envFileEditRefusal(content, path) {
479
+ if (typeof content === "string") return null;
480
+ const bad = firstInvalidUtf8(content);
481
+ if (bad === -1) return null;
482
+ let line = 1;
483
+ for (let k = 0; k < bad; k++) if (content[k] === 10) line++;
484
+ return `refusing to edit ${path}: line ${line} (byte ${bad}) is not valid UTF-8, and rewriting the file would replace those bytes. Re-save it as UTF-8, or remove them`;
485
+ }
486
+
487
+ /**
488
+ * Why `next` does not give `key` the value `value` for the platform's loader, or `null`. See `updateEnvFile`.
489
+ *
490
+ * What it asks is whether the written line is SWALLOWED: inside a multi-line quoted value or a continuation for that
491
+ * loader, or (for systemd) in a file systemd splits into lines differently from this module or will not load. The
492
+ * value of the line the loader takes is compared with a clean `KEY=value` line's. For the cmd wrapper a CRLF line's CR
493
+ * is set aside, as its `for /f` strips it; for a sourcing shell it is KEPT, as the shell keeps it, so a CR-ended line
494
+ * never reads as written (on macOS `planEnvEdit` refuses such a file first, and this is the backstop, regression
495
+ * review).
496
+ */
497
+ function editedValueRefusal(next, key, value, platform) {
498
+ const loader = loaderFor(platform);
499
+ const where = loaderName(loader);
500
+ if (loader === "systemd") {
501
+ // systemd's OWN reading, from its parser states (`systemdReading`), not the line scan: `KEY =x` below the new line
502
+ // is an assignment to it, and an earlier line-scan read-back missed exactly that (gate round 3).
503
+ const h = envFileLoadHazard(Buffer.from(next, "utf8")) ?? envFileSystemdHazard(next);
504
+ if (h !== null) return systemdHazardSentence(h);
505
+ const want = systemdReading(`${key}=${renderEnvValue(value, { platform })}\n`, key)?.value;
506
+ const got = systemdReading(next, key);
507
+ if (got === undefined || got.value !== want) return `after the edit, ${where} would ${got === undefined ? "find no" : "read something other than what was written for"} ${key}${got === undefined ? "" : ` (the assignment it takes is on line ${got.line})`}`;
508
+ return null;
509
+ }
510
+ // A SHELL is asked by command (third regression review): `export \` above `PI_BACKENDS=podman` is one command,
511
+ // `export PI_BACKENDS=podman`, which sets the key, while a line reading called the second line "part of a
512
+ // continuation" and refused an edit the shells read as written. `lastAssignment` walks the commands the lines
513
+ // start and reads the last one that assigns the key, as the shell takes it.
514
+ if (loader === "shell") {
515
+ const taken = lastAssignment(next, key, loader);
516
+ if (taken === undefined) {
517
+ // Named by its line (third regression review nit): the written line is there, and a command above it
518
+ // swallows it (`PI_BACKENDS=\` at the end of the file, then the appended `WEBHOOK_SECRET=...`).
519
+ const lines = next.split("\n");
520
+ const written = lines.findLastIndex((l) => l.replace(/\r$/, "") === `${key}=${renderEnvValue(value, { platform })}`);
521
+ return written === -1
522
+ ? `after the edit, ${where} would find no command that assigns ${key}`
523
+ : `after the edit, ${where} would read line ${written + 1}, where ${key} is written, as part of the command above it, so no command would assign ${key}`;
524
+ }
525
+ const want = readEnvAssignments(`${key}=${renderEnvValue(value, { platform })}\n`, [key], { loader })[key];
526
+ // Unreadable is not DIFFERENT: `PI_BACKENDS=''podman` is `podman` to the shells, and saying the edit would read
527
+ // "something other" was false (third regression review). Said as what it is.
528
+ if (want.plain && !taken.plain) return `after the edit, ${where} would take ${key} from line ${taken.line}, and this command cannot confirm what that line reads. Write it as a plain ${key}=value line, or remove it`;
529
+ if (want.plain && taken.value !== want.value) return `after the edit, ${where} would read ${key} as something other than what was written (the assignment it takes is on line ${taken.line})`;
530
+ return null;
531
+ }
532
+ // The cmd wrapper, by its own reading (issue #470, `cmdReading`): nothing on the edited file it cannot vouch for, and
533
+ // the line it takes for the key holds exactly the value written. An empty one removes the key there, so it is never
534
+ // "written". The line taken is the LAST plain one, with a CRLF line's CR set aside as for /f drops it (the final
535
+ // review of #447: with two empty CRLF lines for the key the first was filled while the loader took the second). This
536
+ // replaced a comparison through `readEnvAssignments`, which read `K==v` as `=v` where for /f reads `v`.
537
+ const { hazard, taken } = cmdReading(next, key);
538
+ if (hazard !== null) return `after the edit, ${cmdHazardSentence(hazard)}`;
539
+ if (taken === undefined) return `after the edit, ${where} would find no ${key} line`;
540
+ const bad = cmdValueRefusal(taken.value);
541
+ if (bad !== null) return `after the edit, ${where} would take ${key} from line ${taken.line}, whose value has ${bad}, so what the service reads cannot be confirmed`;
542
+ if (taken.value === "") return `after the edit, ${where} would take ${key} from line ${taken.line}, which is empty, and an empty value removes ${key} there`;
543
+ if (taken.value !== renderEnvValue(value, { platform })) return `after the edit, ${where} would read ${key} as something other than what was written (the assignment it takes is on line ${taken.line})`;
544
+ return null;
545
+ }
546
+
547
+ /** A systemd hazard as the writer says it: the line and what is there. */
548
+ function systemdHazardSentence(h) {
549
+ return `line ${h.line} has ${SYSTEMD_HAZARD_SHAPES[h.shape].what}${h.detail ? ` (${h.detail})` : ""}`;
550
+ }
551
+
552
+ function loaderFor(platform) {
553
+ return platform === "win32" ? "cmd" : platform === "darwin" ? "shell" : "systemd";
554
+ }
555
+
556
+ function loaderName(loader) {
557
+ return loader === "systemd" ? "systemd's EnvironmentFile=" : loader === "shell" ? "the wrapper that sources the file" : "the .cmd wrapper";
558
+ }
559
+
560
+ /**
561
+ * On Linux, a sentence when the line scan finds `key` set but systemd's own reading does not (an `export` line, which
562
+ * systemd drops as an invalid name), naming the line and what to write instead; otherwise `null`.
563
+ *
564
+ * Also when systemd's reading is EMPTY and a LATER `export` line gives the key a value (focus review):
565
+ * `WEBHOOK_SECRET=` above `export WEBHOOK_SECRET=old` is "already set" to the line scan and to a shell, while the
566
+ * service gets an empty WEBHOOK_SECRET. Only a later export line: one above the empty assignment is overridden by it for
567
+ * every loader, which `noOpMisread` says. (Requiring `export` there is an equivalent mutation today, measured: a later
568
+ * plain line with a value is one systemd reads itself, unless a hazard, refused earlier, hides it. It keeps the sentence
569
+ * true: the line named is one only a shell reads.)
570
+ */
571
+ function setOnlyForAShell(text, key, platform) {
572
+ if (loaderFor(platform) !== "systemd") return null;
573
+ const reading = systemdReading(text, key);
574
+ if (reading !== undefined && reading.value !== "") return null;
575
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
576
+ const setRe = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${escaped}[ \\t]*=([^\\n]*)$`);
577
+ const exportRe = new RegExp(`^[ \\t]*export[ \\t]+${escaped}[ \\t]*=`);
578
+ const lines = text.split("\n");
579
+ for (let i = reading === undefined ? 0 : reading.line; i < lines.length; i++) {
113
580
  const line = lines[i].endsWith("\r") ? lines[i].slice(0, -1) : lines[i];
114
- if (commentRe.test(line)) return replaceLine(i);
581
+ const m = line.match(setRe);
582
+ if (m && m[1].replace(/^[ \t]+|[ \t]+$/g, "") !== "" && (reading === undefined || exportRe.test(line))) {
583
+ return `${key} is set only for a shell (line ${i + 1}: ${line.replace(/=.*$/, "=...")}); systemd's EnvironmentFile= ignores that line, so the service has ${reading === undefined ? `no ${key}` : `the empty ${key} of line ${reading.line}`}. Write it as ${key}=... on a line of its own${reading === undefined ? "" : `, in place of line ${reading.line}`}. Nothing was written`;
584
+ }
585
+ }
586
+ return null;
587
+ }
588
+
589
+ /**
590
+ * Why "unchanged" would be FALSE for this text, as a sentence, or `null` (focus review). The line scan's no-op means
591
+ * "some line already sets the key" (fill) or "the first set line is already `KEY=value`" (overwrite); the platform's
592
+ * loader takes the LAST assignment, so `WEBHOOK_SECRET=old` above `WEBHOOK_SECRET=` gives the service an empty key, and
593
+ * `PI_BACKENDS=podman` above `PI_BACKENDS=docker` gives it docker, while the writer said nothing needed doing. Asked
594
+ * of the loader's own reading (systemd's parser on Linux, the last assignment outside a value for a shell or the cmd
595
+ * wrapper). Values are never echoed: the lines are named.
596
+ *
597
+ * NOT for a key every POSIX loader reads as BLANK (`envKeyIsBlank`): that "unchanged" is `up`'s EMPTY sentence, which is
598
+ * true (`KEY=""`, or `KEY=old` above `KEY=`), and the never-clobber rule keeps such a line the operator's (#365).
599
+ */
600
+ function noOpMisread(text, key, value, overwrite, platform) {
601
+ if (!overwrite && envKeyIsBlank(text, key)) return null;
602
+ const loader = loaderFor(platform);
603
+ const where = loaderName(loader);
604
+ const rendered = `${key}=${renderEnvValue(value, { platform })}\n`;
605
+ const seen = firstSetLine(text, key, !overwrite);
606
+ const at = seen === null ? "" : ` on line ${seen}`;
607
+ let taken;
608
+ let differs;
609
+ let unconfirmed = false;
610
+ if (loader === "systemd") {
611
+ const r = systemdReading(text, key);
612
+ taken = r?.line;
613
+ differs = r === undefined || (overwrite ? r.value !== systemdReading(rendered, key)?.value : r.value === "");
614
+ } else {
615
+ const r = lastAssignment(text, key, loader);
616
+ taken = r?.line;
617
+ const clean = readEnvAssignments(rendered, [key], { loader })[key];
618
+ // Empty for the cmd wrapper UNSETS the key (`set "K="`), so it is no value either.
619
+ differs = r === undefined || (overwrite ? clean.plain && (!r.plain || r.value !== clean.value) : r.blank || (loader === "cmd" && r.value === ""));
620
+ // A reading this module cannot vouch for is not a DIFFERENT value, only an unknown one: said so (regression review).
621
+ unconfirmed = overwrite && r !== undefined && !r.plain;
622
+ // A fill whose value is nothing but expansions (`WEBHOOK_SECRET=$X`) is empty when those are unset at load time,
623
+ // which the wrapper's environment decides and this command cannot see: "unchanged" would vouch for a value the
624
+ // service may not get (second regression review). Refused as unconfirmable, never as set. One with literal text
625
+ // in it (`PI_LOGS_DIR=$HOME/logs`) is never empty, so it stays set.
626
+ if (!overwrite && r !== undefined && r.expansionOnly) {
627
+ differs = true;
628
+ unconfirmed = true;
629
+ }
630
+ }
631
+ if (!differs) return null;
632
+ if (taken === undefined) return `${key} looks set${at} to this command, but ${where} reads no assignment of it from this file, so the service does not get it. Write it as ${key}=... on a line of its own`;
633
+ if (unconfirmed && !overwrite) return `${key} on line ${taken} is only an expansion ($NAME), which ${where} fills in when it loads the file and leaves empty if that variable is unset then, so this command cannot confirm what line ${taken} reads. Write the value itself there`;
634
+ if (unconfirmed) return `${key} already reads as asked${at}, but ${where} takes the assignment on line ${taken}, and this command cannot confirm what line ${taken} reads. Write it as a plain ${key}=value line, or remove one of the two lines`;
635
+ // The cmd wrapper's `set "K="` UNSETS the key, so there the service gets no key at all, not an empty one.
636
+ const gets = `so the service gets ${loader === "cmd" ? "no" : "an empty"} ${key}`;
637
+ // ONE line, read two ways: `KEY= # a note` is set to this command's scan and to systemd (which reads the note as
638
+ // the value) and empty to a shell, whose comment it is (regression review, docs/sandbox.md's own example).
639
+ if (!overwrite && seen === taken) return `${key} on line ${taken} looks set to this command, but ${where} reads it as empty, ${gets}. Put the value after the =, or remove the text after it`;
640
+ return overwrite
641
+ ? `${key} already reads as asked${at}, but ${where} takes the assignment on line ${taken}, which reads something else. Remove one of the two lines`
642
+ : `${key} looks set${at}, but ${where} takes the assignment on line ${taken}, which is empty, ${gets}. Remove one of the two lines`;
643
+ }
644
+
645
+ /** The first line (1-based) that sets `key` for the line scan, with a non-empty value when `nonEmpty`, or `null`. */
646
+ function firstSetLine(text, key, nonEmpty) {
647
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
648
+ const setRe = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${escaped}[ \\t]*=([^\\n]*)$`);
649
+ const lines = text.split("\n");
650
+ const all = valueLines(lines).all;
651
+ for (let i = 0; i < lines.length; i++) {
652
+ if (all[i]) continue;
653
+ const m = lines[i].replace(/\r$/, "").match(setRe);
654
+ if (m && (!nonEmpty || m[1].replace(/^[ \t]+|[ \t]+$/g, "") !== "")) return i + 1;
115
655
  }
656
+ return null;
657
+ }
116
658
 
117
- // Pass 3: no trace of the key — append at the end, on its own line.
118
- const base = text === "" || text.endsWith("\n") ? text : `${text}\n`;
119
- return `${base}${key}=${value}\n`;
659
+ /**
660
+ * The last assignment of `key` a shell or the cmd wrapper takes: its line outside any value, read as that loader reads
661
+ * it. For the cmd wrapper, the line alone with its CR set aside (`for /f` strips it). For a shell, the WORD its joined
662
+ * command starts with (`shellCommand`, `shellWord`): on a line with no shell hazard only blanks or a `#` comment may
663
+ * follow that word, so `KEY=podman # c` reads `podman`, and `KEY=podman \` above an empty line reads `podman` too, as
664
+ * sh, bash and dash do (regression reviews). A CR the word runs into stays in it, as the shell keeps it. `expansionOnly`
665
+ * says the word is nothing but `$NAME`/`${NAME}` expansions and quotes, so it is EMPTY whenever those are unset at load
666
+ * time, which this command cannot see.
667
+ */
668
+ function lastAssignment(text, key, loader) {
669
+ const lines = text.split("\n");
670
+ const inside = quoteSpans(lines, loader).inside;
671
+ const cr = lines.map((l) => l.endsWith("\r"));
672
+ const bare = lines.map((l, k) => (cr[k] ? l.slice(0, -1) : l));
673
+ for (let i = lines.length - 1; i >= 0; i--) {
674
+ if (inside[i]) continue;
675
+ if (loader !== "shell") {
676
+ const m = ASSIGNMENT.exec(bare[i]);
677
+ if (m === null || m[2] !== key || m[1]) continue;
678
+ const read = readEnvAssignments(`${bare[i]}\n`, [key], { loader })[key];
679
+ return read === undefined ? undefined : { ...read, line: i + 1 };
680
+ }
681
+ // By COMMAND for a shell: the line starts one, and its joined text is what assigns (`export \` + `KEY=v`).
682
+ const { logical, end } = shellCommand(bare, cr, i);
683
+ const m = COMMAND_ASSIGNMENT.exec(logical);
684
+ if (m === null || m[2] !== key) continue;
685
+ const rest = m[3];
686
+ let word = shellWord(rest);
687
+ if (word === rest && cr[end]) word += "\r";
688
+ // A word holding a quoted newline is a multi-line value, which this reads no further than a line: not vouched.
689
+ if (word.includes("\n")) return { value: null, plain: false, vouched: false, blank: false, line: i + 1, expansionOnly: false };
690
+ const read = readEnvAssignments(`${key}=${word}\n`, [key], { loader })[key];
691
+ return { ...read, line: i + 1, expansionOnly: !read.blank && expansionOnly(word) };
692
+ }
693
+ return undefined;
694
+ }
695
+
696
+ /** Whether a shell word is only `$NAME`/`${NAME}` expansions and quotes, so nothing in it is literal text. */
697
+ function expansionOnly(word) {
698
+ let q = "";
699
+ for (let i = 0; i < word.length; i++) {
700
+ const c = word[i];
701
+ if (q === "'") {
702
+ if (c === "'") q = "";
703
+ else return false;
704
+ } else if (c === "\\") return false;
705
+ else if (q === '"' && c === '"') q = "";
706
+ else if (q === "" && (c === "'" || c === '"')) q = c;
707
+ else if (c === "$") {
708
+ SIMPLE_EXPANSION.lastIndex = i;
709
+ const m = SIMPLE_EXPANSION.exec(word);
710
+ if (m === null) return false;
711
+ i += m[0].length - 1;
712
+ } else return false;
713
+ }
714
+ return true;
715
+ }
716
+
717
+ /** The text of a shell value up to its first unquoted, unescaped blank (quotes and escapes kept for `readValue`). */
718
+ function shellWord(rest) {
719
+ let q = "";
720
+ for (let i = 0; i < rest.length; i++) {
721
+ const c = rest[i];
722
+ if (q === "'") {
723
+ if (c === "'") q = "";
724
+ } else if (c === "\\") i += 1;
725
+ else if (q === '"') {
726
+ if (c === '"') q = "";
727
+ } else if (c === "'" || c === '"') q = c;
728
+ else if (c === " " || c === "\t") return rest.slice(0, i);
729
+ }
730
+ return rest;
731
+ }
732
+
733
+ /**
734
+ * Whether the platform's loader ALREADY reads `key` as set to something non-empty in this text, as a sentence, or
735
+ * `null`. systemd's answer is its own reading (`systemdReading`); the shells' and the cmd wrapper's is the last
736
+ * assignment line that is not inside a value for them.
737
+ */
738
+ function keyAlreadySet(text, key, platform) {
739
+ const loader = loaderFor(platform);
740
+ const where = loaderName(loader);
741
+ if (loader === "systemd") {
742
+ const got = systemdReading(text, key);
743
+ return got !== undefined && got.value !== "" ? `${where} already reads ${key} as set on line ${got.line}` : null;
744
+ }
745
+ const got = readEnvAssignments(text, [key], { loader })[key];
746
+ // Blank, or empty under the cmd wrapper, where `set "K="` UNSETS the key: nothing to protect.
747
+ if (got === undefined || got.blank || got.value === "") return null;
748
+ if (quoteSpans(text.split("\n"), loader).inside[got.line - 1]) return null;
749
+ return `${where} already reads ${key} as set on line ${got.line}`;
750
+ }
751
+
752
+ /**
753
+ * The edit `updateEnvFile` would make to this content, WITHOUT writing it: `{ changed: false }`, `{ changed: true, next }`,
754
+ * or `{ error }` with the refusal it would throw. One function for the writer and for a caller that must refuse before
755
+ * it runs anything else (the setup wizard, whose pre-check runs this on the file as it stands, gate round 3), so the
756
+ * two can never apply different rules.
757
+ *
758
+ * BYTES FIRST (gate round 1): decoding turns a byte that is not UTF-8 (a Latin-1 `\u00e9`, 0xE9) into U+FFFD, and
759
+ * writing the text back replaced the operator's byte in a line this writer never meant to touch.
760
+ * NEVER CLOBBER (gate round 3): in fill-if-empty mode a key the platform's loader already reads as non-empty is not
761
+ * touched, whatever the line scan concluded; the scan once skipped a real WEBHOOK_SECRET line that only a shell
762
+ * read as part of a quoted value, and appended a new random secret that systemd then took.
763
+ * READ BACK (gate round 2): the new text must give the key exactly the value a clean `KEY=value` line gives, for the
764
+ * platform's own loader (systemd's own parser reading on Linux), with nothing in the file that loader reads
765
+ * differently from this module. Appending after a file ending inside a continuation or an open quote, or replacing
766
+ * a `# KEY=` comment inside a multi-line value, each left the key unset while the writer reported it written.
767
+ * `verify` lets a caller add its own condition on the new text.
768
+ */
769
+ function planEnvEdit(raw, path, key, value, { overwrite = false, platform = process.platform, verify } = {}) {
770
+ const refusal = envFileEditRefusal(raw, path);
771
+ if (refusal !== null) return { error: refusal };
772
+ const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8");
773
+ // THE FILE'S HAZARDS FIRST, before any line scan decides the key is already set or needs no edit (round-cap
774
+ // review): `K=1 exit 0` above `WEBHOOK_SECRET=old` ends the source before the key, and the scan below returned
775
+ // "unchanged" for it, so `up` said "already set" about a key the service never gets. A file the platform's loader
776
+ // reads differently from this module is refused by name rather than called unchanged or already set: for a shell,
777
+ // whatever the edit would have been; for systemd, as described below.
778
+ //
779
+ // On a platform whose service loader is a SHELL, a line that shell reads differently from this module can assign the
780
+ // key where no line scan sees it (`X=1 WEBHOOK_SECRET=abc`, a continuation into ` WEBHOOK_SECRET=abc`), so the
781
+ // append would replace the operator's value: any shell hazard refuses the edit (gate round 4, measured in sh,
782
+ // bash, dash and zsh).
783
+ // On Windows, the same rule by the cmd wrapper's own reading (issue #470, `cmdReading`): a line it cannot be shown
784
+ // to read line by line, or one that names the key other than as a plain `KEY=` line (`webhook_secret=`, an indented
785
+ // or blank-suffixed name, a bare `KEY`), refuses the edit by name, whatever the edit would have been.
786
+ if (loaderFor(platform) === "cmd") {
787
+ const h = cmdReading(text, key).hazard;
788
+ if (h !== null) return { error: `refusing to edit ${path}: ${cmdHazardSentence(h)}. Nothing was written` };
789
+ }
790
+ if (loaderFor(platform) === "shell") {
791
+ const h = envFileHazard(text, { loader: "shell" });
792
+ // A NUL or a CRLF file is named, with its fix: the generic sentence below sends an operator looking for a command
793
+ // on a line that has none (round-cap review).
794
+ if (h?.shape !== undefined) return { error: `refusing to edit ${path}: line ${h.line} has ${SYSTEMD_HAZARD_SHAPES[h.shape].what}. To fix it, ${SYSTEMD_HAZARD_SHAPES[h.shape].fix}. Nothing was written` };
795
+ if (h !== null) return { error: `refusing to edit ${path}: line ${h.line} is one the wrapper that sources this file reads differently from this command (a second assignment on a line, a continuation, a command), so whether ${key} is already set there is unknown. Fix that line first. Nothing was written` };
796
+ const internal = envFileWrapperInternal(text, { loader: "shell" });
797
+ if (internal !== null) return { error: `refusing to edit ${path}: ${wrapperInternalSentence(internal)}. Nothing was written` };
798
+ }
799
+ const next = (overwrite ? setEnvKey : setEnvKeyIfEmpty)(text, key, value, { platform });
800
+ const already = next === text || overwrite ? null : keyAlreadySet(text, key, platform);
801
+ // The cmd wrapper's value, where the answer would be "unchanged" or "already set" (issue #470): the line it takes
802
+ // for the key must hold a value it can be shown to carry, or neither claim is one this command can make.
803
+ if (loaderFor(platform) === "cmd" && (next === text || already !== null)) {
804
+ const t = cmdReading(text, key).taken;
805
+ const bad = t === undefined ? null : cmdValueRefusal(t.value);
806
+ if (bad !== null) return { error: `refusing to edit ${path}: line ${t.line} is the ${key} line the .cmd wrapper takes, and its value has ${bad}, so what the service reads cannot be confirmed. To fix it, write that value without it, or give the service ${key} through its own environment (pi-dispatch service install --env-setup). Nothing was written` };
807
+ }
808
+ // systemd's two, the ones `editedValueRefusal` asks of the edited text: a file it will not load (the key is then set
809
+ // for nobody) or splits into lines differently from this module. Asked of the file AS IT STANDS only where the
810
+ // answer would otherwise be "unchanged" or "already set", which are claims about that file; an edit keeps being
811
+ // judged on its result below, where replacing the one offending line can leave a file systemd reads cleanly. The cmd
812
+ // wrapper's are asked above and below this, by `cmdReading`.
813
+ if (loaderFor(platform) === "systemd" && (next === text || already !== null)) {
814
+ const h = envFileLoadHazard(raw) ?? envFileSystemdHazard(text);
815
+ // With its fix, as the macOS branch says it (focus review): the line alone left an operator to work out the change.
816
+ if (h !== null) return { error: `refusing to edit ${path}: ${systemdHazardSentence(h)}. To fix it, ${SYSTEMD_HAZARD_SHAPES[h.shape].fix}. Nothing was written` };
817
+ }
818
+ if (next === text) {
819
+ // "Already set" by a line systemd IGNORES is not set for the service (gate round 4): `export WEBHOOK_SECRET=abc`
820
+ // is an assignment to a shell and not to systemd's EnvironmentFile=, so up said "left untouched" about a service
821
+ // with no secret. Said, with the line and the fix, rather than reported as set.
822
+ const shellOnly = overwrite ? null : setOnlyForAShell(text, key, platform);
823
+ if (shellOnly !== null) return { error: `refusing to edit ${path}: ${shellOnly}` };
824
+ // And "unchanged" only when the loader READS it so: the scan stops at the first set line, the loader takes the last.
825
+ const misread = noOpMisread(text, key, value, overwrite, platform);
826
+ return misread === null ? { changed: false } : { error: `refusing to edit ${path}: ${misread}. Nothing was written` };
827
+ }
828
+ // A BACKSTOP since the round-cap review: every file that reached this in a fuzz of 300000 per platform also had a
829
+ // hazard, which is now refused by name above, so removing this line is an equivalent mutation today (measured). It
830
+ // stays because the cost of missing a set key is the operator's secret, and a later loosening of a hazard rule must
831
+ // not reopen that.
832
+ if (already !== null) return { error: `refusing to edit ${path}: ${already}, and a key that has a value is never overwritten. Nothing was written` };
833
+ const afterEdit = editedValueRefusal(next, key, value, platform) ?? verify?.(next) ?? null;
834
+ if (afterEdit !== null) return { error: `refusing to edit ${path}: ${afterEdit}. Nothing was written` };
835
+ // THE FILE ABOUT TO BE WRITTEN is judged as the file read was (third regression review): replacing only the first
836
+ // physical line of a command that continues leaves its tail as a line of its own, so `PI_BACKENDS=""\"\` above
837
+ // `a` became `PI_BACKENDS=podman` above `a`, which all three shells RUN, while the key read back as written. Any
838
+ // hazard the platform's loader finds in the new text that the old text did not have refuses the edit, for a fill,
839
+ // an overwrite and an append alike.
840
+ const introduced = newHazard(text, next, loaderFor(platform));
841
+ if (introduced !== null) return { error: `refusing to edit ${path}: after the edit, line ${introduced} would be one ${loaderName(loaderFor(platform))} reads differently from this command (a command, a continuation or an open quote the file did not have before; for example, the line replaced continued onto the next, so its tail would stand as a line of its own). Put the key's line on one line first. Nothing was written` };
842
+ return { changed: true, next };
843
+ }
844
+
845
+ /**
846
+ * The line of a hazard `loader` finds in `next` that it does not find in `text`, or `null`. "Not in `text`" by its
847
+ * shape and by the text of the line, so a hazard the file already had, moved by the edit's line count, is not new.
848
+ */
849
+ function newHazard(text, next, loader) {
850
+ // The cmd wrapper's one hazard, a `"` in a value (`quoteSpans`), belongs to its own line, so it is compared as a SET of
851
+ // lines: overwriting the first of two such lines leaves the second first, and that is not new (the corpus's
852
+ // docs/wait-for.md snippet). The line the writer renders never holds a `"` (`renderEnvValue` refuses it there).
853
+ if (loader === "cmd") {
854
+ const quoted = (l) => (ASSIGNMENT.exec(l.replace(/\r$/, ""))?.[3] ?? "").includes('"');
855
+ const had = new Set(text.split("\n").filter(quoted).map((l) => l.replace(/\r$/, "")));
856
+ const lines = next.split("\n");
857
+ for (let i = 0; i < lines.length; i++) if (quoted(lines[i]) && !had.has(lines[i].replace(/\r$/, ""))) return i + 1;
858
+ return null;
859
+ }
860
+ const after = envFileHazard(next, { loader });
861
+ if (after === null) return null;
862
+ const before = envFileHazard(text, { loader });
863
+ if (before !== null && before.shape === after.shape && text.split("\n")[before.line - 1] === next.split("\n")[after.line - 1]) return null;
864
+ return after.line;
865
+ }
866
+
867
+ /** `planEnvEdit`'s refusal for this content, or `null` when the edit would be made (or is not needed). */
868
+ export function envFileEditCheck(content, path, key, value, opts = {}) {
869
+ return planEnvEdit(content, path, key, value, opts).error ?? null;
120
870
  }
121
871
 
122
872
  /**
@@ -133,21 +883,1107 @@ export function setEnvKey(text, key, value) {
133
883
  * Mode: a .env at 0o600 (an operator who locked their secrets down) stays 0o600 — chmod on the tmp
134
884
  * BEFORE the rename, so no window exists where the secret-bearing file is wider than it was. Any
135
885
  * other mode is left to the platform default; this helper preserves a hardening choice, it does not
136
- * impose one.
886
+ * impose one, except where the caller says `narrow` (issue #468: the Valkey password goes only into a
887
+ * `.env` no other account can read, so its writers narrow the file to the owner's bits).
888
+ *
889
+ * The tmp is CREATED at 0o600 (issue #468): it holds every secret of the file, and created at the
890
+ * umask it was world-readable for the moment between the write and the chmod.
137
891
  */
138
892
  export function updateEnvFile(path, key, value, deps = {}) {
139
- const { fs = { readFileSync, writeFileSync, renameSync, statSync, chmodSync }, overwrite = false } = deps;
140
- const text = fs.readFileSync(path, "utf8");
141
- const next = (overwrite ? setEnvKey : setEnvKeyIfEmpty)(text, key, value);
142
- if (next === text) return { changed: false };
143
- const tmp = `${path}.tmp`;
144
- fs.writeFileSync(tmp, next);
893
+ // `platform` is derived HERE rather than passed by each caller, which is what the first version got
894
+ // wrong: `up` passed it and `github-app-setup.mjs` did not, so on Windows a PEM path was single-quoted
895
+ // and the cmd wrapper kept the quotes, making the path the worker loads wrong behind a ✓ on the key
896
+ // that decides forge auth. A rendering rule that every writer of this file must remember is a rule one
897
+ // of them will forget.
898
+ const { fs = { readFileSync, writeFileSync, renameSync, statSync, chmodSync, realpathSync }, overwrite = false, platform = process.platform, narrow = false } = deps;
899
+ // Every refusal is `planEnvEdit`'s (bytes first, never clobber, read back), made before anything is written.
900
+ const plan = planEnvEdit(fs.readFileSync(path), path, key, value, { overwrite, platform, verify: deps.verify });
901
+ if (plan.error) throw new Error(plan.error);
902
+ if (!plan.changed) return { changed: false };
903
+ const { next } = plan;
904
+ // Through the SYMLINK, not over it. A deployment whose `.env` points at a shared env file is an
905
+ // ordinary layout, and rename-over-the-link replaces it with a regular file: every later edit to the
906
+ // shared file, a rotated WEBHOOK_SECRET included, silently stops reaching this deployment. Resolving
907
+ // first edits what the operator meant. Optional on the seam because the test fakes do not model links.
908
+ let target = path;
145
909
  try {
146
- if ((fs.statSync(path).mode & 0o777) === 0o600) fs.chmodSync(tmp, 0o600);
910
+ target = fs.realpathSync?.(path) ?? path;
147
911
  } catch {
148
- // The file vanished between read and write, or the fs cannot stat: leave the tmp's default
149
- // mode rather than failing an edit that is otherwise sound.
912
+ // Not resolvable (a dangling link, a fs without the call): edit the path we were given.
913
+ }
914
+ // OWNERSHIP, before anything is written. `renameSync` makes a new inode owned by whoever runs this, so
915
+ // a root- or `pi`-owned `.env` at 0640 that the service reads through its group comes back owned by the
916
+ // operator: the service account loses read access, and `deploy/worker.service` uses a bare
917
+ // `EnvironmentFile=` (fatal, not `-`), so the unit stops starting. Widening the mode to compensate
918
+ // would publish a file holding WEBHOOK_SECRET. Refusing is the only honest third option, and the caller
919
+ // turns it into a line rather than a stack trace.
920
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
921
+ const gid = typeof process.getgid === "function" ? process.getgid() : null;
922
+ if (uid !== null) {
923
+ try {
924
+ const { uid: owner, gid: group } = fs.statSync(target);
925
+ // GID as well as UID, because the layout this protects is a `.env` at 0640 read by the service
926
+ // THROUGH ITS GROUP. With `bob:pi 0640` and the operator in group `pi` the uid matches, the
927
+ // rename still makes a new inode with the writer's primary gid, and the `pi` service loses read
928
+ // access exactly as it would have on a uid mismatch.
929
+ if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it would hand it to the wrong account`);
930
+ if (typeof group === "number" && gid !== null && group !== gid) throw new Error(`refusing to edit ${target}: its group is gid ${group} and this process is gid ${gid}, and rewriting it would hand it to the wrong group, which is how a 0640 .env stops being readable by the service`);
931
+ } catch (err) {
932
+ if (err instanceof Error && err.message.startsWith("refusing to edit")) throw err;
933
+ // Cannot stat: fall through to the write, which will fail on its own terms if it must.
934
+ }
935
+ }
936
+ const tmp = `${target}.tmp`;
937
+ fs.writeFileSync(tmp, next, { mode: 0o600 });
938
+ try {
939
+ // The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider
940
+ // keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back
941
+ // 0644, world-readable, on the one file this project says must never reach a scrollback.
942
+ // `narrow` keeps only the owner's bits of it.
943
+ const mode = fs.statSync(target).mode & 0o7777;
944
+ fs.chmodSync(tmp, narrow ? mode & 0o7700 : mode);
945
+ } catch {
946
+ // The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was
947
+ // created with rather than failing an edit that is otherwise sound.
948
+ }
949
+ fs.renameSync(tmp, target);
950
+ return { changed: true, ...(narrow ? { narrowed: true } : {}) };
951
+ }
952
+
953
+ /**
954
+ * What NAMED keys a `.env` TEXT assigns, as seen by ONE loader, with a record per key rather than a value.
955
+ *
956
+ * Deliberately NOT a dotenv loader, and the distinction is the whole reason this is allowed to exist
957
+ * (issue #357). Nothing in this project loads `.env` into a process environment: `docs/secrets.md` opens
958
+ * with "the worker parses no `.env` file", and `worker/test/service.test.mjs` pins that a `PI_ENV_SETUP`
959
+ * line inside `./.env` is deliberately NOT honoured. This reader exists so `doctor` can decide WHAT TO SAY
960
+ * about a file, never what to configure.
961
+ *
962
+ * THREE LOADERS READ THIS FILE AND THEY DISAGREE (issue #384). `deploy/worker.service` and
963
+ * `deploy/receiver.service` hand it to systemd's `EnvironmentFile=`; `deploy/worker-env-wrapper.sh` sources
964
+ * it with `set -a`; `deploy/worker-env-wrapper.cmd` splits each line on its first `=`. So a reading is only
965
+ * meaningful beside the loader that produced it, which is what `loader` selects.
966
+ *
967
+ * AND THEY DISAGREE ABOUT MORE THAN QUOTING. Measured, systemd 252 against /bin/sh, bash and zsh, the same
968
+ * line in each:
969
+ *
970
+ * K=a b systemd "a b" the shells leave K UNSET (a second word is a command)
971
+ * K=$HOME/x systemd "$HOME/x" the shells expand it
972
+ * K=~/x systemd "~/x" the shells expand it
973
+ * K=a"b"c systemd 'a"b"c' the shells concatenate to "abc"
974
+ * K=a #b systemd "a #b" the shells stop at the comment
975
+ * K==ls systemd "=ls" zsh expands to /bin/ls; sh and bash do not
976
+ * K= leading systemd "leading" the shells leave K UNSET
977
+ *
978
+ * ZSH IS CHECKED BUT NOT PROMISED, and the distinction is worth stating because the oracle asserts it
979
+ * (issue #396). The grammar is the intersection of what the SERVICE LOADERS read the same way -- systemd's
980
+ * `EnvironmentFile=`, the `/bin/sh` the wrappers source with, and the cmd wrapper on Windows. zsh is none
981
+ * of those: `deploy/worker-env-wrapper.sh` has a `#!/bin/sh` shebang and the launchd wrapper runs `/bin/sh`,
982
+ * which is bash 3.2 in posix mode on macOS. It is driven anyway because a disagreement there is usually a
983
+ * hole in the grammar rather than a fact about zsh.
984
+ *
985
+ * One FAMILY of shapes is the exception, and it is why this paragraph exists rather than a wider refusal.
986
+ * `K=a:=b` and `K==x` are two of them: zsh treats the right-hand side as a command to find, and when it
987
+ * cannot, its `.` builtin ABORTS THE SOURCING at that line and returns 126. The SHELL carries on -- it does
988
+ * not exit, measured -- but every key below that line is never set, while sh, bash and dash read the file
989
+ * through and this reader keeps vouching. Refusing the shapes outright was considered and rejected: it would
990
+ * warn an operator about a line every loader this project actually deploys reads correctly, which is a false
991
+ * alarm bought with nothing. The oracle names the two it carries; the family is wider (`K2=x:=y:=z`,
992
+ * `K2=:=b`, `K2=a:~b`, `K2=~x` and more behave the same way), and naming two is a sample, not a boundary.
993
+ *
994
+ * So this reader does NOT claim a value for every line. It reports `plain: true` only for the shapes where
995
+ * every one of those loaders agrees, and `plain: false` otherwise, with `value: null`. The previous version
996
+ * claimed a value for all of them and its own docblock listed four shapes where it was wrong; the list was
997
+ * longer than four. Narrowing what is claimed is the fix, rather than writing the shell parser that would be
998
+ * needed to claim more, which is a far larger promise than deciding what a warning SAYS.
999
+ *
1000
+ * `undefined` for a key means no assignment at all. `{ value: "" }` means an assignment to nothing, which is
1001
+ * NOT absence: `config.mjs` reads several keys with `??`, so an empty string survives, and `start.mjs` then
1002
+ * refuses to boot on it. That distinction is why this returns records: the old shape deleted an empty key
1003
+ * and every caller that asked "is it set" got the wrong answer (issue #365, and issue #384's item 4).
1004
+ */
1005
+ export function readEnvAssignments(text, keys, { loader = "systemd" } = {}) {
1006
+ const want = new Set(keys);
1007
+ const found = {};
1008
+ const raw = String(text ?? "");
1009
+ const lines = raw.split("\n");
1010
+ // TWO QUESTIONS, and answering them with one flag is what the first version of this got wrong (found in
1011
+ // review). "What does this LINE assign?" and "can I vouch that the loader ends up with that?" are not the
1012
+ // same question, and doctor asks the first one:
1013
+ //
1014
+ // PI_PAUSE_WINDOWS_FILE="" the key is EMPTY and the worker refuses to start. That is true whatever
1015
+ // unset FOO else the file says, and one flag let the stray line below hide it.
1016
+ //
1017
+ // So `plain` is about the line's own text (plus anything ABOVE it that swallows the line whole), and
1018
+ // `vouched` adds the rest of the file. A caller deciding what the key IS uses `plain`; a caller about to
1019
+ // print a value uses `vouched`.
1020
+ const bare = lines.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
1021
+ // ONE QUESTION ABOUT THE FILE: is every line one this command can read at all? It replaced a taxonomy of
1022
+ // hazard causes and an expansion detector that three adversarial passes each found wrong in a different
1023
+ // place, and a fourth pass then found the first version of THIS wrong in four more. What survived all
1024
+ // four is small, and every refusal is syntactic rather than a guess about what a shell does:
1025
+ //
1026
+ // inside a multi-line quote the shells read this line as more of the value above it, so it is not a
1027
+ // line; when that quote CLOSES, the lines after it are lines again
1028
+ // an unterminated quote from the line that opened it, nothing below can be read
1029
+ // a trailing backslash the same, by continuation
1030
+ // not an assignment the shells RUN it: `unset K`, a heredoc body, a block, a sourced file
1031
+ // a backtick, any `$` but it runs, evaluates arithmetic (which can assign), or ENDS the sourcing shell;
1032
+ // `$NAME` and `${NAME}` outside single quotes nothing else is allowed (`runsOrAborts`)
1033
+ // a NUL, a CRLF line to a sourcing shell only: bash 3.2 stops at the NUL, and every value keeps the CR
1034
+ //
1035
+ // A value's ordinary CONTENT is not judged here. `PI_LOGS_DIR=$HOME/logs` changes only its own line's
1036
+ // reading, which is what `plain` is for, and treating it as a file-wide hazard hid an EMPTY boot key two
1037
+ // lines below it. Nor is anything after a `#` that begins a comment.
1038
+ const spans = quoteSpans(lines, loader);
1039
+ const hazardLine = spans.hazard ?? (loader === "systemd" ? (envFileSystemdHazard(raw)?.line ?? null) : null);
1040
+ for (let i = 0; i < lines.length; i++) {
1041
+ const hadCR = lines[i].endsWith("\r");
1042
+ const line = hadCR ? lines[i].slice(0, -1) : lines[i];
1043
+ // A BOM is not whitespace to any loader here. systemd 252 DROPS an assignment whose line carries one
1044
+ // (measured, and the journal says so), and the shells read the name as starting with the BOM, so the
1045
+ // key is left unset and the line runs as a command. Stripping it and reading on -- which this did, on
1046
+ // every line, while marking only the first line unplain -- vouched for a key no loader sets.
1047
+ //
1048
+ // Belt and braces, and said so rather than left looking load-bearing: `ASSIGNMENT` already refuses
1049
+ // this line, because a BOM is neither `[ \t]` nor the start of a name. Removing this check is an
1050
+ // EQUIVALENT mutation today (measured), and it is kept so that widening that regex later cannot
1051
+ // quietly re-open the hole.
1052
+ if (line.startsWith("\ufeff")) continue;
1053
+ const m = ASSIGNMENT.exec(line);
1054
+ if (!m) continue;
1055
+ const [, exported, key, rest] = m;
1056
+ // One loader per reading, never a blend. systemd's `EnvironmentFile=` grammar is bare `NAME=VALUE`:
1057
+ // an `export` line is not an assignment there and does not cancel one either (measured: the journal
1058
+ // says `Ignoring invalid environment assignment`). The wrapper sources the file, so `export` is
1059
+ // ordinary. The cmd wrapper splits on the first `=`, which makes `export K` a variable NAME.
1060
+ if (exported && loader !== "shell") continue;
1061
+ if (!want.has(key)) continue;
1062
+ const read = readValue(rest, loader);
1063
+ // CR is the cmd wrapper's own line ending, and `for /f` strips it. Holding a CRLF file against the
1064
+ // Windows loader disabled every verdict on the platform where CRLF is NATIVE, including for the exact
1065
+ // line `setEnvKeyIfEmpty` writes into such a file, which preserves CRLF by contract.
1066
+ // A trailing CR belongs to the loaders that KEEP it. systemd 252 strips it (measured) and so does the
1067
+ // cmd wrapper's `for /f`, so a CRLF file is ordinary to both; only a sourcing shell reads the CR as
1068
+ // part of the value. Holding it against systemd made a CRLF deployment on linux unjudgeable -- no
1069
+ // pass, no failure -- on files `up` itself writes that way.
1070
+ const crBreaks = hadCR && loader === "shell";
1071
+ // A line INSIDE a multi-line quote is part of the value above it in every shell, whatever it looks
1072
+ // like, so it is not this key's line at all.
1073
+ const plain = read.plain && !crBreaks && !spans.inside[i];
1074
+ // The LAST assignment, because every loader takes it: `set -a; . ./.env`, `EnvironmentFile=` and the
1075
+ // cmd wrapper's `set` all overwrite as they go.
1076
+ // `blank` is the LOOSE answer, on purpose: it asks only what this line assigns, so `up` can tell an
1077
+ // operator their `WEBHOOK_SECRET` line is empty even in a file with a stray line somewhere in it.
1078
+ // A caller that turns blankness into a REFUSAL asks for `vouched` beside it -- doctor does -- because
1079
+ // a hazard can mean the shells never reach this line at all (a heredoc body, an `if false` block) or
1080
+ // that one of them clears the key afterwards (`unset K`), and a hard "REFUSES TO START" is the one
1081
+ // verdict that must never be reached by inference. A swallowed line is not a line in either sense.
1082
+ found[key] = { value: plain ? read.value : null, plain, vouched: plain && hazardLine === null, blank: loader !== "cmd" && !spans.inside[i] && assignsNothing(rest), line: i + 1, hazardLine };
1083
+ }
1084
+ return found;
1085
+ }
1086
+
1087
+ // The name is followed DIRECTLY by `=`, with no space. NOT because both loaders refuse the line -- systemd
1088
+ // 252 accepts `K =/a.json` and sets the key, measured on the rig after an earlier comment here claimed it
1089
+ // rejected the line -- but because the SHELLS run a command named `K` with `=/a.json` as its argument, so
1090
+ // the two ends of this file disagree about whether the key is assigned at all. Such a line is a hazard
1091
+ // rather than a lax assignment, which is where `setEnvKeyIfEmpty` deliberately differs: it is looking for a
1092
+ // line to REWRITE, and it normalises the spacing when it does.
1093
+ //
1094
+ // The value runs to the end of the line, `[^\n]` rather than `.`, because JavaScript's `.` also excludes
1095
+ // CR, U+2028 and U+2029. With `.` a value carrying any of the three matched NOTHING, so the key had no
1096
+ // record at all and doctor reported it "unset" -- about a key systemd sets to the text before the CR and
1097
+ // the shells set whole.
1098
+ const ASSIGNMENT = /^[ \t]*(export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=([^\n]*)$/;
1099
+
1100
+ /** `ASSIGNMENT` over a shell's whole COMMAND, whose value may hold the newlines of a quote (`quoteSpans`' join). */
1101
+ const COMMAND_ASSIGNMENT = /^[ \t]*(export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=([^]*)$/;
1102
+
1103
+ /**
1104
+ * Which lines are inside a multi-line quote, and which line (if any) this command cannot read.
1105
+ *
1106
+ * ONE PASS, because the two questions share a scanner and answering them separately is how the version
1107
+ * before this managed to disagree with itself: one scanner was fed the VALUE and the other the whole LINE,
1108
+ * and their "a `#` starts a comment" rules differed, so a file could be readable and swallowed at once.
1109
+ *
1110
+ * A quote CLOSES. That is the half the first simplification threw away: in
1111
+ *
1112
+ * OTHER='x
1113
+ * y'
1114
+ * PI_PAUSE_WINDOWS_FILE=/gone.json
1115
+ *
1116
+ * every loader reads line 3 identically (measured on systemd 252 and in sh, bash, dash and zsh), so calling
1117
+ * the whole file unreadable turned a worker that refuses to start into a clean exit 0. Lines 1 and 2 are the
1118
+ * span; line 3 is a line.
1119
+ */
1120
+ function quoteSpans(input, loader) {
1121
+ // Lines may still carry their CR (a CRLF file): a backslash before CRLF is NOT a continuation, to systemd (its
1122
+ // escape eats the CR and the LF ends the line; measured, the q-cont-crlf row) or to a shell (the backslash quotes
1123
+ // the CR only). Reading it as one hid a real `WEBHOOK_SECRET=` line below it from the writer on macOS, which then
1124
+ // appended a new secret over the operator's (gate round 4). Callers that stripped the CR get the old reading.
1125
+ const cr = input.map((l) => l.endsWith("\r"));
1126
+ const bare = input.map((l, i) => (cr[i] ? l.slice(0, -1) : l));
1127
+ const inside = bare.map(() => false);
1128
+ if (loader === "cmd") {
1129
+ // `for /f` reads one line at a time: no continuation, no multi-line value, no execution. A `"` in a value is
1130
+ // kept as this loader's one hazard, CAUTIOUSLY: the parser's phase order says FOR variables are substituted after
1131
+ // the line's quotes and operators are parsed, so it cannot make the rest of the line a command, but that is not
1132
+ // Microsoft's documentation and nothing here can run cmd (issue #470; the writer's own rules are `cmdReading`'s).
1133
+ const at = bare.findIndex((l) => (ASSIGNMENT.exec(l)?.[3] ?? "").includes('"'));
1134
+ return { inside, hazard: at === -1 ? null : at + 1 };
1135
+ }
1136
+ // PER LOADER, because the two POSIX loaders do different things with the same file, and one answer for
1137
+ // both was wrong for whichever one it was not written for. Measured on systemd 252:
1138
+ //
1139
+ // a quote opened MID-value does not continue across lines: `OTHER='a'b'` reads as `ab'` and the NEXT line
1140
+ // is read as an ordinary assignment, where every shell swallows it. CORRECTED (issue #430 review round 2):
1141
+ // the earlier wording said no quote continues, and that is wrong for a quote that OPENS the value, which
1142
+ // systemd's own parser continues across newlines until it closes (test-env-file.c, env_file_6). Those
1143
+ // values are `quotedRegions`' job, and since issue #447 their lines are INSIDE for this loader too, so
1144
+ // doctor's systemd reading no longer takes a line inside one for an assignment. The shapes `quotedRegions`
1145
+ // does not model (a reopened quote, a non-identifier key, a lone CR, a continuation this scan misses) are
1146
+ // `envFileSystemdHazard`'s, which makes the file unreadable rather than modelling them.
1147
+ // a trailing backslash DOES continue, in both.
1148
+ // a line it cannot parse is IGNORED -- `unset K`, `cat <<EOF`, `if false; then`, `OTHER=${NOPE?boom}`
1149
+ // and `OTHER=(` are all inert to systemd, and all of them RUN in a sourcing shell.
1150
+ //
1151
+ // So the hazards below belong to the shells, and systemd's only cross-line mechanism is the backslash.
1152
+ // Judging a linux deployment by the shells' rules is what made an `unset FOO` in a `.env` hide an empty
1153
+ // boot key from systemd, which reads that key perfectly well.
1154
+ const runsLines = loader === "shell";
1155
+ // systemd's own multi-line values: every line after the one that opens the value, up to and including the
1156
+ // one that closes it, is part of that value (measured on systemd 259, issue #447).
1157
+ if (!runsLines) {
1158
+ for (const r of quotedRegions(bare.join("\n"))) {
1159
+ for (let k = r.open; k < (r.close ?? bare.length); k++) inside[k] = true;
1160
+ }
1161
+ }
1162
+ let carry = { q: "", cont: false };
1163
+ let openedAt = null;
1164
+ let hazard = null;
1165
+ let shape;
1166
+ let crComment = null;
1167
+ for (let i = 0; i < bare.length; i++) {
1168
+ const line = bare[i];
1169
+ const within = (runsLines && carry.q !== "") || carry.cont || inside[i];
1170
+ inside[i] = within;
1171
+ // TWO BYTES the shell reads differently from this module, on any line, inside a value or not (round-cap review,
1172
+ // measured on macOS). A NUL: bash 3.2 as /bin/sh stops reading the file at the first one, so nothing after it is
1173
+ // set, while dash drops the byte and reads on. A CR ending a line (a CRLF line ending): the wrapper keeps it, so
1174
+ // `WEBHOOK_SECRET=abc<CR>` is `abc<CR>` to the service, a line of only a CR runs it as a command, and a key
1175
+ // written into such a file would not read as written. Measured: no shipped `.env.example` has one; the fix is to
1176
+ // convert the file to LF line endings.
1177
+ // ANY such line, a comment line included (regression review). A comment line's CR is harmless to the shells on
1178
+ // its own, and exempting it (focus review) let a CRLF file through to the writer, which then wrote the new line
1179
+ // with the file's CRLF ending (`appendEol`, and a replaced `# KEY=` line keeps its CR): `KEY=abc<CR>`, read by
1180
+ // /bin/sh as `abc<CR>`. One rule for the whole file is the one the writer can keep.
1181
+ // A comment line's CR is named as what it is (second regression review): the shells read past it, and the refusal
1182
+ // is this command's, because a key written into the file takes its CRLF ending. But only when EVERY CR line is
1183
+ // such a comment (third regression review): `# c<CR>` above `WEBHOOK_SECRET=abc<CR>` has a value that reaches the
1184
+ // service with its CR, and naming the comment said the shells read the file fine. So a comment line's CR is kept
1185
+ // aside and used only if no other hazard turns up; any other CR line is named as itself.
1186
+ const commentCr = runsLines && cr[i] && !input[i].includes("\0") && !within && /^[ \t]*#/.test(bare[i]);
1187
+ if (commentCr) crComment ??= i + 1;
1188
+ else if (hazard === null && runsLines && (input[i].includes("\0") || cr[i])) {
1189
+ hazard = i + 1;
1190
+ shape = input[i].includes("\0") ? "shell-nul" : "shell-crlf";
1191
+ }
1192
+ if (!within && hazard === null && runsLines) {
1193
+ // The LOGICAL line (gate round 2): a shell removes a backslash-newline before it reads anything, so
1194
+ // `X=a\` + ` PI_EGRESS=0` is `X=a PI_EGRESS=0` to it, and judging only the first physical line passed
1195
+ // every shape below on a continuation (measured in bash: PI_EGRESS=0 set).
1196
+ //
1197
+ // And a quoted newline does not end it (final review): `K="y` + `" WEBHOOK_SECRET=two` is ONE command, two
1198
+ // assignments, and so is `K="y` + `K="a\` + `X=a WEBHOOK_SECRET=two`, where the continuation follows the line
1199
+ // the quote closes on (measured in /bin/sh on macOS: WEBHOOK_SECRET=two, which the writer then appended a new
1200
+ // secret over). Joining only from a line outside a quote judged the first line alone, and every later line of
1201
+ // the command was inside, so nothing judged the tail. So the command is joined through its quoted newlines and
1202
+ // its continuations alike, and judged whole. Not a refusal of every multi-line quote: the documented
1203
+ // GITHUB_APP_PRIVATE_KEY="-----BEGIN ...-----" is one assignment, which this judges as sh reads it.
1204
+ // Incremental, so a long quoted value is scanned once rather than once per line. The blank carried across a
1205
+ // continuation keeps the join's EXTENT exact (`X=a \` + `#'` is a comment, not an open quote); the verdict does
1206
+ // not depend on it, since the checks below rescan the whole command and stop at that comment anyway, so dropping
1207
+ // it is an equivalent mutation (measured), kept so the joined command is the one sh reads.
1208
+ const { logical } = shellCommand(bare, cr, i);
1209
+ // Blanks are space and tab: a NBSP or form feed is a WORD to a shell, so a line of one runs (gate round 1 audit).
1210
+ const t = logical.replace(/^[ \t]+|[ \t]+$/g, "");
1211
+ const m = COMMAND_ASSIGNMENT.exec(logical);
1212
+ if (t !== "" && !t.startsWith("#") && (m === null || logical.startsWith("\ufeff"))) hazard = i + 1;
1213
+ else if (m !== null && runsOrAborts(m[3])) hazard = i + 1;
1214
+ }
1215
+ // A COMMENT never continues: `# note\` leaves the next line a line, in systemd 254 and later (measured on 259) and
1216
+ // in sh, bash, dash and zsh (the E2 oracle), where reading the backslash as a continuation refused a legitimate
1217
+ // `.env` (issue #447). Only a line that STARTS as a comment: a `#` line reached by a continuation, or inside a
1218
+ // quote, is value, and its own backslash joins the next. `;` starts a comment for systemd only; to a shell it is
1219
+ // a syntax error, which the hazard above already names.
1220
+ const comment = !within && (runsLines ? /^[ \t]*#/ : /^[ \t]*[#;]/).test(line);
1221
+ const next = comment ? { q: "", continuation: false } : scanQuotes(line, runsLines ? carry.q : "", runsLines && carry.cont ? carry.blank : false);
1222
+ if (carry.q === "" && next.q !== "") openedAt = i + 1;
1223
+ carry = { q: next.q, cont: next.continuation && !cr[i], blank: next.afterBlank ?? false };
1224
+ }
1225
+ // A quote the shells never see closed makes the WHOLE source fail, so from the line that opened it
1226
+ // nothing can be read. A backslash on the last line is the same for both loaders.
1227
+ if (hazard === null && runsLines && carry.q !== "") hazard = openedAt;
1228
+ if (hazard === null && carry.cont) hazard = bare.length;
1229
+ if (hazard === null && crComment !== null) {
1230
+ hazard = crComment;
1231
+ shape = "shell-crlf-comment";
1232
+ }
1233
+ return { inside, hazard, shape };
1234
+ }
1235
+
1236
+ /** The two `$` forms `runsOrAborts` allows, matched at a `$` (sticky): `$NAME` and exactly `${NAME}`. */
1237
+ const SIMPLE_EXPANSION = /\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/y;
1238
+
1239
+ /**
1240
+ * The shell COMMAND that starts on line `i` (0-based), as the shell reads it: joined through its quoted newlines and its
1241
+ * backslash-newline continuations, the latter removed (outside quotes, and inside double quotes after an odd run of
1242
+ * backslashes), up to the line that ends it. `bare` are the lines without their CR, `cr` whether each had one: a
1243
+ * backslash before a CR continues nothing. One function for `quoteSpans`' hazard judgement and the writer's reading of
1244
+ * the line a shell takes (`lastAssignment`), so the two cannot join a command differently. `end` is the command's last
1245
+ * line.
1246
+ */
1247
+ function shellCommand(bare, cr, i) {
1248
+ let end = i;
1249
+ let logical = bare[i];
1250
+ let s = scanQuotes(bare[i], "");
1251
+ for (let k = i + 1; k < bare.length && (s.q !== "" || (s.continuation && !cr[k - 1])); k++) {
1252
+ end = k;
1253
+ if (s.q === '"' && !cr[k - 1] && /(?:^|[^\\])(?:\\\\)*\\$/.test(bare[k - 1])) {
1254
+ // A backslash-newline INSIDE double quotes is removed as well, before any expansion (delta review): an
1255
+ // odd run of backslashes before the LF escapes it, so `K="$\` + `(z)"` is `K="$(z)"` to sh and runs z.
1256
+ // Keeping the newline split the `$(` across two lines, and the check below saw neither half. The
1257
+ // line's quote state is still `"` after the removed pair, which is what scanning on from `s.q` gives.
1258
+ // Since `runsOrAborts` allows only `$NAME` and `${NAME}`, a `$` before the backslash is refused either
1259
+ // way, and inside double quotes the removal moves no quote, comment or operator, so dropping this
1260
+ // branch (or its parity or CR test) is an equivalent mutation (measured). It is kept so the command
1261
+ // judged is the one sh reads, and no later widening of that allowlist can reopen the split.
1262
+ logical = logical.slice(0, -1) + bare[k];
1263
+ s = scanQuotes(bare[k], s.q);
1264
+ } else if (s.q !== "") {
1265
+ logical = `${logical}\n${bare[k]}`;
1266
+ s = scanQuotes(bare[k], s.q);
1267
+ } else {
1268
+ logical = logical.slice(0, -1) + bare[k];
1269
+ s = scanQuotes(bare[k], "", s.afterBlank);
1270
+ }
1271
+ }
1272
+ return { logical, end };
1273
+ }
1274
+
1275
+ /**
1276
+ * Does this value RUN something, END the shell that is sourcing the file, or fail to parse?
1277
+ *
1278
+ * Narrow on purpose, and comment-aware, which the first version was not: it scanned the whole value, so an
1279
+ * inline comment holding a backtick (`NOTE=x # use `openssl rand``) made a file unreadable that all five
1280
+ * loaders read perfectly, and the empty boot key two lines below it went unreported.
1281
+ *
1282
+ * a backtick command substitution: arbitrary code, arbitrary exit
1283
+ * any `$` but two forms outside single quotes the ONLY `$` forms allowed are `$NAME` and exactly
1284
+ * `${NAME}` (NAME is `[A-Za-z_][A-Za-z0-9_]*`). Everything else is a hazard:
1285
+ * `$(` and `$((`; `$[`, arithmetic the macOS /bin/sh (bash 3.2) evaluates, so
1286
+ * `K=$[WEBHOOK_SECRET=5]` assigns; `${` with anything but a bare name (`${N?x}`
1287
+ * EXITS the sourcing shell, `${K:WEBHOOK_SECRET=2}` and `${a[WEBHOOK_SECRET=5]}`
1288
+ * evaluate arithmetic, `${}` and `${a b}` are a bad substitution that aborts the
1289
+ * source under dash); a positional or special parameter; and a `$` before
1290
+ * anything else or at the end
1291
+ * `( ) ; & | < >` unquoted the line stops being an assignment: `OTHER=(` is a syntax error that aborts
1292
+ * the source, and `OTHER=a; exit 0` ends the wrapper before it launches
1293
+ * anything. Both leave EVERY key in the file unset
1294
+ * a word after a blank unquoted: the value ended at the blank, so the word is a command (`K=1 z`,
1295
+ * `K= unset WEBHOOK_SECRET`, `K=1 exit 0`) or a second assignment (`X=a PI_EGRESS=0`
1296
+ * sets PI_EGRESS, measured in bash and sh on Fedora 44, issue #447 gate round 1)
1297
+ *
1298
+ * `shellSplitsLine`, which judged the second-assignment and `$'` shapes on their own, was removed in the round-cap
1299
+ * review: a second `NAME=` after an unquoted blank is a word after a blank, and an unquoted `$'` (ANSI-C quoting,
1300
+ * whose `\'` does not close it) is a `$` outside the allowlist, so both are this function's. Measured equivalent
1301
+ * on 300000 random shell files before it went.
1302
+ *
1303
+ * AN ALLOWLIST, not a list of the forms that run (delta review): the list this replaced was one form short at every
1304
+ * review (`$[`, arithmetic inside `${}`, a bad substitution). `$NAME` and `${NAME}` only substitute a variable, so
1305
+ * they change their own line's value and nothing else, which is what `plain` is for; every other form is refused
1306
+ * without deciding what it does. `${HOME:-/tmp}` is refused with them, because its default word can hold any of the
1307
+ * above and telling a safe one apart is the list this replaced. An escaped `\$` or `` \` `` is literal, as in sh,
1308
+ * and is skipped by the backslash rule below. The caller has already removed every backslash-newline, inside double
1309
+ * quotes as well, so an escape cannot split a `$(` across two lines.
1310
+ */
1311
+ function runsOrAborts(value) {
1312
+ let q = "";
1313
+ let afterBlank = false;
1314
+ for (let i = 0; i < value.length; i++) {
1315
+ const c = value[i];
1316
+ if (q === "'") {
1317
+ if (c === "'") q = "";
1318
+ afterBlank = false;
1319
+ continue;
1320
+ }
1321
+ if (q === "" && c === "#" && afterBlank) return false;
1322
+ // A WORD AFTER A BLANK, unquoted: the value ended at the blank, so what follows is a command word, a second
1323
+ // assignment, or the start of one (round-cap review, measured in /bin/sh, bash --posix and dash): `K=1 z` and
1324
+ // `K="a" z` run z, `K= unset WEBHOOK_SECRET` and `K= eval "WEBHOOK_SECRET=7"` change the key below, `K=1 exit 0`
1325
+ // ends the source, and `K=a PI_EGRESS=0` is two assignments. Outside quotes, after a blank, only more blanks, a
1326
+ // `#` (a comment, above) or the end of the command may follow.
1327
+ //
1328
+ // BEFORE the backslash, which is a word too (second regression review): `K=podman \z` runs `z` and `K= \#` runs
1329
+ // `#` in all three shells (dash aborts the source on it), and testing the escape first skipped the character it
1330
+ // escapes without asking this. A backslash-newline after a blank never reaches here: the caller joins it away
1331
+ // first, so `K=a \` + `# c` is `K=a # c` and `K=podman \` + an empty line is `K=podman `, as the shells read them.
1332
+ if (q === "" && afterBlank && c !== " " && c !== "\t") return true;
1333
+ if (c === "\\") {
1334
+ i += 1;
1335
+ afterBlank = false;
1336
+ continue;
1337
+ }
1338
+ if (q === "" && (c === "'" || c === '"')) q = c;
1339
+ else if (q === '"' && c === '"') q = "";
1340
+ else if (c === "`") return true;
1341
+ // UNQUOTED only, as the table above says and as sh does: inside double quotes these are ordinary characters, so
1342
+ // `K="a;b"` and `K="(x)"` set K to the text (measured in /bin/sh on macOS). Checking them inside double quotes
1343
+ // refused such lines, and since the final review joined a command across its quoted newlines, every multi-line
1344
+ // double-quoted value holding a parenthesis. A backtick and every `$` form but two still expand inside double
1345
+ // quotes, so those stay refused there; a backslash-escaped character (`\"`, `\$`) is skipped above in both
1346
+ // states, as sh does.
1347
+ else if (q === "" && (c === "(" || c === ")" || c === ";" || c === "&" || c === "|" || c === "<" || c === ">")) return true;
1348
+ else if (c === "$") {
1349
+ SIMPLE_EXPANSION.lastIndex = i;
1350
+ const m = SIMPLE_EXPANSION.exec(value);
1351
+ if (m === null) return true;
1352
+ i += m[0].length - 1;
1353
+ }
1354
+ afterBlank = c === " " || c === "\t";
1355
+ }
1356
+ return false;
1357
+ }
1358
+
1359
+ /**
1360
+ * The quote state at the end of this line, given the state it started in, and whether it ends in the
1361
+ * backslash that makes the next line part of it.
1362
+ *
1363
+ * ONE SCANNER for the whole file, which is the lesson of the round that had two: the other one was fed a
1364
+ * VALUE where this is fed a LINE, and their comment rules drifted apart, so a file could be judged readable
1365
+ * and swallowed at the same time.
1366
+ *
1367
+ * A `#` after UNESCAPED whitespace ends the line for quoting purposes, because every shell does that:
1368
+ * `K=/a.json # it's fine` is an ordinary line and its apostrophe is in a comment. Escaped whitespace does
1369
+ * not count -- in `K=a\ #'` the space is part of the value, so the `#` is too, and the quote after it
1370
+ * swallows the line below. Nor does a `#` at the very start of a value: `NOTE=#don't edit` is one word.
1371
+ *
1372
+ * `blank0`, and `afterBlank` on a continuation, carry that state across a backslash-newline, which a shell removes
1373
+ * before it reads anything: `X=a \` + `#'` is `X=a #'`, a comment, where scanning the second line afresh opened a quote.
1374
+ */
1375
+ function scanQuotes(text, q0, blank0 = false) {
1376
+ let q = q0;
1377
+ let afterBlank = blank0;
1378
+ for (let i = 0; i < text.length; i++) {
1379
+ const c = text[i];
1380
+ if (q === "'") {
1381
+ if (c === "'") q = "";
1382
+ afterBlank = false;
1383
+ continue;
1384
+ }
1385
+ if (q === '"') {
1386
+ if (c === "\\") i += 1;
1387
+ else if (c === '"') q = "";
1388
+ afterBlank = false;
1389
+ continue;
1390
+ }
1391
+ if (c === "\\") {
1392
+ if (i === text.length - 1) return { q, continuation: true, afterBlank };
1393
+ i += 1;
1394
+ afterBlank = false;
1395
+ continue;
1396
+ }
1397
+ if (c === "#" && afterBlank) return { q, continuation: false };
1398
+ if (c === "'" || c === '"') q = c;
1399
+ afterBlank = c === " " || c === "\t";
1400
+ }
1401
+ return { q, continuation: false };
1402
+ }
1403
+
1404
+ /**
1405
+ * Does this line assign NOTHING, whatever else is uncertain about it?
1406
+ *
1407
+ * A THIRD question, separate from both `plain` and `vouched`, and it needs to be: `up` asks it to decide
1408
+ * whether it may fill a key in, and building it on `plain` regressed `up` against the release it shipped
1409
+ * from. On a CRLF `.env`, or one carrying a trailing comment, `WEBHOOK_SECRET=""` stopped reading as empty,
1410
+ * so `up` printed "already set -- left untouched" about an EMPTY value for the receiver's HMAC key and
1411
+ * wrote no secret at all. The loaders can disagree about a value and still agree there is none.
1412
+ *
1413
+ * The rule is deliberately tiny and does not guess: after a trailing CR and trailing blanks come off, what
1414
+ * is left is a run of empty quote pairs, or nothing at all. `K=`, `K= `, `K=''`, `K=""` and `K=""''` are
1415
+ * the set, with their CRLF spellings -- the last because systemd 252 and all four shells read concatenated
1416
+ * empty pairs as empty, measured, even though it sits outside the grammar `plain` claims.
1417
+ *
1418
+ * `K="" # tbd` is NOT in it, and the version of this docblock that said otherwise was describing a comment
1419
+ * arm the rule does not have: no loader of this file treats a trailing `#` as a comment, so systemd hands
1420
+ * the service ` # tbd` while the shells see nothing. That is a disagreement, not an empty value.
1421
+ *
1422
+ * NOT for the cmd wrapper, which is why the caller passes the loader: `set "K="` UNSETS there, so an empty
1423
+ * value is an absent key rather than a blank one, and calling it blank made doctor fail a Windows
1424
+ * deployment that starts.
1425
+ */
1426
+ function assignsNothing(rest) {
1427
+ return /^(?:''|"")*[ \t]*$/.test(rest.replace(/\r+$/, "").replace(/[ \t]+$/, ""));
1428
+ }
1429
+
1430
+ /**
1431
+ * The PLAIN grammar: the shapes systemd, the sourcing shells and the cmd wrapper all read the same way.
1432
+ * Measured rather than derived, with the corpus and the readings recorded on `readEnvAssignments` above.
1433
+ *
1434
+ * empty, `""` and `''` -> ""
1435
+ * `'...'` -> the inner text, any character but `'` (non-ASCII and a TAB included, both
1436
+ * measured on systemd 252 and in the three shells)
1437
+ * `"..."` -> the inner text, without `"`, `$`, `\`, a backtick or a control character
1438
+ * bare -> `[A-Za-z0-9_@+=:,./-]*`, not starting `=` and with no `:=`
1439
+ *
1440
+ * An `=` INSIDE a bare value is literal to every loader (issue #477): systemd's `EnvironmentFile=` splits a line on its
1441
+ * first `=` and keeps the rest, the shells expand nothing at an `=` that follows another character (measured in
1442
+ * /bin/sh, bash, dash and zsh: `K=isolation=enforced`, `K=a=b=c`, `K=a==b`, `K=a=` and `K=a=:b` all set the text as
1443
+ * written), and the cmd wrapper's `tokens=1,*` takes everything after the first `=` run. Refusing it made doctor refuse
1444
+ * the documented `PI_BACKEND_FLOOR=isolation=enforced` and then judge no floor at all. Only the two `=` shapes zsh
1445
+ * expands stay out: one at the start of the value and one after a `:`.
1446
+ *
1447
+ * The exclusions each have a measurement behind them. `$`, a backtick and `~` are expanded by the shells and
1448
+ * not by systemd. A backslash is an escape to systemd and to the shells, and a literal to the cmd wrapper,
1449
+ * so `C:\pi\x` reads as `C:pix` on two of the three. A leading `=` or an embedded `:=` is expanded by zsh
1450
+ * alone. Whitespace outside quotes ends the value for the shells and does not for systemd. A `#` after
1451
+ * whitespace is a comment to the shells and part of the value to systemd, which is the defect issue #392
1452
+ * shipped in the scaffold.
1453
+ */
1454
+ const UNQUOTED_PLAIN = /^(?!=)(?![^\n]*:=)[A-Za-z0-9_@+=:,./-]*$/;
1455
+
1456
+ /**
1457
+ * `plain` has TWO conditions, and the second one is why this exists: every loader must read the value the
1458
+ * same way, AND the value must be one doctor can repeat back to an operator. A quoted ESC, a C1 CSI byte,
1459
+ * a bidi override or a line separator is read identically by systemd and by all four shells -- so the first
1460
+ * condition holds -- and printing it rewrites the operator's terminal, which is the hazard the narrowed
1461
+ * claim exists to remove. Both conditions live in the reader, because a caller that has to remember to
1462
+ * escape is a caller that will forget: doctor prints this value in three places.
1463
+ *
1464
+ * The exception is a TAB, measured rather than tolerated: systemd 252 and all four shells keep it byte for
1465
+ * byte inside single quotes, and `renderEnvValue` quotes a tab-bearing path rather than refusing it, so
1466
+ * excluding tab would make this reader refuse to vouch for a line `up` itself wrote. CR is excluded because
1467
+ * a line ending in one is handled above and a lone CR mid-value has no legitimate source.
1468
+ *
1469
+ * C0 except tab, DEL, C1, the bidi controls and isolates, the zero-width characters, and the line and
1470
+ * paragraph separators. NOT "non-ASCII": a path under an accented or CJK home is ordinary, measured
1471
+ * identical on all five loaders, and the printable-ASCII-only first draft warned about `up`'s own line.
1472
+ */
1473
+ const QUOTED_CONTROL = /[\x00-\x08\x0a-\x1f\x7f-\x9f\u00ad\u061c\u180e\u200b-\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\u3164\ufe00-\ufe0f\ufeff\ufff9-\ufffb]/;
1474
+
1475
+ /**
1476
+ * The first character of `value` the reader never vouches for inside quotes (`QUOTED_CONTROL`), as words naming its code
1477
+ * point ("a control or invisible character (U+200B)"), or null. Shared by the writer's refusal and `unplainCause`, so
1478
+ * both name what is actually there.
1479
+ */
1480
+ export function invisibleCharacter(value) {
1481
+ const c = QUOTED_CONTROL.exec(String(value))?.[0];
1482
+ return c === undefined ? null : `a control or invisible character (U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`;
1483
+ }
1484
+
1485
+ /**
1486
+ * The offset of the first byte of `bytes` that does not begin a well-formed UTF-8 sequence (a stray continuation byte,
1487
+ * a truncated sequence, an overlong form, a surrogate, or a code point past U+10FFFF), or -1 when it is all valid.
1488
+ */
1489
+ export function firstInvalidUtf8(bytes) {
1490
+ for (let i = 0; i < bytes.length; ) {
1491
+ const b = bytes[i];
1492
+ if (b < 0x80) {
1493
+ i += 1;
1494
+ continue;
1495
+ }
1496
+ const [n, lo, hi] = b >= 0xc2 && b <= 0xdf ? [2, 0x80, 0xbf] : b === 0xe0 ? [3, 0xa0, 0xbf] : b === 0xed ? [3, 0x80, 0x9f] : b >= 0xe1 && b <= 0xef ? [3, 0x80, 0xbf] : b === 0xf0 ? [4, 0x90, 0xbf] : b >= 0xf1 && b <= 0xf3 ? [4, 0x80, 0xbf] : b === 0xf4 ? [4, 0x80, 0x8f] : [0, 0, 0];
1497
+ if (n === 0 || i + n > bytes.length) return i;
1498
+ if (bytes[i + 1] < lo || bytes[i + 1] > hi) return i;
1499
+ for (let k = 2; k < n; k++) if (bytes[i + k] < 0x80 || bytes[i + k] > 0xbf) return i;
1500
+ i += n;
1501
+ }
1502
+ return -1;
1503
+ }
1504
+
1505
+ /**
1506
+ * A value rendered so an operator can read it and a terminal cannot be rewritten by it.
1507
+ *
1508
+ * ONE CLASS, one operation, in the module that owns the class. Printable text passes through bare, an
1509
+ * accented or CJK path included, because escaping those would make the commonest non-ASCII home directory
1510
+ * unreadable in the very line telling its owner what to fix. Anything in the control class is quoted and
1511
+ * escaped whole.
1512
+ *
1513
+ * Needed because `plain` guards only what comes out of the FILE. Doctor also prints what this SHELL sets,
1514
+ * and an environment variable is constrained by no grammar at all: `PI_PAUSE_WINDOWS_FILE` holding a raw
1515
+ * ESC reached the terminal through the shell branch while the file branch was carefully withholding it.
1516
+ */
1517
+ export function envValueShown(value) {
1518
+ const v = String(value ?? "");
1519
+ if (!QUOTED_CONTROL.test(v)) return v;
1520
+ return JSON.stringify(v).replace(new RegExp(QUOTED_CONTROL.source, "g"), (c) => "\\u" + c.codePointAt(0).toString(16).padStart(4, "0"));
1521
+ }
1522
+
1523
+ /**
1524
+ * The multi-line quoted values of this file, as `[{ open, close }]` (1-based lines; `close: null` when the quote never
1525
+ * closes and the value runs to the end of the file). systemd's rule, and the shells' for these shapes: a value that
1526
+ * OPENS with `"` continues to the next `"` not escaped by a backslash, one opening with `'` to the next `'`; a value
1527
+ * closed on its own line is no region. Lines strictly after `open`, up to and including `close`, are part of the value.
1528
+ * Issue #430 review round 3 replaced a rule that called every such value unreadable, which refused the documented
1529
+ * multi-line GITHUB_APP_PRIVATE_KEY that systemd reads perfectly (measured on systemd 259).
1530
+ */
1531
+ export function quotedRegions(text) {
1532
+ const lines = String(text ?? "").split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
1533
+ const closeAt = (q, s) => {
1534
+ if (q === "'") return s.indexOf("'");
1535
+ for (let k = 0; k < s.length; k++) {
1536
+ if (s[k] === "\\") k++;
1537
+ else if (s[k] === '"') return k;
1538
+ }
1539
+ return -1;
1540
+ };
1541
+ const regions = [];
1542
+ for (let i = 0; i < lines.length; i++) {
1543
+ const m = /^[ \t]*(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*(["'])([^\n]*)$/.exec(lines[i]);
1544
+ if (!m) continue;
1545
+ const [, q, rest] = m;
1546
+ if (closeAt(q, rest) !== -1) continue;
1547
+ let close = null;
1548
+ for (let k = i + 1; k < lines.length; k++) {
1549
+ if (closeAt(q, lines[k]) !== -1) {
1550
+ close = k + 1;
1551
+ break;
1552
+ }
1553
+ }
1554
+ regions.push({ open: i + 1, close });
1555
+ if (close === null) break;
1556
+ i = close - 1; // resume after the value; the loop's i++ lands on the line after `close`
1557
+ }
1558
+ return regions;
1559
+ }
1560
+
1561
+ /**
1562
+ * What each `envFileSystemdHazard` shape is, and what to change, in words an operator can act on. One table so the
1563
+ * venue readers' refusal and doctor's warning say the same thing about the same line. The two `shell-` shapes are the
1564
+ * sourcing shell's (`envFileHazard` with the shell loader), not systemd's: a systemd service reads both files fine.
1565
+ */
1566
+ export const SYSTEMD_HAZARD_SHAPES = Object.freeze({
1567
+ "lone-cr": {
1568
+ what: "a carriage return (CR) that is not part of a CRLF line ending, which systemd reads as a line break and this command does not",
1569
+ fix: "remove the CR, or save the file with LF (or CRLF) line endings",
1570
+ },
1571
+ "non-identifier-key": {
1572
+ what: "a quoted value under a key that is not a variable name (letters, digits and underscores, not starting with a digit), whose quote does not close on that line, so systemd reads the lines below as part of it",
1573
+ fix: "rename the key to a valid variable name, close the quote on the same line, or delete the line",
1574
+ },
1575
+ "reopened-quote": {
1576
+ what: "a second quote right after a value's closing quote that does not close on that line, so systemd reads the lines below as part of the value",
1577
+ fix: "remove the second quote, or close it on the same line",
1578
+ },
1579
+ continuation: {
1580
+ what: "a trailing backslash that systemd reads as joining the next line to this value (after a quote in the middle of the value, after a #, or on the line where a multi-line quoted value closes)",
1581
+ fix: "remove the trailing backslash, or move the value onto one line",
1582
+ },
1583
+ "unmodelled-quote": {
1584
+ what: "a quoted value whose extent systemd reads differently from this command (a CR, U+2028 or U+2029 inside it, or a quote reached through a continuation), so the lines below it are not what they appear",
1585
+ fix: "put that quoted value on one line and remove the unusual character, or end the line before it with no trailing backslash",
1586
+ },
1587
+ "shell-crlf": {
1588
+ what: "a carriage return (CR) at its end, a CRLF line ending, which the wrapper that sources this file on macOS keeps: a value on that line reaches the service with the CR on its end, and a line holding nothing else runs the CR as a command",
1589
+ fix: "convert the file to LF line endings",
1590
+ },
1591
+ "shell-crlf-comment": {
1592
+ what: "a carriage return (CR) at the end of a comment line, a CRLF line ending: the wrapper that sources this file on macOS would still read the lines below it, but this command refuses CR line endings there, because a key it writes into the file would take the same ending and reach the service with the CR on its end",
1593
+ fix: "convert the file to LF line endings",
1594
+ },
1595
+ "shell-nul": {
1596
+ what: "a NUL byte, and the macOS /bin/sh stops reading the file there, so no key below it is set",
1597
+ fix: "remove the NUL byte",
1598
+ },
1599
+ nul: {
1600
+ what: "a NUL byte, and systemd refuses to load a file with one anywhere in it, so the service does not start",
1601
+ fix: "remove the NUL byte",
1602
+ },
1603
+ "exec-too-large": {
1604
+ what: "more environment than the service can safely be started with: one KEY=value longer than 131071 bytes fails with \"Argument list too long\", and a total over 2031616 bytes (8 bytes per variable counted) may, because Linux gives a program's arguments and environment together a quarter of its stack limit (2 MiB under systemd's default 8 MiB), less what systemd and the unit add",
1605
+ fix: "shorten that value, or move the large content into a file and put its path in the .env",
1606
+ },
1607
+ "invalid-utf8": {
1608
+ what: "bytes in a key or value that are not valid UTF-8, or a Unicode noncharacter (U+FFFE, U+FFFF, U+FDD0 to U+FDEF and the like), and systemd refuses to load such a file, so the service does not start",
1609
+ fix: "re-save the file as UTF-8, or remove those bytes",
1610
+ },
1611
+ });
1612
+
1613
+ /**
1614
+ * The first place in this file where systemd's `EnvironmentFile=` parser reads LINES differently from this module's
1615
+ * reader, as `{ line, shape }` (1-based; `shape` a key of `SYSTEMD_HAZARD_SHAPES`), or `null` (issue #447).
1616
+ *
1617
+ * It walks systemd's own parser states (src/basic/env-file.c: PRE_KEY, KEY, PRE_VALUE, VALUE, the two quoted
1618
+ * states, their escapes and COMMENT) rather than listing forms, because a list refused lines that are harmless: after
1619
+ * a value's closing quote any character but a blank, a quote or a backslash puts systemd in VALUE, where a quote is
1620
+ * literal, so `K="a"b`, `K=a"b"c`, `K="a" # "b"` and `K="/a.json" # see "notes"` all read line by line (measured).
1621
+ * Only the transitions this reader does not model are hazards, each measured on systemd 259:
1622
+ *
1623
+ * lone-cr a CR not followed by LF, outside a quoted value. systemd's newline set is "\n\r", so
1624
+ * `X=1<CR>PI_BACKENDS=podman` sets both keys; this reader splits on LF alone. A CR at the very
1625
+ * end of the file, or inside a quoted value, is read the same way by both and is not a hazard.
1626
+ * non-identifier-key systemd's key is everything before the first `=` (the first character excepted: at the
1627
+ * start of a line even `=` is part of the key), trimmed. Under ANY key, a value that opens with
1628
+ * a quote continues to its close; systemd drops the assignment afterwards when the key is not a
1629
+ * valid name, but the lines it swallowed stay swallowed. `quotedRegions` models identifier keys
1630
+ * (with `export `, which systemd also drops), so only the rest is a hazard, and only when the
1631
+ * quote does not close on its own line.
1632
+ * reopened-quote after a closing quote systemd is back in PRE_VALUE, which skips blanks, so a quote after
1633
+ * optional blanks OPENS again (`K="a" "b` continues onto the next line). A hazard when that
1634
+ * quote does not close on its line, including on the line where an earlier multi-line value
1635
+ * closes.
1636
+ * continuation a backslash before a LF outside quotes joins the next line to the value (VALUE_ESCAPE eats
1637
+ * the newline). The reader's line scan finds that, except where it takes a mid-value quote
1638
+ * or a `#` as quoting or a comment, which systemd does not: `K=a"b\`, `K=a # b\` and a `b"\`
1639
+ * closing a multi-line value all swallow the next line (measured). A hazard only where the
1640
+ * line scan misses the join. A backslash before CRLF does NOT continue in systemd (the CR is
1641
+ * eaten, the LF ends the line); the reader treats it as a continuation, which only ever makes
1642
+ * it read LESS, so it is left alone.
1643
+ *
1644
+ * unmodelled-quote STRUCTURAL, not a form (gate round 1): at every newline, whether systemd is inside a quote
1645
+ * must equal whether `quotedRegions` puts the next line inside a value. The shape list above
1646
+ * passed a value whose first line held U+2028, U+2029 or a CR, because `quotedRegions` used
1647
+ * `.`, which stops there; this comparison is what catches the next such gap.
1648
+ *
1649
+ * A file with no hazard is one where every line this reader calls a line, systemd calls a line too.
1650
+ */
1651
+ export function envFileSystemdHazard(text) {
1652
+ return walkSystemd(String(text ?? "")).hazard;
1653
+ }
1654
+
1655
+ /**
1656
+ * systemd's EnvironmentFile= parser over `raw`, one character at a time, as one pass: the first place it reads LINES
1657
+ * differently from this reader (`hazard`), and the span of every assignment it PUSHES (`pushed`: `[start, end)` in
1658
+ * `raw` plus the line it starts on), which is what systemd checks for valid UTF-8 (`envFileLoadHazard`).
1659
+ *
1660
+ * THE READER'S OWN MODEL IS CHECKED AT EVERY NEWLINE (issue #447, gate round 1): whether systemd is inside a quote
1661
+ * when a line ends must equal whether `quotedRegions` puts the next line inside a value. Any disagreement is a hazard,
1662
+ * whatever its cause. The first version compared shapes only, and `quotedRegions` itself missed a value whose first
1663
+ * line carried U+2028, U+2029 or a CR (its `.` stopped matching there), so the swallowed lines read as assignments
1664
+ * while this function reported nothing (measured on systemd 259: `PI_EGRESS=1` read where systemd set `0`).
1665
+ */
1666
+ function walkSystemd(raw) {
1667
+ const bare = raw.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
1668
+ const readerInside = new Set();
1669
+ for (const r of quotedRegions(raw)) for (let n = r.open + 1; n <= (r.close ?? bare.length); n++) readerInside.add(n);
1670
+ const IDENTIFIER = /^(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*$/;
1671
+ const SHELL_NEED_ESCAPE = '"\\`$';
1672
+ const blank = (c) => c === " " || c === "\t";
1673
+ let hazard = null;
1674
+ const note = (h) => {
1675
+ hazard ??= h;
1676
+ };
1677
+ // What systemd STORES for each assignment it pushes, escapes and quotes processed (trailing blanks are ASCII and
1678
+ // left on, which changes no UTF-8 verdict): that, not the raw span, is what it checks for valid UTF-8, so
1679
+ // `X="\xC3""\xA9"` loads and `X="\xC3\\\xA9"` does not (both measured on systemd 259, gate round 2).
1680
+ const pushed = [];
1681
+ let state = "PRE_KEY";
1682
+ let line = 1;
1683
+ let key = "";
1684
+ let value = "";
1685
+ let keyLine = 1;
1686
+ let closedOnce = false; // a quote of this value has opened and closed, so the next one is a REOPEN
1687
+ let pending = null; // the open quote's hazard if a newline arrives inside it; set afresh on every quote that opens
1688
+ // Trailing blanks of an UNQUOTED value are not stored (systemd's last_value_whitespace); quoted ones are.
1689
+ let trailingBlank = -1;
1690
+ const push = (trim = false) => pushed.push({ line: keyLine, key: key.replace(/[ \t]+$/, ""), value: trim && trailingBlank !== -1 ? value.slice(0, trailingBlank) : value });
1691
+ for (let p = 0; p < raw.length; p++) {
1692
+ const c = raw[p];
1693
+ const quoted = state === "SQ" || state === "DQ" || state === "DQ_ESCAPE";
1694
+ if (c === "\r" && !quoted && p + 1 < raw.length && raw[p + 1] !== "\n") note({ line, shape: "lone-cr" });
1695
+ const newline = c === "\n" || c === "\r";
1696
+ if (quoted && c === "\n" && pending) note(pending);
1697
+ // The reader's own model, checked at every line end: systemd inside a quote exactly where `quotedRegions` puts
1698
+ // the next line inside a value.
1699
+ if (c === "\n" && quoted !== readerInside.has(line + 1)) note({ line, shape: "unmodelled-quote" });
1700
+ switch (state) {
1701
+ case "PRE_KEY":
1702
+ if (c === "#" || c === ";") state = "COMMENT";
1703
+ else if (!blank(c) && !newline) {
1704
+ state = "KEY";
1705
+ key = c;
1706
+ value = "";
1707
+ keyLine = line;
1708
+ closedOnce = false;
1709
+ }
1710
+ break;
1711
+ case "KEY":
1712
+ if (newline) state = "PRE_KEY";
1713
+ else if (c === "=") {
1714
+ state = "PRE_VALUE";
1715
+ trailingBlank = -1;
1716
+ } else key += c;
1717
+ break;
1718
+ case "PRE_VALUE":
1719
+ if (newline) {
1720
+ state = "PRE_KEY";
1721
+ push();
1722
+ } else if (c === "'" || c === '"') {
1723
+ state = c === "'" ? "SQ" : "DQ";
1724
+ if (closedOnce) pending = { line, shape: "reopened-quote" };
1725
+ else if (!IDENTIFIER.test(key.replace(/[ \t]+$/, ""))) pending = { line, shape: "non-identifier-key" };
1726
+ else pending = null;
1727
+ } else if (c === "\\") state = "VALUE_ESCAPE";
1728
+ else if (!blank(c)) {
1729
+ state = "VALUE";
1730
+ trailingBlank = -1;
1731
+ value += c;
1732
+ }
1733
+ break;
1734
+ case "VALUE":
1735
+ if (newline) {
1736
+ state = "PRE_KEY";
1737
+ push(true);
1738
+ } else if (c === "\\") {
1739
+ state = "VALUE_ESCAPE";
1740
+ trailingBlank = -1;
1741
+ } else {
1742
+ if (!blank(c)) trailingBlank = -1;
1743
+ else if (trailingBlank === -1) trailingBlank = value.length;
1744
+ value += c;
1745
+ }
1746
+ break;
1747
+ case "VALUE_ESCAPE":
1748
+ state = "VALUE";
1749
+ if (c === "\n" && !scanQuotes(bare[line - 1], "").continuation) note({ line, shape: "continuation" });
1750
+ if (!newline) value += c;
1751
+ break;
1752
+ case "SQ":
1753
+ if (c === "'") {
1754
+ state = "PRE_VALUE";
1755
+ closedOnce = true;
1756
+ } else value += c;
1757
+ break;
1758
+ case "DQ":
1759
+ if (c === '"') {
1760
+ state = "PRE_VALUE";
1761
+ closedOnce = true;
1762
+ } else if (c === "\\") state = "DQ_ESCAPE";
1763
+ else value += c;
1764
+ break;
1765
+ case "DQ_ESCAPE":
1766
+ state = "DQ";
1767
+ if (SHELL_NEED_ESCAPE.includes(c)) value += c;
1768
+ else if (c !== "\n") value += "\\" + c;
1769
+ break;
1770
+ case "COMMENT":
1771
+ if (c === "\\") state = "COMMENT_ESCAPE";
1772
+ else if (newline) state = "PRE_KEY";
1773
+ break;
1774
+ case "COMMENT_ESCAPE":
1775
+ // Since systemd 254 a comment's trailing backslash does not carry the comment onto the next line.
1776
+ state = newline ? "PRE_KEY" : "COMMENT";
1777
+ break;
1778
+ }
1779
+ if (c === "\n") line++;
1780
+ }
1781
+ // At the end of the file systemd pushes whatever value is open, quoted or not.
1782
+ if (state !== "PRE_KEY" && state !== "KEY" && state !== "COMMENT" && state !== "COMMENT_ESCAPE") push(state === "VALUE");
1783
+ return { hazard, pushed };
1784
+ }
1785
+
1786
+ /**
1787
+ * Why systemd would REFUSE TO LOAD this file at all, as `{ line, shape }`, or `null` (issue #447, gate round 1). The
1788
+ * unit then fails to start with the file's every key, so no reading of it means anything. Measured on systemd 259:
1789
+ *
1790
+ * nul a NUL byte ANYWHERE, a comment included ("Failed to load environment files: Bad message")
1791
+ * invalid-utf8 a key or value systemd pushes that is not valid UTF-8 (a stray byte, an overlong form, a
1792
+ * surrogate, a 5- or 6-byte form, a truncated sequence) or holds a Unicode noncharacter ("Invalid
1793
+ * argument"), judged as systemd STORES it, quotes and escapes processed (gate round 2: `X=\xE2\\\x82\x82`
1794
+ * loads, its escape joining the sequence). In a comment, or on a line with no `=`, systemd never
1795
+ * pushes it and loads the file; a correctly encoded U+FFFD is ordinary text.
1796
+ *
1797
+ * `content` is the file's BYTES: decoding first (as `readFileSync(path, "utf8")` does) turns a bad byte into U+FFFD
1798
+ * and the evidence is gone. A string (a test's seam) can still be checked for NUL and is otherwise taken as valid.
1799
+ * The walk runs over the bytes as latin1, one character per byte, which is exact for systemd's states: every
1800
+ * character it acts on is ASCII, and every byte of a multi-byte sequence is 0x80 or above.
1801
+ */
1802
+ export function envFileLoadHazard(content) {
1803
+ const isBytes = content instanceof Uint8Array;
1804
+ const text = isBytes ? Buffer.from(content.buffer, content.byteOffset, content.byteLength).toString("latin1") : String(content ?? "");
1805
+ const nul = text.indexOf("\0");
1806
+ if (nul !== -1) return { line: lineAt(text, nul), shape: "nul" };
1807
+ const { pushed } = walkSystemd(text);
1808
+ if (isBytes) {
1809
+ for (const a of pushed) {
1810
+ if (!systemdUtf8(Buffer.from(a.key, "latin1")) || !systemdUtf8(Buffer.from(a.value, "latin1"))) return { line: a.line, shape: "invalid-utf8" };
1811
+ }
1812
+ }
1813
+ // TOO BIG TO EXEC (gate round 3): the unit then fails at start with "Argument list too long" (203/EXEC). Measured on
1814
+ // systemd 259: a KEY=value of 131071 bytes runs and of 131072 does not (Linux caps ONE string at 131072 bytes WITH
1815
+ // its NUL; gate round 4 corrected an off-by-one), and 24 values of 100 KiB (2.4 MB in all) fail where 19 (1.9 MB)
1816
+ // run. So one KEY=value longer than 131071 bytes is refused, and so is a whole environment past `EXEC_TOTAL_MAX`.
1817
+ //
1818
+ // THE TOTAL IS NOT A CONSTANT OF systemd (focus review, measured on systemd 259 under `systemd-run --user`, Fedora
1819
+ // 44): the kernel gives argv and envp together a quarter of the stack limit, which is 8 MiB by default, so 2097152
1820
+ // bytes; a unit's `.env` of 2096288 counted bytes (21 assignments) started and 2096349 failed with 203/EXEC, the
1821
+ // 864 between them being systemd's own 17 variables (535 bytes), a pointer (8 bytes) per string and the argv. So
1822
+ // the bound counts 8 bytes per variable too and keeps a 64 KiB margin for the argv, what systemd and the unit add,
1823
+ // and the stack limit (LimitSTACK=). It was a flat 1 MiB, which refused a 1.1 MB file systemd starts.
1824
+ const size = (s) => (isBytes ? s.length : Buffer.byteLength(s, "utf8"));
1825
+ // Only what reaches exec counts: the LAST value of each valid name (measured: a 200 KB `X=` reassigned small below runs,
1826
+ // and names systemd drops as invalid never reach the service).
1827
+ const finalEnv = new Map();
1828
+ for (const a of pushed) if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(a.key)) finalEnv.set(a.key, { n: size(a.key) + 1 + size(a.value), line: a.line });
1829
+ for (const [key, { n, line }] of finalEnv) if (n > EXEC_ONE_MAX) return { line, shape: "exec-too-large", detail: `${key}=... is ${n} bytes` };
1830
+ let total = 0;
1831
+ for (const { n, line } of finalEnv.values()) {
1832
+ total += n + 1 + 8;
1833
+ if (total > EXEC_TOTAL_MAX) return { line, shape: "exec-too-large", detail: `the file's assignments reach ${total} bytes by this line` };
1834
+ }
1835
+ return null;
1836
+ }
1837
+
1838
+ /**
1839
+ * The longest one KEY=value, and the whole-environment bound, `envFileLoadHazard` accepts, in bytes (gate rounds 3, 4;
1840
+ * the total re-measured in the focus review): 2 MiB, the measured exec limit under systemd's default stack, less 64 KiB.
1841
+ */
1842
+ export const EXEC_ONE_MAX = 131071;
1843
+ export const EXEC_TOTAL_MAX = 2 * 1024 * 1024 - 64 * 1024;
1844
+
1845
+ /**
1846
+ * What systemd STORES for `key` from this `.env` text, as `{ value, line }`, or `undefined` when it assigns none: the
1847
+ * last assignment its parser pushes under exactly that name, quotes, escapes and trailing blanks processed. This is
1848
+ * systemd's own reading, not the line-level one, so `KEY =x` counts (systemd trims the blank before `=`) and
1849
+ * `export KEY=x` does not (that key is `export KEY`, which systemd drops). The writer's never-clobber and read-back
1850
+ * checks use it on Linux (gate round 3).
1851
+ */
1852
+ export function systemdReading(text, key) {
1853
+ let found;
1854
+ for (const a of walkSystemd(String(text ?? "")).pushed) if (a.key === key) found = { value: a.value, line: a.line };
1855
+ return found;
1856
+ }
1857
+
1858
+ /**
1859
+ * systemd's `utf8_is_valid` over these bytes: well-formed UTF-8 (`firstInvalidUtf8`) AND no Unicode noncharacter, which
1860
+ * it rejects too while a strict `TextDecoder` accepts them: U+FDD0 to U+FDEF, and every code point whose low 16 bits
1861
+ * are FFFE or FFFF (U+FFFF, U+1FFFE, U+10FFFE and the rest). Measured on systemd 259 (gate round 2): U+FFFE, U+FFFF,
1862
+ * U+FDD0, U+FDEF, U+1FFFF and U+10FFFE in a value fail the load, U+FDCF and U+FDF0 do not.
1863
+ */
1864
+ export function systemdUtf8(bytes) {
1865
+ if (firstInvalidUtf8(bytes) !== -1) return false;
1866
+ for (const ch of Buffer.from(bytes).toString("utf8")) {
1867
+ const cp = ch.codePointAt(0);
1868
+ if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) return false;
1869
+ }
1870
+ return true;
1871
+ }
1872
+
1873
+ /** The 1-based line of the character at `index`. */
1874
+ function lineAt(text, index) {
1875
+ let n = 1;
1876
+ for (let k = 0; k < index; k++) if (text.charCodeAt(k) === 10) n++;
1877
+ return n;
1878
+ }
1879
+
1880
+ /**
1881
+ * The text of a `.env` read as BYTES, plus the reason the loader would refuse to load it (`loadHazard`, systemd only).
1882
+ * The one place the call sites (`up`, `service install`, the setup wizard, doctor) turn a file into text, so the
1883
+ * check `envFileLoadHazard` needs the bytes for is made once rather than by each of them.
1884
+ */
1885
+ export function decodeEnvFile(content, { loader = "systemd" } = {}) {
1886
+ const isBytes = content instanceof Uint8Array;
1887
+ const text = isBytes ? Buffer.from(content.buffer, content.byteOffset, content.byteLength).toString("utf8") : String(content ?? "");
1888
+ return { text, loadHazard: loader === "systemd" ? envFileLoadHazard(content) : null };
1889
+ }
1890
+
1891
+ /**
1892
+ * The variables each service wrapper keeps for itself (issue #470 follow-up). Both wrappers load `.env` into their own
1893
+ * scope, so a line could once set one: `env_setup=` (sh) or `ENV_SETUP=` (cmd) named a script the wrapper then ran,
1894
+ * `signaled=1` made the sh wrapper exit 0 without starting, and `ERRORLEVEL=` shadowed the exit code the cmd wrapper
1895
+ * converts. The wrappers now assign all of them after the load, from values no line reaches, so such a line has no
1896
+ * effect, and the reader and writer name it rather than let an operator believe it does anything. PI_ENV_SETUP is on
1897
+ * both lists: it is unit configuration and never honoured from the file. The cmd list is compared ignoring case, as
1898
+ * `set` does. Kept beside the wrappers' text by a test, which fails when either wrapper's own variables change.
1899
+ */
1900
+ export const WRAPPER_INTERNAL_KEYS = Object.freeze({
1901
+ shell: Object.freeze(["PI_ENV_SETUP", "env_setup", "signaled", "child", "rc"]),
1902
+ cmd: Object.freeze(["PI_ENV_SETUP", "ENV_SETUP", "RC", "ERRORLEVEL"]),
1903
+ });
1904
+
1905
+ /** What a line assigning one of the wrapper's own variables is, and what to do, in the words doctor and the writer use. */
1906
+ const WRAPPER_INTERNAL_WHAT = (name) => `assigns ${name}, a variable the service wrapper keeps for itself (deploy/worker-env-wrapper.sh on macOS, .cmd on Windows) and assigns again after loading this file, so the line has no effect${/^pi_env_setup$/i.test(name) ? "; PI_ENV_SETUP is never honoured from this file" : ""}`;
1907
+ const WRAPPER_INTERNAL_FIX = "remove the line (a setup script is named with pi-dispatch service install --env-setup <path>, never in .env)";
1908
+
1909
+ /** A `envFileWrapperInternal` finding as one sentence: the line, what it is, and the fix. */
1910
+ export function wrapperInternalSentence(found) {
1911
+ return `line ${found.line} ${WRAPPER_INTERNAL_WHAT(found.name)}. To fix it, ${WRAPPER_INTERNAL_FIX}`;
1912
+ }
1913
+
1914
+ /**
1915
+ * The first line of this text assigning one of the service wrapper's own variables (`WRAPPER_INTERNAL_KEYS`) for this
1916
+ * loader, as `{ line, name }`, or `null`; always `null` for systemd, which runs no wrapper. Not a hazard: such a line
1917
+ * changes no other key's reading, so doctor says it beside its readings rather than in place of them.
1918
+ */
1919
+ export function envFileWrapperInternal(text, { loader = "systemd" } = {}) {
1920
+ if (loader === "systemd") return null;
1921
+ const lines = String(text ?? "").split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
1922
+ if (loader === "cmd") {
1923
+ // The cmd wrapper's own name for the line (`cmdReading`): after any leading `=`, up to the first `=`, with blanks,
1924
+ // quotes and a BOM around it set aside, compared ignoring case. A `#` line (skipped by `eol=#`) needs no test of
1925
+ // its own: its name starts with `#`, which no name on the list does.
1926
+ const names = new Set(WRAPPER_INTERNAL_KEYS.cmd);
1927
+ for (let i = 0; i < lines.length; i++) {
1928
+ const body = lines[i].replace(/^=+/, "");
1929
+ const eq = body.indexOf("=");
1930
+ const name = (eq === -1 ? body : body.slice(0, eq)).replace(/^[ \t\r\ufeff"]+|[ \t\r\ufeff"]+$/g, "");
1931
+ const upper = name.replace(/[a-z]+/g, (m) => m.toUpperCase());
1932
+ if (names.has(upper)) return { line: i + 1, name };
1933
+ }
1934
+ return null;
1935
+ }
1936
+ const names = new Set(WRAPPER_INTERNAL_KEYS.shell);
1937
+ const inside = quoteSpans(lines, loader).inside;
1938
+ for (let i = 0; i < lines.length; i++) {
1939
+ if (inside[i]) continue;
1940
+ const m = ASSIGNMENT.exec(lines[i]);
1941
+ if (m !== null && names.has(m[2])) return { line: i + 1, name: m[2] };
1942
+ }
1943
+ return null;
1944
+ }
1945
+
1946
+ /**
1947
+ * The first line of this file that this command cannot read, or `null`. Same rule as the reader's.
1948
+ *
1949
+ * Exported because ABSENCE OF A READING IS NOT ABSENCE OF AN ASSIGNMENT, and doctor printed the second when
1950
+ * it had the first: a key with no record at all, in a file with a line like this in it, was reported as
1951
+ * "unset, so the worker ignores it". Answered for the cmd loader too, which an earlier version refused to
1952
+ * do -- so the one cross-line hazard Windows has was invisible to the caller that needed it.
1953
+ */
1954
+ export function envFileHazard(text, { loader = "systemd" } = {}) {
1955
+ const spans = quoteSpans(String(text ?? "").split("\n"), loader);
1956
+ if (spans.hazard !== null) return spans.shape === undefined ? { line: spans.hazard } : { line: spans.hazard, shape: spans.shape };
1957
+ // systemd's own line structure, where it differs from this reader's (issue #447). Only for that loader: the
1958
+ // shells split on LF and carry every quote, and the cmd wrapper reads one line at a time.
1959
+ return loader === "systemd" ? envFileSystemdHazard(text) : null;
1960
+ }
1961
+
1962
+ function readValue(rest, loader) {
1963
+ if (loader === "cmd") {
1964
+ // `for /f "delims=="` takes the rest of the line verbatim, so there is no quoting and no comment.
1965
+ // An empty value UNSETS the variable there (`set "K="`), read from the wrapper's own source rather
1966
+ // than run on Windows.
1967
+ // Verbatim: there is no quoting to strip and no comment syntax. It can still DISAGREE with the POSIX loaders,
1968
+ // which is why a caller asks the loader its own deployment uses rather than blending them.
1969
+ // NOT PLAIN where this reading is not the wrapper's (issue #470, the table at `cmdReading`): `K==v` is `v` to
1970
+ // for /f, not `=v`; a `!` (and a `^` beside one) depends on the registry's delayed expansion; a character outside
1971
+ // ASCII is decoded in the console code page; a control character is not documented at all. A `"` and a `%` keep
1972
+ // the reading the wrapper's own header describes (the quote stays unvouched, as the file's hazard); the WRITER is
1973
+ // stricter and refuses all of them on the line it takes (`cmdValueRefusal`).
1974
+ return rest.startsWith("=") || /[\x00-\x08\x0a-\x1f\x7f!^]|[^\x00-\x7f]/u.test(rest) ? { plain: false, value: null } : { plain: true, value: rest };
1975
+ }
1976
+ const trimmedEnd = rest.replace(/[ \t]+$/, "");
1977
+ if (trimmedEnd === "") return { plain: true, value: "" };
1978
+ if (/^[ \t]/.test(trimmedEnd)) return { plain: false, value: null }; // the shells drop it
1979
+ const q = trimmedEnd[0];
1980
+ if (q === '"' || q === "'") {
1981
+ const close = trimmedEnd.indexOf(q, 1);
1982
+ if (close !== trimmedEnd.length - 1) return { plain: false, value: null };
1983
+ const inner = trimmedEnd.slice(1, -1);
1984
+ const bad = q === '"' ? /["$\\`]/ : /'/;
1985
+ return { plain: !bad.test(inner) && !QUOTED_CONTROL.test(inner), value: inner };
150
1986
  }
151
- fs.renameSync(tmp, path);
152
- return { changed: true };
1987
+ if (trimmedEnd.endsWith("\\")) return { plain: false, value: null };
1988
+ return { plain: UNQUOTED_PLAIN.test(trimmedEnd), value: trimmedEnd };
153
1989
  }