@homeflare/alchemy 0.50.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.
Files changed (75) hide show
  1. package/dist/linux/container-env-quote.d.ts +39 -0
  2. package/dist/linux/container-env-quote.d.ts.map +1 -0
  3. package/dist/linux/container-form.d.ts +14 -13
  4. package/dist/linux/container-form.d.ts.map +1 -1
  5. package/dist/linux/container-paths.d.ts +17 -0
  6. package/dist/linux/container-paths.d.ts.map +1 -0
  7. package/dist/linux/index.js +41 -8
  8. package/dist/linux/index.js.map +6 -4
  9. package/dist/unifi/acl-rule-ordering.d.ts +6 -1
  10. package/dist/unifi/acl-rule-ordering.d.ts.map +1 -1
  11. package/dist/unifi/acl-rule.d.ts +6 -1
  12. package/dist/unifi/acl-rule.d.ts.map +1 -1
  13. package/dist/unifi/dns-policy.d.ts +6 -1
  14. package/dist/unifi/dns-policy.d.ts.map +1 -1
  15. package/dist/unifi/fake-unifi.d.ts +4 -0
  16. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  17. package/dist/unifi/firewall-policy-ordering.d.ts +6 -1
  18. package/dist/unifi/firewall-policy-ordering.d.ts.map +1 -1
  19. package/dist/unifi/firewall-policy.d.ts +6 -1
  20. package/dist/unifi/firewall-policy.d.ts.map +1 -1
  21. package/dist/unifi/firewall-zone.d.ts +6 -1
  22. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  23. package/dist/unifi/index.d.ts +5 -3
  24. package/dist/unifi/index.d.ts.map +1 -1
  25. package/dist/unifi/index.js +339 -86
  26. package/dist/unifi/index.js.map +10 -6
  27. package/dist/unifi/network-scope.d.ts +31 -0
  28. package/dist/unifi/network-scope.d.ts.map +1 -0
  29. package/dist/unifi/network-update.d.ts +44 -0
  30. package/dist/unifi/network-update.d.ts.map +1 -0
  31. package/dist/unifi/network.d.ts +34 -13
  32. package/dist/unifi/network.d.ts.map +1 -1
  33. package/dist/unifi/policy.d.ts +89 -1
  34. package/dist/unifi/policy.d.ts.map +1 -1
  35. package/dist/unifi/resource.d.ts +17 -43
  36. package/dist/unifi/resource.d.ts.map +1 -1
  37. package/dist/unifi/traffic-matching-list.d.ts +6 -1
  38. package/dist/unifi/traffic-matching-list.d.ts.map +1 -1
  39. package/dist/unifi/update-reconcile.d.ts +31 -0
  40. package/dist/unifi/update-reconcile.d.ts.map +1 -0
  41. package/dist/unifi/wifi-broadcast.d.ts +6 -1
  42. package/dist/unifi/wifi-broadcast.d.ts.map +1 -1
  43. package/dist/unifi/wire-guard.d.ts +33 -0
  44. package/dist/unifi/wire-guard.d.ts.map +1 -0
  45. package/docs/unifi-network-write.md +90 -0
  46. package/docs/unifi.md +40 -39
  47. package/package.json +1 -1
  48. package/src/linux/container-env-chain.test.ts +135 -0
  49. package/src/linux/container-env-live.test.ts +115 -0
  50. package/src/linux/container-env-oracle.ts +109 -0
  51. package/src/linux/container-env-quadlet-oracle.ts +174 -0
  52. package/src/linux/container-env-quote.test.ts +131 -0
  53. package/src/linux/container-env-quote.ts +126 -0
  54. package/src/linux/container-fixture.test.ts +3 -1
  55. package/src/linux/container-form.ts +32 -28
  56. package/src/linux/container-paths.ts +21 -0
  57. package/src/unifi/fake-unifi.ts +15 -2
  58. package/src/unifi/handler-allow-news.test.ts +80 -0
  59. package/src/unifi/index.ts +21 -5
  60. package/src/unifi/network-scope.test.ts +224 -0
  61. package/src/unifi/network-scope.ts +75 -0
  62. package/src/unifi/network-update.test.ts +250 -0
  63. package/src/unifi/network-update.ts +95 -0
  64. package/src/unifi/network.test.ts +27 -1
  65. package/src/unifi/network.ts +58 -13
  66. package/src/unifi/policy.ts +128 -10
  67. package/src/unifi/resource.test.ts +15 -3
  68. package/src/unifi/resource.ts +64 -105
  69. package/src/unifi/update-reconcile.ts +126 -0
  70. package/src/unifi/wire-guard-anchor.test.ts +198 -0
  71. package/src/unifi/wire-guard-round2.test.ts +120 -0
  72. package/src/unifi/wire-guard.test.ts +218 -0
  73. package/src/unifi/wire-guard.ts +192 -0
  74. package/src/unifi/write-op-reference.test.ts +41 -4
  75. package/src/unifi/get-only-guard.test.ts +0 -165
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The `Environment=` quoting contract, proven against a port of the three parsers a value crosses —
3
+ * container-env-oracle.ts, whose every rule was reproduced with the LIVE podman 5.4.2 system
4
+ * generator and a live systemd 257 unit (2026-10-11/12, `QUADLET_UNIT_DIRS=<dir>
5
+ * /usr/lib/systemd/system-generators/podman-system-generator --dryrun`; artifacts in
6
+ * /tmp/opencode/quadlet-quote/) — not inferred. The port's own header cites each measurement.
7
+ *
8
+ * The TEXT-level contract of the renderer itself (what we emit) is container-env-quote.test.ts;
9
+ * this file is the parser-level proof that what we emit SURVIVES, and that what shipped before
10
+ * did not. container-env-live.test.ts re-validates the port against the real generator wherever
11
+ * one is installed.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import { environmentLines } from './container-env-quote.ts';
15
+ import { renderContainerFile } from './container-form.ts';
16
+ import { deployed } from './container-env-oracle.ts';
17
+
18
+ // ── The real values this row exists for (homeflare-ct100 commit 53b84c1, grafana.container) ──
19
+
20
+ const VAR = 'GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH';
21
+ const ROLE_PATH =
22
+ "(contains(groups[*], 'Admin') || contains(email, 'tim@schenanigans.com')) && 'GrafanaAdmin'";
23
+ const oracleLines = (value: string): string[] => [
24
+ 'Environment=SCOPES=openid',
25
+ `Environment=${VAR}=${value}`,
26
+ ];
27
+
28
+ describe('the unquoted renderer (what shipped before) corrupts values through the real chain', () => {
29
+ test('the deployed Grafana expression is truncated at its first space', () => {
30
+ const { env } = deployed(oracleLines(ROLE_PATH));
31
+ expect(env[VAR]).toBe('(contains(groups[*],');
32
+ // Everything after the first space produced no variable of its own here — it is simply gone.
33
+ expect(Object.keys(env).sort()).toEqual([VAR, 'SCOPES']);
34
+ });
35
+
36
+ test('a quoted fragment holding `=` becomes a bogus second variable (measured oracle output)', () => {
37
+ const { env } = deployed(
38
+ oracleLines("contains(groups[*], 'cn=admins,ou=people') && is_member('')"),
39
+ );
40
+ expect(env).toEqual({
41
+ [VAR]: 'contains(groups[*],',
42
+ cn: 'admins,ou=people)',
43
+ SCOPES: 'openid',
44
+ });
45
+ });
46
+
47
+ test('an unknown escape drops every assignment of its line, silently', () => {
48
+ const { env } = deployed(oracleLines('A=1 B=2\\d C=3'));
49
+ expect(env).toEqual({ SCOPES: 'openid' });
50
+ });
51
+
52
+ test('a bare % is consumed as a specifier, never literal data', () => {
53
+ const { env, consumed } = deployed(oracleLines('PCT=50%off'));
54
+ expect(consumed).toContain('%o');
55
+ // ⛔ Assert the VALUE the chain produced, not what it is not: `oracleLines` puts the text
56
+ // under VAR, so an `env.PCT` assertion compares `undefined` and can never fail.
57
+ expect(env[VAR]).toBe('PCT=50ff');
58
+ });
59
+
60
+ test('systemd halves $$: the shipped renderer lets `pa$$word` arrive as `pa$word`', () => {
61
+ // Measured 2026-10-12, live systemd 257 unit: the ExecStart word `pa$$word` (no `:` prefix,
62
+ // so variables ARE substituted) folds to `pa$word` — systemd.service(5) §Command lines.
63
+ const { env, consumed } = deployed(oracleLines('pa$$word'));
64
+ expect(env[VAR]).toBe('pa$word');
65
+ expect(consumed).toEqual([]);
66
+ });
67
+
68
+ test('systemd erases braced variables: the shipped renderer lets a braced HOME ref arrive as `ab`', () => {
69
+ // oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
70
+ const { env, consumed } = deployed(oracleLines('a${HOME}b'));
71
+ expect(env[VAR]).toBe('ab');
72
+ // oxlint-disable-next-line no-template-curly-in-string -- the consumed record is the literal reference systemd erased
73
+ expect(consumed).toEqual(['${HOME}']);
74
+ });
75
+ });
76
+
77
+ describe('the quoted renderer survives the same chain intact', () => {
78
+ test('the deployed Grafana expression round-trips exactly', () => {
79
+ const lines = environmentLines({
80
+ [VAR]: ROLE_PATH,
81
+ GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true',
82
+ }).map(([key, value]) => `${key}=${value}`);
83
+ const { env, consumed } = deployed(lines);
84
+ expect(env).toEqual({
85
+ [VAR]: ROLE_PATH,
86
+ GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true',
87
+ });
88
+ expect(consumed).toEqual([]);
89
+ });
90
+
91
+ test('escapes, quotes and percent signs each round-trip to the measured generator output', () => {
92
+ const lines = environmentLines({
93
+ BACKSLASH: 'C:\\path',
94
+ PCT: '50%off',
95
+ QUOTED: 'say "hi"',
96
+ }).map(([key, value]) => `${key}=${value}`);
97
+ const { env, consumed } = deployed(lines);
98
+ expect(env).toEqual({ BACKSLASH: 'C:\\path', PCT: '50%off', QUOTED: 'say "hi"' });
99
+ expect(consumed).toEqual([]);
100
+ });
101
+
102
+ test('dollars round-trip exactly: bcrypt hashes, braced references and $$ stay data', () => {
103
+ // The live-systemd counterparts of these three arrived corrupted under the shipped renderer
104
+ // (the two tests above); each `$` doubled to `$$` — systemd's literal-dollar escape — makes
105
+ // the fold restore exactly the declared bytes. `$2y$10$abc` is a bcrypt hash prefix: the
106
+ // common real-world `$` value this round trip must hold.
107
+ // oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
108
+ const declared = { B: 'a${HOME}b', C: '$2y$10$abc', D: 'pa$$word' };
109
+ const lines = environmentLines(declared).map(([key, value]) => `${key}=${value}`);
110
+ const { env, consumed } = deployed(lines);
111
+ expect(env).toEqual(declared);
112
+ expect(consumed).toEqual([]);
113
+ });
114
+ });
115
+
116
+ describe('renderContainerFile wires the quoting in — end to end through the chain', () => {
117
+ const props = {
118
+ name: 'grafana-primary',
119
+ container: {
120
+ environmentFile: ['/etc/grafana-primary/env'],
121
+ environment: { [VAR]: ROLE_PATH, GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_STRICT: 'true' },
122
+ image: 'docker.io/grafana/grafana:12.2.1',
123
+ network: 'host',
124
+ },
125
+ };
126
+
127
+ test('what the container would receive equals what was declared', () => {
128
+ const envLines = renderContainerFile(props)
129
+ .split('\n')
130
+ .filter((line) => line.startsWith('Environment='));
131
+ const { env, consumed } = deployed(envLines);
132
+ expect(env).toEqual(props.container.environment);
133
+ expect(consumed).toEqual([]);
134
+ });
135
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The LIVE cross-check: the oracle port (container-env-oracle.ts) against the REAL
3
+ * `podman-system-generator`, wherever one is installed — the same dry run that produced every
4
+ * measured `--env` quote in this family's headers (podman 5.4.2, `QUADLET_UNIT_DIRS=<tmpdir>
5
+ * /usr/lib/systemd/system-generators/podman-system-generator --dryrun`, 2026-10-11/12).
6
+ *
7
+ * ★ WHY A TEST AND NOT A ONE-OFF: the pinned bytes in container-env-chain.test.ts hold the
8
+ * contract between re-measurements; this test RE-MEASURES it, on every `bun test`, on any
9
+ * machine with podman's generator. A podman update that changes what the generator emits for
10
+ * these lines fails here first — and the oracle and its pins get re-measured together, not
11
+ * silently left describing an old generator.
12
+ * ⚠️ SKIPS WHERE THE GENERATOR IS ABSENT (CI, macOS): the pinned tests still hold the contract
13
+ * there; a skip is honest about what was not re-measured on that machine, rather than green
14
+ * by assumption.
15
+ * ⛔ NOTHING AT STAKE: `--dryrun` prints the generated unit to stderr and writes nothing — no
16
+ * daemon-reload, no unit start, no image pull, nothing touches a live host.
17
+ */
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { renderContainerFile } from './container-form.ts';
23
+ import { quadletEnvArgs } from './container-env-quadlet-oracle.ts';
24
+
25
+ const GENERATOR = '/usr/lib/systemd/system-generators/podman-system-generator';
26
+
27
+ /** Every character class this row measured: `$`, `%`, `"`, `\`, a space, and Grafana's own text. */
28
+ const ENVIRONMENT = {
29
+ // oxlint-disable-next-line no-template-curly-in-string -- `${HOME}` is the measured test data, not a placeholder
30
+ B: 'a${HOME}b',
31
+ BACKSLASH: 'C:\\path',
32
+ C: '$2y$10$abc',
33
+ D: 'pa$$word',
34
+ PCT: '50%off',
35
+ QUOTED: 'say "hi"',
36
+ SPACED: 'a b',
37
+ } as const;
38
+
39
+ /** The dry run's printed unit text (both streams — the generator logs to stderr). */
40
+ const generatedExecStart = async (dir: string, unitDirs: string): Promise<string> => {
41
+ const proc = Bun.spawn([GENERATOR, '--dryrun'], {
42
+ cwd: dir,
43
+ env: { ...process.env, QUADLET_UNIT_DIRS: unitDirs },
44
+ stdout: 'pipe',
45
+ stderr: 'pipe',
46
+ });
47
+ const [out, err] = await Promise.all([
48
+ new Response(proc.stdout).text(),
49
+ new Response(proc.stderr).text(),
50
+ ]);
51
+ if ((await proc.exited) !== 0) throw new Error(`generator exited non-zero:\n${out}${err}`);
52
+ const line = [...err.split('\n'), ...out.split('\n')].find((l) => l.startsWith('ExecStart='));
53
+ if (line === undefined) throw new Error(`no ExecStart= in generator output:\n${out}${err}`);
54
+ return line.slice('ExecStart='.length);
55
+ };
56
+
57
+ describe('the oracle replays against the live podman generator (when installed)', () => {
58
+ // Quadlet emits the `--env` arguments contiguously, in the .container file's key order, just
59
+ // before the trailing image word (measured, artifacts gen.service / old-gen/gen.out) — so the
60
+ // whole segment is compared at once: content AND order, byte for byte.
61
+ test.skipIf(!existsSync(GENERATOR))(
62
+ 'quadlet emits exactly the --env args the oracle models, quoted renderer',
63
+ async () => {
64
+ const image = 'docker.io/library/alpine:3';
65
+ const file = renderContainerFile({
66
+ name: 'dollar',
67
+ container: { image, environment: ENVIRONMENT },
68
+ });
69
+ const envLines = file.split('\n').filter((line) => line.startsWith('Environment='));
70
+ const dir = mkdtempSync(join(tmpdir(), 'hf-quadlet-live-'));
71
+ try {
72
+ writeFileSync(join(dir, 'dollar.container'), file);
73
+ const args = await generatedExecStart(dir, dir);
74
+ const segment = args.slice(args.indexOf('--env '), args.lastIndexOf(' '));
75
+ expect(segment).toBe(
76
+ quadletEnvArgs(envLines)
77
+ .map((arg) => `--env ${arg}`)
78
+ .join(' '),
79
+ );
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ },
84
+ );
85
+
86
+ // The shipped (unquoted) renderer, through the same live run: parser 1's split and skip must
87
+ // reproduce the corruption the chain tests pin — `TRUNC=a b c` yields one variable, not three.
88
+ test.skipIf(!existsSync(GENERATOR))(
89
+ 'quadlet emits exactly the --env args the oracle models, unquoted renderer',
90
+ async () => {
91
+ const lines = [
92
+ 'Environment=DOLLAR2=pa$$word',
93
+ 'Environment=TRUNC=a b c',
94
+ // oxlint-disable-next-line no-template-curly-in-string -- the measured old-renderer line, not a placeholder
95
+ 'Environment=DOLLARBRACE=a${HOME}b',
96
+ ];
97
+ const dir = mkdtempSync(join(tmpdir(), 'hf-quadlet-live-'));
98
+ try {
99
+ writeFileSync(
100
+ join(dir, 'old.container'),
101
+ ['[Container]', 'Image=docker.io/library/alpine:3', ...lines, ''].join('\n'),
102
+ );
103
+ const args = await generatedExecStart(dir, dir);
104
+ const segment = args.slice(args.indexOf('--env '), args.lastIndexOf(' '));
105
+ expect(segment).toBe(
106
+ quadletEnvArgs(lines)
107
+ .map((arg) => `--env ${arg}`)
108
+ .join(' '),
109
+ );
110
+ } finally {
111
+ rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ },
114
+ );
115
+ });
@@ -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
+ });