@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.
- package/.env.example +300 -148
- package/README.md +50 -0
- package/deploy/com.pi-dispatch.worker.plist +9 -3
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +11 -0
- package/deploy/worker-env-wrapper.sh +60 -34
- package/deploy/worker.service +18 -8
- package/package.json +14 -4
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4701 -414
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +455 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +222 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/processor.mjs +505 -26
- package/src/provider-key.mjs +41 -0
- package/src/provider-steering.mjs +144 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +23 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1348 -326
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +176 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- 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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
94
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
fs
|
|
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
|
-
|
|
910
|
+
target = fs.realpathSync?.(path) ?? path;
|
|
147
911
|
} catch {
|
|
148
|
-
//
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
return {
|
|
1987
|
+
if (trimmedEnd.endsWith("\\")) return { plain: false, value: null };
|
|
1988
|
+
return { plain: UNQUOTED_PLAIN.test(trimmedEnd), value: trimmedEnd };
|
|
153
1989
|
}
|