@homeflare/alchemy 0.51.0 → 0.52.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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The `Environment=` oracle — a PORT of the three parsers a declared env value crosses, from
3
+ * `Environment=` line to what the container actually receives. The quadlet half (read, re-emit)
4
+ * is container-env-quadlet-oracle.ts; this file holds the SYSTEMD half — the `%` and `$` folds —
5
+ * and the composed chain. Every rule was MEASURED, not inferred (live podman 5.4.2
6
+ * `podman-system-generator --dryrun`, and a live systemd 257 unit start, 2026-10-11/12):
7
+ *
8
+ * 3. systemd parses the generated `ExecStart=` with the same splitter, then folds BOTH
9
+ * expansion syntaxes: `%%` → one literal `%`, every other `%x` consumed as a specifier (an
10
+ * unknown one invalidates the unit — systemd.unit(5) §Specifiers); and the `$` rules of
11
+ * systemd.service(5) §Command lines (v257), measured against a live unit: `$$` → one literal
12
+ * `$`; `${NAME}` "erased and replaced by the exact value of the environment variable" —
13
+ * modelled here as UNSET, so recorded in `consumed` and replaced with the empty string — and
14
+ * any other `$` mid-word stays literal, because unbraced expansion applies only to a word
15
+ * that is EXACTLY `$FOO`, which a `KEY=value` word never is. An unterminated `${` stays
16
+ * literal too (measured: `x${UNCLOSED` arrived intact). The generated ExecStart has no `:`
17
+ * prefix, so variable substitution always applies. Measured end to end: the UNQUOTED
18
+ * renderer's `pa$$word` arrives as `pa$word` (halved) and its `a${HOME}b` as `ab` (erased),
19
+ * while the quoted-and-escaped forms of the same values arrive intact.
20
+ *
21
+ * ★ `consumed` IS THE FAILURE SIGNAL — a non-empty list means a `%x` or `${NAME}` the renderer let
22
+ * through as data was eaten downstream, so the declared value did not survive (expanded away, or
23
+ * the unit invalidated). The live unit also logged
24
+ * `Referenced but unset environment variable evaluates to an empty string` for every `${NAME}`
25
+ * — the host-side tell, in `journalctl`, if one ever escapes the renderer at deploy time.
26
+ * ⚠️ WHY A PORT, NOT THE REAL PARSERS: the real generator runs root-side per host, and starting a
27
+ * unit IS a deploy — neither can be a unit test's dependency. The port lets every renderer
28
+ * change replay the measured bytes in CI; container-env-live.test.ts re-validates the port
29
+ * against the REAL generator wherever podman is installed, so port and pins stay honest.
30
+ */
31
+ import { quadletEnvArgs, splitWords } from './container-env-quadlet-oracle.ts';
32
+
33
+ /**
34
+ * Parser 3's `%` rules: `%%` folds to one literal `%`; every other `%x` is a specifier systemd
35
+ * consumes, so it is recorded — a non-empty record means the declared value did not survive
36
+ * (expanded away, or the unit invalidated).
37
+ */
38
+ const foldSpecifiers = (text: string): { text: string; consumed: string[] } => {
39
+ const consumed: string[] = [];
40
+ const folded = text.replace(/%(.)/g, (_all, c: string) => {
41
+ if (c === '%') return '%';
42
+ consumed.push(`%${c}`);
43
+ return '';
44
+ });
45
+ return { text: folded, consumed };
46
+ };
47
+
48
+ /**
49
+ * Parser 3's `$` rules, left-to-right in ONE scan — a fold that ran two passes could not tell
50
+ * `pa$$$$word` (the escaped `pa$$word`) from anything else. systemd.service(5) §Command lines
51
+ * (v257), measured against a live unit: `$$` → one literal `$`; `${NAME}` → its value (unset →
52
+ * empty, recorded); any other `$` mid-word stays literal — unbraced expansion applies only to a
53
+ * word that is EXACTLY `$FOO`, and this oracle only ever folds `KEY=value` words.
54
+ */
55
+ const foldDollars = (text: string): { text: string; consumed: string[] } => {
56
+ const consumed: string[] = [];
57
+ let out = '';
58
+ let i = 0;
59
+ while (i < text.length) {
60
+ const c = text.charAt(i);
61
+ if (c !== '$') {
62
+ out += c;
63
+ i++;
64
+ continue;
65
+ }
66
+ const next = text.charAt(i + 1);
67
+ if (next === '$') {
68
+ out += '$';
69
+ i += 2;
70
+ continue;
71
+ }
72
+ if (next === '{') {
73
+ const end = text.indexOf('}', i + 2);
74
+ if (end === -1) {
75
+ out += text.slice(i);
76
+ break;
77
+ }
78
+ consumed.push(text.slice(i, end + 1));
79
+ i = end + 1;
80
+ continue;
81
+ }
82
+ out += '$';
83
+ i++;
84
+ }
85
+ return { text: out, consumed };
86
+ };
87
+
88
+ /** Parser 3: what a `--env`-shaped ExecStart word becomes at the container — both folds applied. */
89
+ const deployArgs = (
90
+ args: readonly string[],
91
+ ): { env: Record<string, string>; consumed: string[] } => {
92
+ const env: Record<string, string> = {};
93
+ const consumed: string[] = [];
94
+ for (const arg of args) {
95
+ const word = splitWords(arg)?.[0];
96
+ if (word === undefined) continue;
97
+ const specifiers = foldSpecifiers(word);
98
+ const dollars = foldDollars(specifiers.text);
99
+ consumed.push(...specifiers.consumed, ...dollars.consumed);
100
+ const eq = dollars.text.indexOf('=');
101
+ if (eq > 0) env[dollars.text.slice(0, eq)] = dollars.text.slice(eq + 1);
102
+ }
103
+ return { env, consumed };
104
+ };
105
+
106
+ /** The whole chain over rendered `Environment=` lines → what the container actually receives. */
107
+ export const deployed = (
108
+ lines: readonly string[],
109
+ ): { env: Record<string, string>; consumed: string[] } => deployArgs(quadletEnvArgs(lines));
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The QUADLET half of the `Environment=` oracle — how podman reads an `Environment=` line and
3
+ * re-emits it as `--env` arguments of the generated unit's `ExecStart=`. The systemd half (the
4
+ * `$`/`%` folds) and the composed chain live in container-env-oracle.ts, whose header carries
5
+ * the three-parser overview; every rule below was MEASURED against the live podman 5.4.2
6
+ * generator (2026-10-11/12), not inferred.
7
+ *
8
+ * 1. quadlet reads the line's value with systemd's own splitter (pkg/systemd/parser/unitfile.go
9
+ * `LookupAllKeyVal` → `splitString(value, " \t\n\r", SplitRelax | SplitUnquote |
10
+ * SplitCUnescape)`, podman v5.4.0). Measured: an unquoted space ends the assignment there
11
+ * (`ROLE_ATTRIBUTE_PATH` was truncated at its first space); quoted fragments are unquoted
12
+ * and re-parsed (`'cn=admins,ou=people')` became the bogus variable `cn`); words without
13
+ * `=` are silently skipped; an unknown C escape (`\d`) drops EVERY assignment of that line,
14
+ * silently.
15
+ * 2. quadlet re-emits each assignment as one `--env` argument (podman `addEnv` → `AddCmdline` →
16
+ * `escapeWords`, pkg/systemd/parser/split.go v5.4.2): re-escaped, and wrapped in double quotes
17
+ * iff ANY character needs escape — `wordNeedEscape`'s set: whitespace, control characters,
18
+ * `"`, `'` and `\` — never touching `%` or `$`, which pass through verbatim. Measured:
19
+ * `Environment="D=pa$$$$word"` → `--env D=pa$$$$word`, and the single quotes in Grafana's
20
+ * role path stayed literal inside the double-quoted `--env "…'Admin'…"`.
21
+ */
22
+
23
+ /**
24
+ * Parsers 1+2 composed: the assignments quadlet reads out of rendered `Environment=` lines, then
25
+ * the `--env` arguments it emits for them. Exported for container-env-live.test.ts, which replays
26
+ * this against the REAL generator wherever one is installed.
27
+ */
28
+ export const quadletEnvArgs = (lines: readonly string[]): string[] => {
29
+ const told: Record<string, string> = {};
30
+ for (const line of lines) {
31
+ const words = splitWords(line.slice('Environment='.length));
32
+ if (words !== undefined) Object.assign(told, parseAssignments(words));
33
+ }
34
+ return emitPodmanArgs(told);
35
+ };
36
+
37
+ /** Podman's own separator set — `WhitespaceSeparators = " \t\n\r"` (split.go v5.4.2), NOT the
38
+ * fuller C-locale whitespace: a raw `\f` or `\v` is a word character to quadlet, not a break. */
39
+ const WHITESPACE = ' \t\n\r';
40
+
41
+ const C_UNESCAPES: Record<string, string> = {
42
+ a: '\x07',
43
+ b: '\b',
44
+ f: '\f',
45
+ n: '\n',
46
+ r: '\r',
47
+ t: '\t',
48
+ v: '\v',
49
+ '\\': '\\',
50
+ '"': '"',
51
+ "'": "'",
52
+ 0: '\0',
53
+ };
54
+
55
+ /**
56
+ * Parser 1: systemd's `splitString(value, WhitespaceSeparators, SplitRelax | SplitUnquote |
57
+ * SplitCUnescape)`. Returns the words, or `undefined` when the split FAILS — which is what turns
58
+ * into "every assignment on this line silently dropped" (measured, see header). Quotes are
59
+ * stripped and their content kept; `\xNN` is unescaped like the simple table.
60
+ */
61
+ export const splitWords = (value: string): string[] | undefined => {
62
+ const words: string[] = [];
63
+ let word = '';
64
+ let quote: '"' | "'" | undefined;
65
+ let i = 0;
66
+ let started = false;
67
+ while (i < value.length) {
68
+ const c = value.charAt(i);
69
+ if (quote === undefined && WHITESPACE.includes(c)) {
70
+ if (started) words.push(word);
71
+ word = '';
72
+ started = false;
73
+ i++;
74
+ continue;
75
+ }
76
+ if (c === '\\') {
77
+ // CUNESCAPE applies inside and outside quotes; an unknown escape fails the whole split.
78
+ const next = value[i + 1];
79
+ if (next === undefined) return undefined;
80
+ if (next === 'x' && /^[0-9a-fA-F]{2}/.test(value.slice(i + 2, i + 4))) {
81
+ word += String.fromCharCode(parseInt(value.slice(i + 2, i + 4), 16));
82
+ i += 4;
83
+ started = true;
84
+ continue;
85
+ }
86
+ const unescaped = C_UNESCAPES[next];
87
+ if (unescaped === undefined) return undefined;
88
+ word += unescaped;
89
+ started = true;
90
+ i += 2;
91
+ continue;
92
+ }
93
+ if (c === '"' || c === "'") {
94
+ if (quote === c) quote = undefined;
95
+ else if (quote === undefined) {
96
+ quote = c;
97
+ started = true;
98
+ } else word += c;
99
+ i++;
100
+ continue;
101
+ }
102
+ word += c;
103
+ started = true;
104
+ i++;
105
+ }
106
+ if (started) words.push(word);
107
+ return words;
108
+ };
109
+
110
+ /** A word without `=` is not an assignment — measured: silently skipped, never an error. */
111
+ export const parseAssignments = (words: readonly string[]): Record<string, string> => {
112
+ const env: Record<string, string> = {};
113
+ for (const word of words) {
114
+ const eq = word.indexOf('=');
115
+ if (eq <= 0) continue;
116
+ env[word.slice(0, eq)] = word.slice(eq + 1);
117
+ }
118
+ return env;
119
+ };
120
+
121
+ /**
122
+ * `wordNeedEscape`'s set, ported from podman pkg/systemd/parser/split.go (v5.4.2): a character
123
+ * forces quoting iff it is ASCII whitespace or control (`<= 0x20` or `0x7f`), a double or single
124
+ * quote, or a backslash — "unicode is ok" is the source's own comment, so anything above `0x80`
125
+ * passes. ⚠️ Note the SINGLE quote: podman quotes a word containing one even though escaping
126
+ * leaves it literal — a port that only quotes what escaping CHANGES would drift from the real
127
+ * `--env` bytes for values like `it's`.
128
+ */
129
+ const needEscape = (word: string): boolean => {
130
+ for (const c of word) {
131
+ const code = c.charCodeAt(0);
132
+ if (code > 128) continue;
133
+ if (code <= 0x20 || code === 0x7f || c === '"' || c === "'" || c === '\\') return true;
134
+ }
135
+ return false;
136
+ };
137
+
138
+ /** `escapeString`'s table (same source): podman re-escapes the C control codes and `"`/`\`
139
+ * inside the double quotes it adds, and writes a literal space and `'` — safe inside `"`. */
140
+ const REESCAPES: Record<string, string> = {
141
+ '\x07': '\\a',
142
+ '\b': '\\b',
143
+ '\f': '\\f',
144
+ '\n': '\\n',
145
+ '\r': '\\r',
146
+ '\t': '\\t',
147
+ '\v': '\\v',
148
+ '\\': '\\\\',
149
+ '"': '\\"',
150
+ "'": "'",
151
+ ' ': ' ',
152
+ };
153
+
154
+ /** Parser 2: re-emit as one `--env` argument — escaped, and quoted iff any character needs it.
155
+ * ⚠️ KEYS SORTED, NOT INSERTION-ORDERED: podman's `addEnv` → `addKeys` collects the map's keys
156
+ * and `sort.Strings`s them before emitting (pkg/systemd/quadlet/podmancmdline.go, v5.4.2), so
157
+ * the generated ExecStart holds the assignments sorted by key whatever order the file declared
158
+ * them in — measured: a file declaring DOLLAR2, TRUNC, DOLLARBRACE emitted DOLLAR2, DOLLARBRACE,
159
+ * TRUNC. The sort here compares UTF-16 code units like the renderer's own; for the ASCII env
160
+ * key names in play it is Go's byte order exactly. */
161
+ export const emitPodmanArgs = (env: Readonly<Record<string, string>>): string[] =>
162
+ Object.entries(env)
163
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
164
+ .map(([key, value]) => {
165
+ const word = `${key}=${value}`;
166
+ if (!needEscape(word)) return word;
167
+ let escaped = '"';
168
+ for (const c of word) {
169
+ const known = c.charCodeAt(0) <= 128 ? REESCAPES[c] : undefined;
170
+ escaped +=
171
+ known ?? (needEscape(c) ? `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}` : c);
172
+ }
173
+ return `${escaped}"`;
174
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The TEXT-level contract of the `Environment=` renderer: what `quoteEnvironmentValue` /
3
+ * `environmentLines` emit, that newlines are refused rather than encoded, and that
4
+ * `renderContainerFile` places the quoted block where the unquoted renderer had it.
5
+ *
6
+ * ⛔ Every expected string below was accepted by the LIVE podman 5.4.2 system generator (measured
7
+ * 2026-10-11/12, artifacts in /tmp/opencode/quadlet-quote/): the generator read each quoted line
8
+ * as exactly ONE assignment. What those bytes BECOME through quadlet's splitter, podman's
9
+ * re-emission and systemd's ExecStart parsing is the parser-level proof —
10
+ * container-env-chain.test.ts, which also shows the old unquoted renderer corrupting the same
11
+ * values through the same chain.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import {
15
+ QuadletEnvironmentValueError,
16
+ environmentLines,
17
+ environmentValueProblems,
18
+ quoteEnvironmentValue,
19
+ } from './container-env-quote.ts';
20
+ import { containerProblems, renderContainerFile } from './container-form.ts';
21
+
22
+ // The real values this row exists for (homeflare-ct100 commit 53b84c1, grafana.container).
23
+ const VAR = 'GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH';
24
+ const ROLE_PATH =
25
+ "(contains(groups[*], 'Admin') || contains(email, 'tim@schenanigans.com')) && 'GrafanaAdmin'";
26
+
27
+ describe('the rendered text', () => {
28
+ test('the deployed Grafana expression, quoted whole', () => {
29
+ expect(environmentLines({ [VAR]: ROLE_PATH })).toEqual([
30
+ [
31
+ 'Environment',
32
+ `"${VAR}=(contains(groups[*], 'Admin') || contains(email, ` +
33
+ `'tim@schenanigans.com')) && 'GrafanaAdmin'"`,
34
+ ],
35
+ ]);
36
+ });
37
+
38
+ test('escapes, quotes and percent signs, exactly as the generator accepted them', () => {
39
+ expect(
40
+ environmentLines({ BACKSLASH: 'C:\\path', PCT: '50%off', QUOTED: 'say "hi"' }).map(
41
+ ([key, value]) => `${key}=${value}`,
42
+ ),
43
+ ).toEqual([
44
+ String.raw`Environment="BACKSLASH=C:\\path"`,
45
+ String.raw`Environment="PCT=50%%off"`,
46
+ String.raw`Environment="QUOTED=say \"hi\""`,
47
+ ]);
48
+ });
49
+
50
+ test('dollar signs are doubled for systemd, exactly as the generator accepted them', () => {
51
+ // ⛔ THE PIN THE 2026-10-12 FIX ROUND ADDS: systemd.service(5) §Command lines (v257, measured
52
+ // against a live unit) folds `$$` to one literal `$` and erases `${FOO}`, so a `$` left bare
53
+ // is data the container never receives: `pa$$word` arrived as `pa$word`, `a${HOME}b` as `ab`.
54
+ expect(
55
+ // oxlint-disable-next-line no-template-curly-in-string -- the declared value under test, not a placeholder
56
+ environmentLines({ B: 'a${HOME}b', D: 'pa$$word' }).map(([key, value]) => `${key}=${value}`),
57
+ ).toEqual(
58
+ // oxlint-disable-next-line no-template-curly-in-string -- the measured bytes the renderer must emit
59
+ ['Environment="B=a$${HOME}b"', 'Environment="D=pa$$$$word"'],
60
+ );
61
+ });
62
+
63
+ test('keys are sorted, so the render is stable for an unordered plain object', () => {
64
+ expect(environmentLines({ B: '2', A: '1' })).toEqual([
65
+ ['Environment', '"A=1"'],
66
+ ['Environment', '"B=2"'],
67
+ ]);
68
+ expect(environmentLines({})).toEqual([]);
69
+ });
70
+ });
71
+
72
+ describe('newlines are refused, not encoded', () => {
73
+ test('a newline in a value is a typed error naming the key, never the value', () => {
74
+ expect(() => quoteEnvironmentValue('WEBHOOK_URL', 'https://x\nSECRET-TAIL')).toThrow(
75
+ QuadletEnvironmentValueError,
76
+ );
77
+ try {
78
+ quoteEnvironmentValue('WEBHOOK_URL', 'https://x\nSECRET-TAIL');
79
+ } catch (error) {
80
+ expect((error as QuadletEnvironmentValueError)._tag).toBe('QuadletEnvironmentValueError');
81
+ expect((error as QuadletEnvironmentValueError).key).toBe('WEBHOOK_URL');
82
+ const message = (error as QuadletEnvironmentValueError).message;
83
+ expect(message).toContain('WEBHOOK_URL');
84
+ expect(message).not.toContain('SECRET-TAIL');
85
+ }
86
+ expect(() => quoteEnvironmentValue('BAD\nKEY', 'value')).toThrow(QuadletEnvironmentValueError);
87
+ });
88
+
89
+ test('environmentValueProblems reports every refusal at once, as problem strings', () => {
90
+ expect(environmentValueProblems({ OK: 'fine', BAD: 'a\nb', WORSE: 'c\nd' })).toEqual([
91
+ expect.stringContaining('BAD'),
92
+ expect.stringContaining('WORSE'),
93
+ ]);
94
+ expect(environmentValueProblems({ OK: 'fine' })).toEqual([]);
95
+ });
96
+
97
+ test('containerProblems reports the refusal, never throwing past the plan', () => {
98
+ // The plan-time surface: a newline value must not abort `containerProblems` (a caller
99
+ // building a plan needs every refusal at once), and the string it gets back is the rule
100
+ // text — enough to locate the prop, never the value.
101
+ const problems = containerProblems({
102
+ name: 'grafana-primary',
103
+ container: { image: 'docker.io/library/alpine:3', environment: { BAD: 'a\nb' } },
104
+ });
105
+ expect(problems).toHaveLength(1);
106
+ expect(problems[0]).toContain('environment key "BAD" contains a newline');
107
+ });
108
+ });
109
+
110
+ describe('renderContainerFile wires the quoting in — the whole file', () => {
111
+ const props = {
112
+ name: 'grafana-primary',
113
+ container: {
114
+ environmentFile: ['/etc/grafana-primary/env'],
115
+ environment: { [VAR]: ROLE_PATH, GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true' },
116
+ image: 'docker.io/grafana/grafana:12.2.1',
117
+ network: 'host',
118
+ },
119
+ };
120
+
121
+ test('the Environment= block is quoted, still sorted, still after EnvironmentFile=', () => {
122
+ const lines = renderContainerFile(props).split('\n');
123
+ const envBlock = lines.filter((line) => line.startsWith('Environment='));
124
+ expect(envBlock).toEqual(
125
+ environmentLines(props.container.environment).map(([key, value]) => `${key}=${value}`),
126
+ );
127
+ expect(lines.indexOf(envBlock[0] ?? '')).toBeGreaterThan(
128
+ lines.indexOf('EnvironmentFile=/etc/grafana-primary/env'),
129
+ );
130
+ });
131
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The `[Container]` `Environment=` value renderer — the one place a declared env var crosses THREE
3
+ * parsers between `container.environment` and the container's own environment, each able to mangle
4
+ * a value the props held intact:
5
+ *
6
+ * 1. Quadlet's generator reads the raw line value with systemd's exec-like splitter:
7
+ * `pkg/systemd/parser/unitfile.go` `LookupAllKeyVal` calls `splitString(value,
8
+ * WhitespaceSeparators, SplitRelax | SplitUnquote | SplitCUnescape)` (podman v5.4.0). An
9
+ * unquoted space ENDS the assignment there; a quote starts/ends a quoted region wherever it
10
+ * sits; C-escapes are processed; and an unsupported escape (`\d` in a regex, say) fails the
11
+ * split — `LookupAllKeyVal` then drops EVERY assignment of that line, silently.
12
+ * 2. Quadlet re-emits each assignment as `--env KEY=value` arguments of the generated unit's
13
+ * `ExecStart=` (`podman.addEnv` → `AddCmdline` → `escapeWords`, same source), re-quoting for
14
+ * the next parser but never touching `%` or `$` — both pass through verbatim.
15
+ * 3. systemd parses that `ExecStart=` and expands BOTH syntaxes there. `%`: `%%` folds to one
16
+ * literal `%` and an unknown `%x` makes the setting invalid ("Specifiers must be known and
17
+ * resolvable for the setting to be valid", systemd.unit(5) §Specifiers). `$`
18
+ * (systemd.service(5) §Command lines, v257): the generated ExecStart has no `:` prefix, so
19
+ * variables are always substituted — `${FOO}` is "erased and replaced by the exact value of
20
+ * the environment variable", "to pass a literal dollar sign, use "$$"", and a bare `$` in
21
+ * the middle of a word stays literal (unbraced expansion applies only to a whole `$FOO`
22
+ * word, which a `KEY=value` word never is). So `pa$$word` arrives halved as `pa$word` and
23
+ * `a${HOME}b` arrives as `ab` — the variable is resolved from the UNIT's environment, not
24
+ * the one this file declares.
25
+ *
26
+ * ⛔ MEASURED 2026-10-11 (ice2/grafana-oidc.md; reproduced locally with the real podman 5.4.2
27
+ * generator): the previous renderer emitted `Environment=KEY=value` UNQUOTED, so Grafana's
28
+ * `ROLE_ATTRIBUTE_PATH=contains(groups[*], '…')` reached the container as
29
+ * `ROLE_ATTRIBUTE_PATH=contains(groups[*],` — split at the first space — plus a bogus `--env
30
+ * cn=…` variable, and every login failed `role_attribute_strict_violation`.
31
+ * ⛔ MEASURED 2026-10-12 (same generator dry run, plus a live systemd 257 unit): escaping `\`, `"`,
32
+ * `%` and `$` is what survives all three parsers. `Environment="KEY=a b"` generates `--env
33
+ * "KEY=a b"`; `Environment="D=pa$$$$word"` generates `--env D=pa$$$$word` and
34
+ * `Environment="B=a$${HOME}b"` generates `--env B=a$${HOME}b` — podman passes `$` through
35
+ * untouched — and the live unit folded them back to `pa$$word` and `a${HOME}b`, exactly the
36
+ * declared values, while the same values UNQUOTED arrived as `pa$word` and `ab`.
37
+ * ⛔ NEWLINES ARE REFUSED, NOT ENCODED — a raw newline in a key or value breaks the LINE the whole
38
+ * format is built on: quadlet reads the file line by line, so the assignment ends mid-value and
39
+ * anything after the newline is parsed as a NEW directive (attacker-controllable key injection
40
+ * when a value comes from external config). The refusal is a typed error so a caller can catch
41
+ * it by tag; `containerProblems` (container-form.ts) turns it into a problem string so one plan
42
+ * still shows every refusal at once.
43
+ */
44
+ import * as Data from 'effect/Data';
45
+
46
+ /** Same shape as container-form.ts's `UnitLine` — structural, so this module imports nothing back. */
47
+ export type EnvironmentLine = readonly [key: string, value: string];
48
+
49
+ /** The shared rule text — the typed error's message and the problem string are the same sentence. */
50
+ const environmentValueProblem = (key: string): string =>
51
+ `environment key ${JSON.stringify(key)} contains a newline; a .container file is line-based, ` +
52
+ 'so quadlet would end the assignment mid-value and parse whatever follows as a new directive. ' +
53
+ 'Nothing was written.';
54
+
55
+ /**
56
+ * ⛔ CARRIES THE KEY AND THE RULE, NEVER THE VALUE — an error is logged, retried and pasted into a
57
+ * PR, and a newline-containing value may be arbitrarily large or semi-secret; the key name (which
58
+ * `secretLikeEnvironment` already names in its own messages) is enough to locate the prop.
59
+ */
60
+ export class QuadletEnvironmentValueError extends Data.TaggedError('QuadletEnvironmentValueError')<{
61
+ readonly key: string;
62
+ }> {
63
+ override get message(): string {
64
+ return environmentValueProblem(this.key);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * `Environment="KEY=value"` — the whole assignment double-quoted, with `\`, `"`, `%` and `$`
70
+ * escaped (in that order, over the joined `KEY=value`), so the wrapping quotes this function adds
71
+ * can never be confused with quotes the value contained, every `%` reaches systemd's specifier
72
+ * expansion as the documented `%%` literal instead of an unknown `%x` that invalidates the unit,
73
+ * and every `$` reaches it as `$$` — the documented literal-dollar escape (systemd.service(5)
74
+ * §Command lines) — instead of a `${FOO}` systemd would erase and replace with a variable of ITS
75
+ * choosing, or a `$$` it would fold and halve.
76
+ */
77
+ export const quoteEnvironmentValue = (key: string, value: string): string => {
78
+ if (key.includes('\n') || value.includes('\n')) throw new QuadletEnvironmentValueError({ key });
79
+ // ⛔ THE `$` REPLACEMENT MUST BE A FUNCTION — measured 2026-10-12: `replaceAll`'s replacement
80
+ // STRING runs its own `$`-pattern mini-language (same fold as systemd: `$$` in it IS one
81
+ // literal `$`, and `$&` is the match), so a plain `'$$'` replacement silently no-ops and the
82
+ // value ships bare. A function's return value is inserted verbatim, no patterns read.
83
+ // The earlier `\`, `"` and `%` replacements keep string form: only `$` starts a pattern.
84
+ return `"${`${key}=${value}`
85
+ .replaceAll('\\', '\\\\')
86
+ .replaceAll('"', '\\"')
87
+ .replaceAll('%', '%%')
88
+ .replaceAll('$', () => '$$')}"`;
89
+ };
90
+
91
+ /**
92
+ * The sorted, quoted `Environment=` lines of a `[Container]` section — spliced by container-form.ts
93
+ * between `EnvironmentFile=` and `Volume=`, exactly where the unquoted renderer emitted them, so
94
+ * the render ORDER does not change (only the bytes of the Environment lines themselves do).
95
+ */
96
+ export const environmentLines = (
97
+ environment: Readonly<Record<string, string>>,
98
+ ): readonly EnvironmentLine[] =>
99
+ // ★ Object.entries, not keys+index: under noUncheckedIndexedAccess an indexed read is
100
+ // `string | undefined`, and the sort below reproduces the old `keys().sort()` order exactly
101
+ // (both compare UTF-16 code units), so the render order does not change.
102
+ Object.entries(environment)
103
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
104
+ .map(([key, value]): EnvironmentLine => ['Environment', quoteEnvironmentValue(key, value)]);
105
+
106
+ /**
107
+ * The newline refusal as problem STRINGS (containerProblems' currency), so one plan still shows the
108
+ * whole list instead of aborting on the first newline — the typed error above stays the contract
109
+ * for anyone rendering directly.
110
+ */
111
+ export const environmentValueProblems = (
112
+ environment: Readonly<Record<string, string>>,
113
+ ): string[] => {
114
+ const found: string[] = [];
115
+ for (const [key, value] of Object.entries(environment).sort(([a], [b]) =>
116
+ a < b ? -1 : a > b ? 1 : 0,
117
+ )) {
118
+ try {
119
+ quoteEnvironmentValue(key, value);
120
+ } catch (error) {
121
+ if (!(error instanceof QuadletEnvironmentValueError)) throw error;
122
+ found.push(environmentValueProblem(key));
123
+ }
124
+ }
125
+ return found;
126
+ };
@@ -66,7 +66,9 @@ test('renders every directive CT100’s live caddy.container carries', () => {
66
66
  'ContainerName=caddy',
67
67
  'Network=host',
68
68
  'EnvironmentFile=/etc/caddy/cf-token.env',
69
- 'Environment=CADDY_CONFIG=/etc/caddy/Caddyfile',
69
+ // ⛔ Quoted, like every Environment= value now (container-env-quote.ts) — the caddy value has
70
+ // no spaces, but uniform quoting is the rule; container-env-quote.test.ts has the story.
71
+ 'Environment="CADDY_CONFIG=/etc/caddy/Caddyfile"',
70
72
  'Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z',
71
73
  'Volume=/etc/caddy/rendered:/etc/caddy/rendered:ro,Z',
72
74
  'Volume=/etc/caddy/cf-token.env:/run/cf/token.env:ro',