@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.
@@ -16,6 +16,15 @@
16
16
  * `containerProblems` below. A container that needs a secret takes `EnvironmentFile=` pointing at
17
17
  * a host path an out-of-band secret renderer (openbao-agent) maintains; this family never reads
18
18
  * or renders that file's contents.
19
+ * ⛔ `Environment=` VALUES ARE ALWAYS QUOTED, VIA container-env-quote.ts — measured 2026-10-11:
20
+ * unquoted, a spaced value splits at its first space, a quoted fragment re-parses into a bogus
21
+ * variable, and an unknown C escape drops the line's assignments silently (ice2/grafana-oidc.md;
22
+ * the live podman 5.4.2 generator reproduced all three). Quoting costs the PARSE nothing — the
23
+ * generator reads a quoted line as exactly one assignment — and it also stops systemd expanding
24
+ * `%` specifiers in values. It is NOT free at deploy: every Environment line's BYTES change, so
25
+ * the first deploy after this bump rewrites the file and RESTARTS every container declaring
26
+ * `environment` (all 13 on ct100; only grafana's values change meaning) — plan it as one window.
27
+ * Newlines are refused, never encoded (same module).
19
28
  * ⛔ `[Install]` SUPPORTS ONLY `Alias=`/`WantedBy=`/`RequiredBy=` FOR A `.container` FILE (same doc,
20
29
  * "Currently, only the Alias, WantedBy and RequiredBy keys are supported") — NOT `Also=`, which
21
30
  * plain `systemd.unit(5)` allows and unit-text.ts's `INSTALL_KEYS` therefore lists. See
@@ -23,9 +32,14 @@
23
32
  */
24
33
  import { type UnitSection, configDigest, digestOf, renderUnit } from './unit-form.ts';
25
34
  import { podmanArgsProblems, secretLikeEnvironment, secretLikeLines } from './container-secrets.ts';
35
+ import { environmentLines, environmentValueProblems } from './container-env-quote.ts';
36
+ import { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES } from './container-paths.ts';
26
37
 
27
38
  /** ★ Reused, not redefined: same digest and `restartOn`-join contract as Systemd.Unit. */
28
39
  export { configDigest, digestOf };
40
+ // ★ The search path lives in container-paths.ts (file-length cap) and is re-exported here so every
41
+ // importer and the barrel keep one import site — the API does not move.
42
+ export { QUADLET_DEFAULT_DIRECTORY, QUADLET_SEARCH_DIRECTORIES };
29
43
 
30
44
  export type UnitLine = readonly [key: string, value: string];
31
45
 
@@ -64,7 +78,8 @@ export type ContainerSection = {
64
78
  export type ContainerProps = {
65
79
  /** The Quadlet file's basename, WITHOUT `.container`. Written as `<name>.container`. */
66
80
  name: string;
67
- /** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES (below). */
81
+ /** @default QUADLET_DEFAULT_DIRECTORY. Must be one of QUADLET_SEARCH_DIRECTORIES
82
+ * (container-paths.ts, where the search path now lives). */
68
83
  directory?: string;
69
84
  /** `[Unit]` — `Description=`/`Documentation=` typed, everything else verbatim (see file header). */
70
85
  unit?: { description?: string; documentation?: string; lines?: readonly UnitLine[] };
@@ -103,25 +118,8 @@ export type ContainerAttributes = {
103
118
  };
104
119
 
105
120
  /**
106
- * Podman's OWN rootful search path, most specific first — `podman-systemd.unit(5)`, "Podman Unit
107
- * Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
108
- * following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
109
- * ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
110
- * 2026-09-24: `/etc/containers/systemd/caddy.container`.
111
- * ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
112
- * a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
113
- * unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
114
- * it were enabled. See `containerProblems`.
121
+ * ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`.
115
122
  */
116
- export const QUADLET_SEARCH_DIRECTORIES = [
117
- '/run/containers/systemd',
118
- '/etc/containers/systemd',
119
- '/usr/share/containers/systemd',
120
- ] as const;
121
-
122
- export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
123
-
124
- /** ★ A conservative subset of what Quadlet itself accepts as a unit name: no template `@`, no `/`. */
125
123
  export const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
126
124
 
127
125
  export const containerPathFor = (props: Pick<ContainerProps, 'name' | 'directory'>): string =>
@@ -149,13 +147,8 @@ const containerLines = (c: ContainerSection): UnitLine[] => [
149
147
  ...(c.exec === undefined ? [] : [['Exec', c.exec] as UnitLine]),
150
148
  ...(c.autoUpdate === undefined ? [] : [['AutoUpdate', c.autoUpdate] as UnitLine]),
151
149
  ...(c.environmentFile ?? []).map((file): UnitLine => ['EnvironmentFile', file]),
152
- // ★ Sorted by key: a stable render for a plain object whose own key order JS does not promise.
153
- ...Object.keys(c.environment ?? {})
154
- .sort()
155
- .map((key): UnitLine => [
156
- 'Environment',
157
- `${key}=${(c.environment as Record<string, string>)[key]}`,
158
- ]),
150
+ // ⛔ Always quoted, via container-env-quote.ts (see this file's header) — sorted there too.
151
+ ...environmentLines(c.environment ?? {}),
159
152
  ...(c.volume ?? []).map((volume): UnitLine => ['Volume', volume]),
160
153
  ...(c.publishPort ?? []).map((port): UnitLine => ['PublishPort', port]),
161
154
  ...(c.podmanArgs ?? []).map((arg): UnitLine => ['PodmanArgs', arg]),
@@ -225,19 +218,30 @@ export const containerProblems = (props: ContainerProps): string[] => {
225
218
  (problem) => `container.${problem}`,
226
219
  ),
227
220
  );
221
+ // ⛔ Newlines in a key or value are refused before anything renders: a .container file is read
222
+ // line by line, so a raw newline ends the assignment mid-value and whatever follows parses as a
223
+ // NEW directive (key injection when a value comes from external config). Strings here so one
224
+ // plan shows every refusal at once; the typed error stays the contract for direct renderers.
225
+ const envProblems = environmentValueProblems(props.container.environment ?? {});
226
+ found.push(...envProblems);
228
227
  // ★ Every OTHER rendered line — `Exec=`/`PodmanArgs=` and the `unit`/`service`/`install` escape
229
228
  // hatches — checked too, so a secret cannot reach the file through a field this typed check does
230
229
  // not otherwise inspect. `container.environment`'s OWN lines are excluded: `secretLikeEnvironment`
231
230
  // above already checked them, with a better error message (the actual env var name).
231
+ // ⛔ The sweep renders WITHOUT the environment (a newline value cannot throw here); the NUL
232
+ // check is skipped when the environment could not render — the refusals above are its result.
233
+ const { environment: _env, ...withoutEnvironment } = props.container;
232
234
  found.push(
233
235
  ...secretLikeLines([
234
236
  ...unitLines(props),
235
- ...containerLines(props.container).filter(([key]) => key !== 'Environment'),
237
+ // ⛔ No `Environment` filter needed: the destructure above removed the only field whose
238
+ // lines would carry it, so `containerLines` here emits none — a filter would be dead code.
239
+ ...containerLines(withoutEnvironment),
236
240
  ...serviceLines(props),
237
241
  ...installLines(props),
238
242
  ]),
239
243
  );
240
- if (renderContainerFile(props).includes('\u0000'))
244
+ if (envProblems.length === 0 && renderContainerFile(props).includes('\u0000'))
241
245
  found.push('the rendered .container file contains NUL');
242
246
  return found;
243
247
  };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Where Quadlet looks for `.container` files — split out of container-form.ts under the file-length
3
+ * cap, with the measured context that justifies every entry kept intact.
4
+ *
5
+ * Podman's OWN rootful search path, most specific first — `podman-systemd.unit(5)`, "Podman Unit
6
+ * Search Path", Podman 5.4, read 2026-09-24: "Quadlet files for the root user can be placed in the
7
+ * following directories ordered in precedence" (`/run` over `/etc` over `/usr/share`).
8
+ * ★ CT100's `caddy.container` lives in the middle one — measured live, `cat` over `ssh ct100`,
9
+ * 2026-09-24: `/etc/containers/systemd/caddy.container`.
10
+ * ⛔ A directory outside this list is invisible to Quadlet: the generator only reads these three, so
11
+ * a file elsewhere is a `.container` file with no `.service` ever generated from it — silently,
12
+ * unlike `Systemd.Unit`'s directory check, which at least writes a unit systemd could load if only
13
+ * it were enabled. See `containerProblems`.
14
+ */
15
+ export const QUADLET_SEARCH_DIRECTORIES = [
16
+ '/run/containers/systemd',
17
+ '/etc/containers/systemd',
18
+ '/usr/share/containers/systemd',
19
+ ] as const;
20
+
21
+ export const QUADLET_DEFAULT_DIRECTORY = '/etc/containers/systemd';
@@ -9,6 +9,7 @@ import * as Effect from 'effect/Effect';
9
9
  import * as Exit from 'effect/Exit';
10
10
  import { fakeFailure, fakeUnifi, fakeUnifiLayer } from './fake-unifi.ts';
11
11
  import { type NetworkProps, spec } from './network.ts';
12
+ import { IMMUTABLE_KEYS, REMOVABLE_KEYS, SETTABLE_KEYS } from './network-scope.ts';
12
13
  import { UnifiLiveDriftedSinceDeploy, UnifiUpdateDidNotConverge } from './policy.ts';
13
14
  import { unifiOperations } from './resource.ts';
14
15
 
@@ -71,6 +72,26 @@ const tagOf = (exit: Exit.Exit<unknown, unknown>) => {
71
72
  const puts = (fake: { seen: ReadonlyArray<{ method: string }> }) =>
72
73
  fake.seen.filter((s) => s.method === 'PUT');
73
74
 
75
+ // ⛔ EXACT-CONTENTS PIN. The three allowlists are the whole write scope: a widened SETTABLE_KEYS or
76
+ // REMOVABLE_KEYS would let a whole-object PUT reach a key the controller reads "off" when omitted,
77
+ // and a narrowed IMMUTABLE_KEYS would let a patch move the network (`zoneId`/`vlanId`/`management`/
78
+ // `deviceId`) or the management LAN (`default` is refused separately, not listed here). A widening
79
+ // with a key no behaviour test below names would keep them all green, so each list is pinned to
80
+ // its EXACT contents — a mutation fails this test even when nothing else does.
81
+ describe('the three key sets are pinned to their exact contents', () => {
82
+ test('SETTABLE_KEYS is exactly ipv6Configuration', () => {
83
+ expect([...SETTABLE_KEYS]).toEqual(['ipv6Configuration']);
84
+ });
85
+
86
+ test('REMOVABLE_KEYS is exactly ipv6Configuration', () => {
87
+ expect([...REMOVABLE_KEYS]).toEqual(['ipv6Configuration']);
88
+ });
89
+
90
+ test('IMMUTABLE_KEYS is exactly zoneId/vlanId/management/deviceId', () => {
91
+ expect([...IMMUTABLE_KEYS]).toEqual(['zoneId', 'vlanId', 'management', 'deviceId']);
92
+ });
93
+ });
94
+
74
95
  describe('removable-field allowlist (omission would turn the block off)', () => {
75
96
  test.each([
76
97
  ['ipv4Configuration', 'ipv4Configuration'],
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `Unifi.Network` — one network on a UniFi Network site, READ-ONLY (`policy.ts`).
2
+ * `Unifi.Network` — one network on a UniFi Network site, the ONE family with a write path
3
+ * (`policy.ts` lifts Tim's read-only rule for exactly this update; see `network-update.ts`).
3
4
  *
4
5
  * ★ WHY NETWORK IS IN THE FIRST IMPORT SET. `getNetworkDetails`/`getNetworksOverviewPage`
5
6
  * (`../../distilled-unifi-network/src/services/networks.ts`) is a list+get pair over
@@ -4,8 +4,9 @@
4
4
  * what those operations are named. `resource.ts`'s engine and `policy.ts`'s refusal never call one
5
5
  * today; nothing in the TYPE SYSTEM stops a future family file from importing, say,
6
6
  * `unifi_devices.adoptDevice` and calling it directly, bypassing `unifiOperations` altogether.
7
- * `GetOnlyHttpClient` (`resource.ts`) is the runtime backstop if one ever did; this is the static
8
- * one, so the mistake is caught at test time instead of by a wire-level defect.
7
+ * The wire guard (`wire-guard.ts`'s `guardedHttpClient`, `GetOnlyHttpClient` = empty allow
8
+ * list) is the runtime backstop if one ever did; this is the static one, so the mistake is
9
+ * caught at test time instead of by a wire-level defect.
9
10
  *
10
11
  * ⚠️ REWRITTEN (2026-09-26, red team, IMPORTANT-2). The previous version parsed import statements
11
12
  * structurally (namespace alias + `.op`, or a named import's LOCAL name) and missed: the SDK's