@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.
- package/dist/linux/container-env-quote.d.ts +39 -0
- package/dist/linux/container-env-quote.d.ts.map +1 -0
- package/dist/linux/container-form.d.ts +14 -13
- package/dist/linux/container-form.d.ts.map +1 -1
- package/dist/linux/container-paths.d.ts +17 -0
- package/dist/linux/container-paths.d.ts.map +1 -0
- package/dist/linux/index.js +41 -8
- package/dist/linux/index.js.map +6 -4
- package/dist/unifi/index.js.map +2 -2
- package/dist/unifi/network.d.ts +2 -1
- package/dist/unifi/network.d.ts.map +1 -1
- package/docs/unifi.md +11 -10
- package/package.json +1 -1
- package/src/linux/container-env-chain.test.ts +135 -0
- package/src/linux/container-env-live.test.ts +115 -0
- package/src/linux/container-env-oracle.ts +109 -0
- package/src/linux/container-env-quadlet-oracle.ts +174 -0
- package/src/linux/container-env-quote.test.ts +131 -0
- package/src/linux/container-env-quote.ts +126 -0
- package/src/linux/container-fixture.test.ts +3 -1
- package/src/linux/container-form.ts +32 -28
- package/src/linux/container-paths.ts +21 -0
- package/src/unifi/network-scope.test.ts +21 -0
- package/src/unifi/network.ts +2 -1
- package/src/unifi/write-op-reference.test.ts +3 -2
|
@@ -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
|
-
|
|
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',
|