@homeflare/alchemy 0.37.6 → 0.37.8
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-form.d.ts +1 -1
- package/dist/linux/container-generator.d.ts +28 -0
- package/dist/linux/container-generator.d.ts.map +1 -1
- package/dist/linux/container-lifecycle.d.ts +18 -1
- package/dist/linux/container-lifecycle.d.ts.map +1 -1
- package/dist/linux/container-preflight.d.ts +34 -0
- package/dist/linux/container-preflight.d.ts.map +1 -1
- package/dist/linux/container-settle.d.ts.map +1 -1
- package/dist/linux/fake-sudo.d.ts +14 -2
- package/dist/linux/fake-sudo.d.ts.map +1 -1
- package/dist/linux/index.js +76 -29
- package/dist/linux/index.js.map +11 -10
- package/dist/linux/sudo-exec.d.ts +16 -0
- package/dist/linux/sudo-exec.d.ts.map +1 -1
- package/dist/linux/unit-form.d.ts.map +1 -1
- package/dist/linux/unit-text.d.ts +35 -0
- package/dist/linux/unit-text.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-config.d.ts +36 -0
- package/dist/proxmox/firewall-alias-config.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-form.d.ts +3 -1
- package/dist/proxmox/firewall-alias-form.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-lifecycle.d.ts +43 -0
- package/dist/proxmox/firewall-alias-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts +2 -8
- package/dist/proxmox/firewall-alias.d.ts.map +1 -1
- package/dist/proxmox/index.js +795 -526
- package/dist/proxmox/index.js.map +23 -10
- package/dist/proxmox/lxc-errors.d.ts +12 -0
- package/dist/proxmox/lxc-errors.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +9 -31
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/lxc-read.d.ts +4 -0
- package/dist/proxmox/lxc-read.d.ts.map +1 -0
- package/dist/proxmox/lxc-task.d.ts +15 -0
- package/dist/proxmox/lxc-task.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +7 -14
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/network-apply-run.d.ts +7 -0
- package/dist/proxmox/network-apply-run.d.ts.map +1 -0
- package/dist/proxmox/network-apply.d.ts +2 -3
- package/dist/proxmox/network-apply.d.ts.map +1 -1
- package/dist/proxmox/qemu-errors.d.ts +5 -0
- package/dist/proxmox/qemu-errors.d.ts.map +1 -0
- package/dist/proxmox/qemu-form.d.ts +22 -0
- package/dist/proxmox/qemu-form.d.ts.map +1 -0
- package/dist/proxmox/qemu-lifecycle.d.ts +26 -0
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/qemu-read.d.ts +5 -0
- package/dist/proxmox/qemu-read.d.ts.map +1 -0
- package/dist/proxmox/qemu-task.d.ts +9 -0
- package/dist/proxmox/qemu-task.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +9 -14
- package/dist/proxmox/qemu.d.ts.map +1 -1
- package/dist/proxmox/replication-firewall-fixtures.d.ts +30 -0
- package/dist/proxmox/replication-firewall-fixtures.d.ts.map +1 -0
- package/dist/proxmox/replication-job-config.d.ts +56 -0
- package/dist/proxmox/replication-job-config.d.ts.map +1 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts +62 -0
- package/dist/proxmox/replication-job-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +5 -6
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/release/archive.d.ts.map +1 -1
- package/dist/release/index.js +3 -2
- package/dist/release/index.js.map +3 -3
- package/docs/linux-sudo.md +18 -7
- package/docs/proxmox-lxc-distilled.md +56 -0
- package/docs/proxmox-replication-firewall-distilled.md +53 -0
- package/docs/proxmox-sdk-migration.md +23 -18
- package/package.json +2 -2
- package/src/linux/container-form.ts +1 -1
- package/src/linux/container-generator.test.ts +33 -0
- package/src/linux/container-generator.ts +72 -0
- package/src/linux/container-lifecycle.test.ts +6 -3
- package/src/linux/container-lifecycle.ts +23 -2
- package/src/linux/container-preflight.ts +47 -0
- package/src/linux/container-settle.ts +12 -1
- package/src/linux/container-shadow.test.ts +131 -0
- package/src/linux/fake-sudo.ts +21 -5
- package/src/linux/sudo-exec.ts +60 -10
- package/src/linux/sudo-lifecycle.test.ts +64 -3
- package/src/linux/unit-form.ts +4 -18
- package/src/linux/unit-text.test.ts +67 -0
- package/src/linux/unit-text.ts +58 -0
- package/src/proxmox/firewall-alias-config.ts +39 -0
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias-lifecycle.ts +100 -0
- package/src/proxmox/firewall-alias.ts +4 -67
- package/src/proxmox/lxc-create.test.ts +4 -1
- package/src/proxmox/lxc-distilled.test.ts +239 -0
- package/src/proxmox/lxc-errors.ts +9 -0
- package/src/proxmox/lxc-lifecycle.ts +31 -113
- package/src/proxmox/lxc-read.test.ts +29 -6
- package/src/proxmox/lxc-read.ts +88 -0
- package/src/proxmox/lxc-task.ts +71 -0
- package/src/proxmox/network-apply-distilled.test.ts +76 -0
- package/src/proxmox/network-apply-read.ts +18 -39
- package/src/proxmox/network-apply-run.ts +97 -0
- package/src/proxmox/network-apply.ts +5 -94
- package/src/proxmox/qemu-errors.ts +7 -0
- package/src/proxmox/qemu-form.ts +15 -0
- package/src/proxmox/qemu-lifecycle.test.ts +171 -0
- package/src/proxmox/qemu-lifecycle.ts +134 -0
- package/src/proxmox/qemu-read.ts +73 -0
- package/src/proxmox/qemu-task.ts +64 -0
- package/src/proxmox/qemu.ts +11 -44
- package/src/proxmox/replication-firewall-fixtures.ts +71 -0
- package/src/proxmox/replication-firewall-lifecycle.test.ts +109 -0
- package/src/proxmox/replication-firewall-wire.test.ts +173 -0
- package/src/proxmox/replication-job-config.ts +79 -0
- package/src/proxmox/replication-job-lifecycle.ts +113 -0
- package/src/proxmox/replication-job.ts +7 -84
- package/src/release/archive.ts +4 -1
|
@@ -192,4 +192,37 @@ describe('verifyGenerated, directly', () => {
|
|
|
192
192
|
}),
|
|
193
193
|
).toThrow(/earlier in Quadlet/);
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* ⛔ REGRESSION — found on adversarial review. Before the FragmentPath check was added, a real,
|
|
198
|
+
* already-loaded PLAIN unit (e.g. an admin's own `/etc/systemd/system/…` sharing this
|
|
199
|
+
* container's service name) reported `LoadState=loaded` (it is a genuinely valid unit) and no
|
|
200
|
+
* `SourcePath` (hand-written units never carry one) — both existing checks passed it silently,
|
|
201
|
+
* so `verifyGenerated` was not the backstop its own doc comments claimed: a shadow that slipped
|
|
202
|
+
* past `assertUnshadowed` (container-preflight.ts) would misapply silently, not throw loudly.
|
|
203
|
+
*/
|
|
204
|
+
test('a loaded plain unit with no SourcePath is still caught, via FragmentPath', () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
207
|
+
activeState: 'active',
|
|
208
|
+
fragmentPath: '/etc/systemd/system/hf-example.service',
|
|
209
|
+
known: true,
|
|
210
|
+
loadState: 'loaded',
|
|
211
|
+
needDaemonReload: false,
|
|
212
|
+
}),
|
|
213
|
+
).toThrow(/not from Quadlet.s generator/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a real generated FragmentPath is not mistaken for a shadow', () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
verifyGenerated(NAME, PATH, SERVICE, {
|
|
219
|
+
activeState: 'active',
|
|
220
|
+
fragmentPath: `/run/systemd/generator/${SERVICE}`,
|
|
221
|
+
known: true,
|
|
222
|
+
loadState: 'loaded',
|
|
223
|
+
needDaemonReload: false,
|
|
224
|
+
sourcePath: PATH,
|
|
225
|
+
}),
|
|
226
|
+
).not.toThrow();
|
|
227
|
+
});
|
|
195
228
|
});
|
|
@@ -55,6 +55,56 @@ import type { UnitStatus } from './systemctl.ts';
|
|
|
55
55
|
/** MEASURED on CT100 2026-09-24: `systemctl show caddy.service -p FragmentPath`. */
|
|
56
56
|
export const GENERATED_UNIT_DIRECTORY = '/run/systemd/generator';
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Whether `fragmentPath` is Quadlet's OWN generator output — the "did daemon-reload actually
|
|
60
|
+
* produce OUR unit" question `verifyGenerated` needs, as distinct from `isShadowingFragment`
|
|
61
|
+
* below (the "does something ELSE outrank the generator" question `assertUnshadowed` needs — the
|
|
62
|
+
* two are not complements of each other, see that function's header).
|
|
63
|
+
*/
|
|
64
|
+
export const isGeneratorFragment = (fragmentPath: string | undefined): boolean =>
|
|
65
|
+
fragmentPath !== undefined && fragmentPath.startsWith(`${GENERATED_UNIT_DIRECTORY}/`);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ⛔ CORRECTED ON ADVERSARIAL RE-REVIEW OF THE FIX BELOW — the directories that ACTUALLY search
|
|
69
|
+
* BEFORE `${GENERATED_UNIT_DIRECTORY}` in systemd's real Unit File Load Path (`systemd.unit(5)`,
|
|
70
|
+
* tag v257, "Unit File Load Path"), highest to lowest precedence:
|
|
71
|
+
* /etc/systemd/system.control, /run/systemd/system.control, /run/systemd/transient,
|
|
72
|
+
* /etc/systemd/system, /etc/systemd/system.attached, /run/systemd/system,
|
|
73
|
+
* /run/systemd/system.attached, [${GENERATED_UNIT_DIRECTORY}], /usr/local/lib/systemd/system,
|
|
74
|
+
* /usr/lib/systemd/system, /run/systemd/generator.late.
|
|
75
|
+
* ⛔ `/usr/local/lib/systemd/system` AND `/usr/lib/systemd/system` ARE DELIBERATELY ABSENT — the
|
|
76
|
+
* original version of this check (`isGeneratorFragment`'s NEGATION: "not the generator's own
|
|
77
|
+
* output") treated ANY other FragmentPath as shadowing, including these two vendor directories,
|
|
78
|
+
* which are LOWER precedence than the generator, not higher. A plain unit there is harmlessly
|
|
79
|
+
* shadowed BY Quadlet's generated unit, same as `unit-form.ts`'s `UNIT_SEARCH_DIRECTORIES`
|
|
80
|
+
* (measured via `systemd-analyze unit-paths`) independently lists them below `/run/systemd/system`,
|
|
81
|
+
* and `systemctl.ts`'s own header distinguishes `/etc/systemd/system` from "the vendor's
|
|
82
|
+
* `/usr/lib/systemd/system`" rather than lumping the two together. Refusing for either one blocked
|
|
83
|
+
* a create/adopt apply would have handled fine — exactly the false positive S49 ("neither looser
|
|
84
|
+
* nor stricter") forbids. `/etc/systemd/system.control`, `.attached` and `/run/systemd/transient`
|
|
85
|
+
* are included for completeness against the man page though no family here writes into them.
|
|
86
|
+
*/
|
|
87
|
+
const SHADOWING_UNIT_DIRECTORIES = [
|
|
88
|
+
'/etc/systemd/system.control',
|
|
89
|
+
'/run/systemd/system.control',
|
|
90
|
+
'/run/systemd/transient',
|
|
91
|
+
'/etc/systemd/system',
|
|
92
|
+
'/etc/systemd/system.attached',
|
|
93
|
+
'/run/systemd/system',
|
|
94
|
+
'/run/systemd/system.attached',
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether `fragmentPath` genuinely OUTRANKS Quadlet's generator — the real question
|
|
99
|
+
* `container-preflight.ts`'s `assertUnshadowed` needs ("would writing our `.container` file and
|
|
100
|
+
* reloading still lose to this other unit?"), as opposed to the broader and wrong question the
|
|
101
|
+
* original check asked ("is this simply not the generator's own output?", which is also true of
|
|
102
|
+
* every LOWER-precedence vendor unit and wrongly refused those too).
|
|
103
|
+
*/
|
|
104
|
+
export const isShadowingFragment = (fragmentPath: string | undefined): boolean =>
|
|
105
|
+
fragmentPath !== undefined &&
|
|
106
|
+
SHADOWING_UNIT_DIRECTORIES.some((dir) => fragmentPath.startsWith(`${dir}/`));
|
|
107
|
+
|
|
58
108
|
export class QuadletGeneratorError extends Error {
|
|
59
109
|
constructor(name: string, detail: string) {
|
|
60
110
|
super(`Podman.Container ${name}: ${detail}`);
|
|
@@ -85,6 +135,19 @@ export const needsVerificationReload = (containerPath: string, status: UnitStatu
|
|
|
85
135
|
* resource is simply absent", which is what a plain `LoadState=not-found` would otherwise look
|
|
86
136
|
* like to a caller that only knows the `Systemd.Unit` shape (a unit that has genuinely never been
|
|
87
137
|
* declared also reads `not-found`).
|
|
138
|
+
*
|
|
139
|
+
* ⛔ THE FRAGMENTPATH CHECK BELOW WAS ADDED — found on adversarial review of `assertUnshadowed`
|
|
140
|
+
* (container-preflight.ts): before it, this function trusted `LoadState`/`SourcePath` alone, and
|
|
141
|
+
* neither one notices a genuinely-shadowing PLAIN unit. A real, already-loaded plain unit (e.g.
|
|
142
|
+
* an admin's own `/etc/systemd/system/foo.service` sharing this container's service name) reports
|
|
143
|
+
* `LoadState=loaded` — it is a valid unit — and `SourcePath` empty, since hand-written units never
|
|
144
|
+
* carry one; both checks above pass it silently. MEASURED directly against this function: a
|
|
145
|
+
* `status` built from exactly what `systemctl show` reports for that case (`loadState: 'loaded'`,
|
|
146
|
+
* `fragmentPath` under `/etc/systemd/system`, `sourcePath: undefined`) did not throw before this
|
|
147
|
+
* check existed. Without it, `assertUnshadowed`'s plan-time refusal was the ONLY thing standing
|
|
148
|
+
* between a shadowing plain unit and a SILENT apply-time "success" that leaves the pre-existing
|
|
149
|
+
* unit running untouched while state records attributes read back from it — not a loud mid-apply
|
|
150
|
+
* throw, which is what earlier documentation of this fix claimed the pre-fix risk was.
|
|
88
151
|
*/
|
|
89
152
|
export const verifyGenerated = (
|
|
90
153
|
name: string,
|
|
@@ -102,6 +165,15 @@ export const verifyGenerated = (
|
|
|
102
165
|
`'systemd-analyze --generators=true verify ${serviceName}' (podman-systemd.unit(5), Podman 5.4).`,
|
|
103
166
|
);
|
|
104
167
|
}
|
|
168
|
+
if (status.fragmentPath !== undefined && !isGeneratorFragment(status.fragmentPath)) {
|
|
169
|
+
throw new QuadletGeneratorError(
|
|
170
|
+
name,
|
|
171
|
+
`${serviceName} is loaded from ${status.fragmentPath}, not from Quadlet’s generator ` +
|
|
172
|
+
`(${GENERATED_UNIT_DIRECTORY}/…) — daemon-reload ran, but a plain unit at that path still ` +
|
|
173
|
+
'answers to this name, and systemd loaded IT, not ours. Move the plain unit aside — a ' +
|
|
174
|
+
'cutover — and redeploy.',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
105
177
|
if (status.sourcePath !== undefined && status.sourcePath !== containerPath) {
|
|
106
178
|
throw new QuadletGeneratorError(
|
|
107
179
|
name,
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* like it, cares most about the NEGATIVE assertions: an adopted container that already matches is
|
|
4
4
|
* not restarted, a re-run restarts nothing, and a read that fails writes nothing. Generator-failure
|
|
5
5
|
* rollback and the never-enable guarantee are in container-generator.test.ts; secret refusal is in
|
|
6
|
-
* container-secrets.test.ts; the CT100 fixture is in container-fixture.test.ts
|
|
6
|
+
* container-secrets.test.ts; the CT100 fixture is in container-fixture.test.ts; a plain unit
|
|
7
|
+
* shadowing the service name (`assertUnshadowed`) is in container-shadow.test.ts.
|
|
7
8
|
*/
|
|
8
9
|
import { describe, expect, test } from 'bun:test';
|
|
9
10
|
import { fakeQuadletHost } from './fake-quadlet.ts';
|
|
@@ -52,7 +53,7 @@ describe('create', () => {
|
|
|
52
53
|
expect(fake.files.get(PATH)?.uid).toBe(0);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
test('a start that fails removes the file this deploy wrote and
|
|
56
|
+
test('a start that fails removes the file this deploy wrote and reports the read-back state', async () => {
|
|
56
57
|
const fake = host();
|
|
57
58
|
const broken = {
|
|
58
59
|
...fake.runner,
|
|
@@ -61,8 +62,10 @@ describe('create', () => {
|
|
|
61
62
|
? { exitCode: 1, stderr: 'Job failed', stdout: '' }
|
|
62
63
|
: fake.runner.exec(argv),
|
|
63
64
|
};
|
|
65
|
+
// ★ Not a hardcoded "NOT running" claim (measured false for a refusal-before-sudo failure,
|
|
66
|
+
// container-settle.ts's header) — a genuine read-back of what systemd now reports.
|
|
64
67
|
await expect(reconcileContainer(broken, props('example/one:1'), undefined)).rejects.toThrow(
|
|
65
|
-
/
|
|
68
|
+
/unit is now inactive/,
|
|
66
69
|
);
|
|
67
70
|
expect(fake.files.has(PATH)).toBe(false);
|
|
68
71
|
});
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import {
|
|
37
37
|
assertMayWrite,
|
|
38
38
|
assertReplaceable,
|
|
39
|
+
assertUnshadowed,
|
|
39
40
|
assertUsable,
|
|
40
41
|
assertValid,
|
|
41
42
|
} from './container-preflight.ts';
|
|
@@ -44,7 +45,24 @@ import { attributesOf, settle } from './container-settle.ts';
|
|
|
44
45
|
const encoder = new TextEncoder();
|
|
45
46
|
const decoder = new TextDecoder();
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* What is on the host now: the `.container` file's digest and the GENERATED unit's status.
|
|
50
|
+
*
|
|
51
|
+
* ⛔ A FILE-LESS `known` UNIT IS NOT NECESSARILY OURS — found on adversarial review. The OLD test
|
|
52
|
+
* here was `bytes === undefined && !status.known`: absent file, unknown unit → nothing. But a
|
|
53
|
+
* PLAIN unit under `/etc/systemd/system` with the same service name also makes `status.known`
|
|
54
|
+
* true, with no `.container` file behind it at all — the read then reported a container that has
|
|
55
|
+
* never been declared as "exists", and a plan built on that said "adopted" for something a create
|
|
56
|
+
* would later fail to make real (`assertUnshadowed`, container-preflight.ts, has the measured
|
|
57
|
+
* precedence fact and the plan-time refusal). ⛔ `/usr/lib/systemd/system` is DELIBERATELY NOT
|
|
58
|
+
* named alongside `/etc/systemd/system` here — corrected on adversarial re-review: it is LOWER
|
|
59
|
+
* precedence than Quadlet's generator, not higher (`isShadowingFragment`, container-generator.ts),
|
|
60
|
+
* so a plain unit there is exists-but-not-a-shadow: `readContainer` reports it as adopted, same as
|
|
61
|
+
* before, and a create/update writes our `.container` file, which then wins for real. A REAL
|
|
62
|
+
* Quadlet generation (this or an earlier apply's own file, later removed) reads no differently
|
|
63
|
+
* here than before: its `FragmentPath` sits under Quadlet's own generator directory, which
|
|
64
|
+
* `assertUnshadowed` never refuses.
|
|
65
|
+
*/
|
|
48
66
|
export const readContainer = async (
|
|
49
67
|
runner: HostRunner,
|
|
50
68
|
props: ContainerProps,
|
|
@@ -52,7 +70,10 @@ export const readContainer = async (
|
|
|
52
70
|
const path = containerPathFor(props);
|
|
53
71
|
const bytes = await runner.readFile(path);
|
|
54
72
|
const status = await showUnit(runner, serviceNameFor(props));
|
|
55
|
-
if (bytes === undefined
|
|
73
|
+
if (bytes === undefined) {
|
|
74
|
+
if (!status.known) return undefined;
|
|
75
|
+
assertUnshadowed(props, status);
|
|
76
|
+
}
|
|
56
77
|
const sha = bytes === undefined ? '' : digestOf(decoder.decode(bytes));
|
|
57
78
|
return attributesOf(
|
|
58
79
|
props,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { type HostRunner, canActAsRoot } from '../launchd/runner.ts';
|
|
10
10
|
import { UNIT_WRITE, digestOf } from './unit-form.ts';
|
|
11
11
|
import { type UnitStatus, showUnit } from './systemctl.ts';
|
|
12
|
+
import { GENERATED_UNIT_DIRECTORY, isShadowingFragment } from './container-generator.ts';
|
|
12
13
|
import {
|
|
13
14
|
type ContainerAttributes,
|
|
14
15
|
type ContainerProps,
|
|
@@ -73,6 +74,52 @@ export const assertUnclaimed = async (runner: HostRunner, props: ContainerProps)
|
|
|
73
74
|
}
|
|
74
75
|
};
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* ⛔ A PLAIN UNIT SHARING THE SERVICE NAME CAN OUTRANK QUADLET'S GENERATOR, SILENTLY — but only
|
|
79
|
+
* when it sits in one of the directories that actually search BEFORE `${GENERATED_UNIT_DIRECTORY}`
|
|
80
|
+
* in systemd's own unit load path (systemd.unit(5) "Unit File Load Path") — `/etc/systemd/system`
|
|
81
|
+
* chief among them (`isShadowingFragment`, container-generator.ts, has the full list and the
|
|
82
|
+
* measured facts). ⛔ CORRECTED ON ADVERSARIAL RE-REVIEW: an EARLIER version of this check refused
|
|
83
|
+
* for ANY `FragmentPath` not literally under the generator directory, including the vendor
|
|
84
|
+
* directories (`/usr/local/lib/systemd/system`, `/usr/lib/systemd/system`) — those are LOWER
|
|
85
|
+
* precedence than the generator, not higher, so a plain unit there is harmlessly shadowed BY our
|
|
86
|
+
* generated unit once we write and reload; refusing for it was a false positive that blocked a
|
|
87
|
+
* create/adopt apply would have handled fine (exactly what S49 "neither looser nor stricter"
|
|
88
|
+
* forbids). With no `.container` file of ours on disk yet, a `FragmentPath` under one of the
|
|
89
|
+
* genuinely higher-precedence directories means some OTHER unit file — hand-written, or shipped by
|
|
90
|
+
* a package, and admin-controlled rather than vendor-controlled — already answers to this name:
|
|
91
|
+
* writing and reloading would generate a unit systemd never actually loads, because the
|
|
92
|
+
* higher-precedence plain one still wins.
|
|
93
|
+
* ★ TWIN OF `unit-preflight.ts`'s `assertUnclaimed`, for the one failure mode `Systemd.Unit` can't
|
|
94
|
+
* have: there is no generator standing between ITS file and the unit systemd loads, so nothing
|
|
95
|
+
* there can be shadowed the way a Quadlet generation can.
|
|
96
|
+
* ★ ONLY WHEN THE `.container` FILE ITSELF IS ABSENT (called from `readContainer` in that branch):
|
|
97
|
+
* a present file with different content is `assertUnclaimed`'s job; a present file that IS ours
|
|
98
|
+
* generating a shadowed unit is `verifyGenerated`'s job (container-generator.ts, hardened to check
|
|
99
|
+
* this same `FragmentPath` fact), at apply time, once daemon-reload has actually run — this is the
|
|
100
|
+
* "nothing declared yet" half neither of those covers, and the one this bug leaves refusing only
|
|
101
|
+
* mid-apply instead of at plan time for the directories that genuinely shadow.
|
|
102
|
+
* ⚠️ NEVER FIRES WITHOUT A CONCRETE `fragmentPath` TO NAME, AND NEVER FOR A MASKED UNIT — MEASURED
|
|
103
|
+
* against this family's own fake (`fakeQuadletHost`, unlike `sudo-lifecycle.test.ts`'s, which is
|
|
104
|
+
* a different fake and reads differently): `placeUnit(path, text, {masked: true})` still reports
|
|
105
|
+
* a real `FragmentPath`. Refusing there anyway would work, but with THIS function's generic
|
|
106
|
+
* "plain unit" wording instead of `assertUsable`'s specific, more actionable "is masked, run
|
|
107
|
+
* `systemctl unmask` deliberately" — masking is a person's decision, not an accident to explain
|
|
108
|
+
* as a name collision. `assertUsable` stays the one place that message comes from.
|
|
109
|
+
*/
|
|
110
|
+
export const assertUnshadowed = (props: Pick<ContainerProps, 'name'>, status: UnitStatus): void => {
|
|
111
|
+
if (!status.known || status.fragmentPath === undefined) return;
|
|
112
|
+
if (status.loadState === 'masked' || status.unitFileState === 'masked') return;
|
|
113
|
+
if (!isShadowingFragment(status.fragmentPath)) return;
|
|
114
|
+
throw refuseContainer(
|
|
115
|
+
props.name,
|
|
116
|
+
`${serviceNameFor(props)} already exists as a plain unit at ${status.fragmentPath}, not a ` +
|
|
117
|
+
`Quadlet generation — Quadlet's own output (${GENERATED_UNIT_DIRECTORY}/…) would be ` +
|
|
118
|
+
'shadowed by it and never actually run: systemd loads the higher-precedence file first. ' +
|
|
119
|
+
'Move the plain unit aside — a cutover — before declaring this container.',
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
76
123
|
/** The half of a rename check that needs only the NEW NAME — mirrors `assertRenameTarget`. */
|
|
77
124
|
export const assertRenameTarget = async (
|
|
78
125
|
runner: HostRunner,
|
|
@@ -79,7 +79,18 @@ export const settle = async (
|
|
|
79
79
|
await runner.removeFile(step.path).catch(() => undefined);
|
|
80
80
|
await daemonReload(runner).catch(() => undefined);
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
// 🔴 MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: this suffix used to assert "is NOT
|
|
83
|
+
// running" unconditionally — even for a `detail` that was a REFUSAL before sudo ever ran
|
|
84
|
+
// (the sudo ownership check on a Quadlet unit, say), where nothing about the running
|
|
85
|
+
// container was touched. `caddy` stayed active/running through the whole failed deploy.
|
|
86
|
+
// State only what this call actually knows: read the live state back, best-effort, rather
|
|
87
|
+
// than assert either way.
|
|
88
|
+
const after = await showUnit(runner, step.serviceName).catch(() => undefined);
|
|
89
|
+
const stateNote =
|
|
90
|
+
after === undefined
|
|
91
|
+
? 'Its running state was not re-checked.'
|
|
92
|
+
: `Its unit is now ${after.activeState}.`;
|
|
93
|
+
throw refuseContainer(props.name, `${detail} ${stateNote}`);
|
|
83
94
|
};
|
|
84
95
|
try {
|
|
85
96
|
if (!wantStarted) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Podman.Container`'s read/plan behaviour when a PLAIN unit — never Quadlet's own generator
|
|
3
|
+
* output — already answers to the service name this resource would generate. Split from
|
|
4
|
+
* container-lifecycle.test.ts (see its header) because this is its own, self-contained scenario:
|
|
5
|
+
* container-preflight.ts's `assertUnshadowed`, called from `readContainer`.
|
|
6
|
+
*
|
|
7
|
+
* ★ WHY `readContainer` IS THE RIGHT LEVEL TO TEST. Alchemy's own cold-start adoption probe calls
|
|
8
|
+
* `provider.read` directly to decide "create" vs "adopted" BEFORE `diff` ever runs (alchemy
|
|
9
|
+
* beta.79 `Plan.ts` — no state row exists yet, so there is nothing to diff against). Our
|
|
10
|
+
* provider's `read` (`readHandler`, container-handlers.ts) is a thin wrapper over
|
|
11
|
+
* `readContainer`, so a refusal here IS the plan refusing, not merely a lower layer.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { fakeQuadletHost } from './fake-quadlet.ts';
|
|
15
|
+
import type { ContainerProps } from './container-form.ts';
|
|
16
|
+
import { readContainer, reconcileContainer } from './container-lifecycle.ts';
|
|
17
|
+
|
|
18
|
+
const NAME = 'hf-example';
|
|
19
|
+
const PATH = `/etc/containers/systemd/${NAME}.container`;
|
|
20
|
+
const SERVICE = `${NAME}.service`;
|
|
21
|
+
const PLAIN_UNIT_PATH = `/etc/systemd/system/${SERVICE}`;
|
|
22
|
+
|
|
23
|
+
const props: ContainerProps = {
|
|
24
|
+
container: { image: 'example/one:1', network: 'host' },
|
|
25
|
+
install: { wantedBy: ['multi-user.target'] },
|
|
26
|
+
name: NAME,
|
|
27
|
+
service: { restart: 'always', restartSec: '5s' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A unit systemd loaded from somewhere OTHER than Quadlet's own generator — e.g. a PLAIN unit
|
|
31
|
+
* dropped in `/etc/systemd/system`, the case `assertUnshadowed` exists for. */
|
|
32
|
+
const placePlainUnit = (fake: ReturnType<typeof fakeQuadletHost>) =>
|
|
33
|
+
fake.placeUnit(PLAIN_UNIT_PATH, '[Service]\nExecStart=/usr/bin/true\n', { active: true });
|
|
34
|
+
|
|
35
|
+
describe('a plain unit already answers to the name, and our .container file is absent', () => {
|
|
36
|
+
test('the read refuses instead of reporting the container exists', async () => {
|
|
37
|
+
const fake = fakeQuadletHost();
|
|
38
|
+
placePlainUnit(fake);
|
|
39
|
+
await expect(readContainer(fake.runner, props)).rejects.toThrow(PLAIN_UNIT_PATH);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the refusal names Podman.Container, the unit and the fix — not a generic crash', async () => {
|
|
43
|
+
const fake = fakeQuadletHost();
|
|
44
|
+
placePlainUnit(fake);
|
|
45
|
+
await expect(readContainer(fake.runner, props)).rejects.toThrow(
|
|
46
|
+
/Podman\.Container hf-example:.*hf-example\.service.*plain unit.*move the plain unit aside/is,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('nothing was written before the refusal — a plan promises this, it never risks it', async () => {
|
|
51
|
+
const fake = fakeQuadletHost();
|
|
52
|
+
placePlainUnit(fake);
|
|
53
|
+
await expect(readContainer(fake.runner, props)).rejects.toThrow();
|
|
54
|
+
expect(fake.files.has(PATH)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('a masked unit keeps its own, more specific refusal', () => {
|
|
59
|
+
test('the read does not report it as a shadowing plain unit', async () => {
|
|
60
|
+
const fake = fakeQuadletHost();
|
|
61
|
+
fake.placeUnit(PLAIN_UNIT_PATH, '[Service]\nExecStart=/usr/bin/true\n', {
|
|
62
|
+
active: false,
|
|
63
|
+
masked: true,
|
|
64
|
+
});
|
|
65
|
+
// Masked still has a real FragmentPath on this fake (measured directly — see
|
|
66
|
+
// container-preflight.ts's `assertUnshadowed`), so this proves the exemption, not an absence.
|
|
67
|
+
const attrs = await readContainer(fake.runner, props);
|
|
68
|
+
expect(attrs).toMatchObject({ loadState: 'masked', name: NAME });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('adopting it still refuses, with the masked message, not the shadow one', async () => {
|
|
72
|
+
const fake = fakeQuadletHost();
|
|
73
|
+
fake.placeUnit(PLAIN_UNIT_PATH, '[Service]\nExecStart=/usr/bin/true\n', {
|
|
74
|
+
active: false,
|
|
75
|
+
masked: true,
|
|
76
|
+
});
|
|
77
|
+
await expect(reconcileContainer(fake.runner, props, undefined, true)).rejects.toThrow(
|
|
78
|
+
/is masked/,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('a real Quadlet generation is unaffected', () => {
|
|
84
|
+
test('FragmentPath under the generator directory still reads as existing', async () => {
|
|
85
|
+
const fake = fakeQuadletHost();
|
|
86
|
+
await reconcileContainer(fake.runner, props, undefined);
|
|
87
|
+
// The source file is gone but the LAST generation is still on disk, unreloaded — exactly what
|
|
88
|
+
// a manual `rm` of the `.container` file leaves behind before the next `daemon-reload`.
|
|
89
|
+
fake.files.delete(PATH);
|
|
90
|
+
const attrs = await readContainer(fake.runner, props);
|
|
91
|
+
expect(attrs).toMatchObject({ containerPath: PATH, name: NAME, serviceName: SERVICE });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('neither a file nor a unit exists', () => {
|
|
96
|
+
test('read answers undefined, so a plan says create — never a refusal', async () => {
|
|
97
|
+
const fake = fakeQuadletHost();
|
|
98
|
+
expect(await readContainer(fake.runner, props)).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* ⛔ REGRESSION FOR A FALSE POSITIVE — found on adversarial review. An earlier version of
|
|
104
|
+
* `assertUnshadowed` refused for ANY `FragmentPath` not literally under the generator directory,
|
|
105
|
+
* including a vendor unit at `/usr/lib/systemd/system` — which is LOWER precedence than
|
|
106
|
+
* Quadlet's generator (`isShadowingFragment`, container-generator.ts, has the measured order), so
|
|
107
|
+
* writing and reloading our `.container` file would actually win the name, same as any other
|
|
108
|
+
* cutover from a package-installed daemon to a Podman container. Refusing there blocked exactly
|
|
109
|
+
* the create/adopt an apply would have handled fine.
|
|
110
|
+
*/
|
|
111
|
+
describe('a plain unit at a directory LOWER precedence than the generator is not a shadow', () => {
|
|
112
|
+
test('a vendor unit at /usr/lib/systemd/system does not refuse — it reads as adopted', async () => {
|
|
113
|
+
const fake = fakeQuadletHost();
|
|
114
|
+
fake.placeUnit(`/usr/lib/systemd/system/${SERVICE}`, '[Service]\nExecStart=/usr/bin/true\n', {
|
|
115
|
+
active: true,
|
|
116
|
+
});
|
|
117
|
+
const attrs = await readContainer(fake.runner, props);
|
|
118
|
+
expect(attrs).toMatchObject({ name: NAME, serviceName: SERVICE });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a vendor unit at /usr/local/lib/systemd/system does not refuse either', async () => {
|
|
122
|
+
const fake = fakeQuadletHost();
|
|
123
|
+
fake.placeUnit(
|
|
124
|
+
`/usr/local/lib/systemd/system/${SERVICE}`,
|
|
125
|
+
'[Service]\nExecStart=/usr/bin/true\n',
|
|
126
|
+
{ active: true },
|
|
127
|
+
);
|
|
128
|
+
const attrs = await readContainer(fake.runner, props);
|
|
129
|
+
expect(attrs).toMatchObject({ name: NAME, serviceName: SERVICE });
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/linux/fake-sudo.ts
CHANGED
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
* `mktemp -d` registers a fresh 0700 directory the operator owns.
|
|
13
13
|
* ★ SUDO REFUSES A CALL THAT WAS NOT LOGGED FIRST, so every test holds the runner to "logged
|
|
14
14
|
* before it runs", exactly as the Mac fake does.
|
|
15
|
+
* ★ `hostFactory`/`prefixes` (below) let sudo-lifecycle.test.ts drive `fakeQuadletHost` (not just
|
|
16
|
+
* the plain `fakeLinuxHost`) through this SAME elevation wiring, for the Podman.Container-through
|
|
17
|
+
* -sshSudoRunner regression tests — `fakeQuadletHost` returns the exact shape `fakeLinuxHost`
|
|
18
|
+
* does (`FakeLinuxOptions` in, the same fields out), so every handler below that reads
|
|
19
|
+
* `fake.dirs`/`fake.modes`/`fake.files` or calls `fake.runner.exec` works unchanged either way.
|
|
15
20
|
*/
|
|
16
21
|
import type { ExecResult, FileStat, HostRunner } from '../launchd/runner.ts';
|
|
17
22
|
import { SUDO_SAYS } from '../launchd/fake-sudo.ts';
|
|
18
|
-
import { fakeLinuxHost } from './fake-linux-host.ts';
|
|
23
|
+
import { type FakeLinuxOptions, fakeLinuxHost } from './fake-linux-host.ts';
|
|
19
24
|
import { type RootState, asRoot } from './fake-sudo-root.ts';
|
|
20
25
|
import { SUDO } from './sudo-allowlist.ts';
|
|
21
26
|
import { makeSshSudoRunner } from './sudo-runner.ts';
|
|
@@ -49,17 +54,28 @@ const lsLine = (path: string, stat: FileStat, acl: boolean): string => {
|
|
|
49
54
|
);
|
|
50
55
|
};
|
|
51
56
|
|
|
57
|
+
export type FakeSudoHostOptions = {
|
|
58
|
+
/** Swap `fakeLinuxHost` for a variant that models more than plain units — `fakeQuadletHost`. */
|
|
59
|
+
readonly hostFactory?: (options: FakeLinuxOptions) => ReturnType<typeof fakeLinuxHost>;
|
|
60
|
+
/** The runner's own declared prefixes. @default PREFIXES */
|
|
61
|
+
readonly prefixes?: readonly string[];
|
|
62
|
+
};
|
|
63
|
+
|
|
52
64
|
/**
|
|
53
65
|
* `extraDirs` adds more directories to the fake filesystem (a pre-existing tree outside every
|
|
54
|
-
* prefix, say); it never changes the runner's OWN prefixes, which
|
|
66
|
+
* prefix, say); it never changes the runner's OWN prefixes, which default to `PREFIXES`.
|
|
55
67
|
* `acl` is the set of directory paths a test wants to carry the `+` (POSIX ACL) flag.
|
|
56
68
|
*/
|
|
57
69
|
export const fakeSudoHost = (
|
|
58
70
|
extraDirs: Record<string, number> = {},
|
|
59
71
|
acl: ReadonlySet<string> = new Set(),
|
|
72
|
+
{ hostFactory = fakeLinuxHost, prefixes = PREFIXES }: FakeSudoHostOptions = {},
|
|
60
73
|
) => {
|
|
61
|
-
const
|
|
62
|
-
|
|
74
|
+
const dirs: Record<string, number> = {};
|
|
75
|
+
for (const prefix of prefixes) dirs[prefix] = 0;
|
|
76
|
+
for (const [path, owner] of Object.entries(extraDirs)) dirs[path] = owner;
|
|
77
|
+
const fake = hostFactory({
|
|
78
|
+
dirs,
|
|
63
79
|
euid: OPERATOR,
|
|
64
80
|
users: { app: { gid: 60, home: '/opt/app', uid: 900 } },
|
|
65
81
|
});
|
|
@@ -127,7 +143,7 @@ export const fakeSudoHost = (
|
|
|
127
143
|
},
|
|
128
144
|
};
|
|
129
145
|
|
|
130
|
-
const runner = makeSshSudoRunner(
|
|
146
|
+
const runner = makeSshSudoRunner(prefixes, {
|
|
131
147
|
base,
|
|
132
148
|
log: (line) => logs.push(line),
|
|
133
149
|
// ★ The real stager, unmodified: it only ever calls `base.exec`/`base.writeFileAtomic`, both
|
package/src/linux/sudo-exec.ts
CHANGED
|
@@ -23,13 +23,44 @@
|
|
|
23
23
|
* is a valid type in `unit-form.ts`'s `TYPES` set) would also have elevated. Grepped: no code
|
|
24
24
|
* in this package ever calls `enable`/`start`/`restart` on a unit it has not itself just
|
|
25
25
|
* written a file for, so narrowing the exemption to `stop`/`disable` costs nothing legitimate.
|
|
26
|
+
* 🔴 MEASURED, CT100 deploy 2026-09-26 00:11Z: a THIRD gap, this one refusing a call that should
|
|
27
|
+
* have been allowed. `Podman.Container caddy`'s generated `caddy.service` has
|
|
28
|
+
* `FragmentPath=/run/systemd/generator/caddy.service` — Quadlet's generator output, never a
|
|
29
|
+
* path this runner would ever declare as a prefix (nothing writes there directly) — so `restart`
|
|
30
|
+
* was refused outright, even though the `.container` FILE THAT GENERATED IT lives at
|
|
31
|
+
* `/etc/containers/systemd/caddy.container`, squarely under this runner's own
|
|
32
|
+
* `/etc/containers/systemd` prefix, and this SAME runner had just written it. `SourcePath` is
|
|
33
|
+
* the generator's own record of that file (systemctl.ts's header, MEASURED on CT100). So a
|
|
34
|
+
* generated unit is now also reachable when its `FragmentPath` is the generator's OWN output
|
|
35
|
+
* directory (`GENERATOR_DIRECTORIES` below) AND its `SourcePath` is under a declared prefix —
|
|
36
|
+
* the thing this runner actually owns is the `.container` file, and the generated unit is
|
|
37
|
+
* systemd's own derivative of it, not a foreign unit. Everything the check refused before still
|
|
38
|
+
* refuses: a vendor unit's `FragmentPath` (`/usr/lib/systemd/system/...`) is not under a
|
|
39
|
+
* generator directory at all; a generated unit with no `SourcePath`, or one outside every
|
|
40
|
+
* prefix (a `.container` file elsewhere in Quadlet's OWN search path —
|
|
41
|
+
* `podman-systemd.unit(5)` — that this runner was never told to own), still refuses.
|
|
26
42
|
*/
|
|
27
43
|
import type { ExecResult, HostRunner } from '../launchd/runner.ts';
|
|
44
|
+
import { GENERATED_UNIT_DIRECTORY } from './container-generator.ts';
|
|
28
45
|
import { SYSTEMCTL_ABS, SudoRefusedError, canonicalize, prefixOf } from './sudo-allowlist.ts';
|
|
29
46
|
import { type ChainExpect, assertGuardedChain } from './sudo-guard.ts';
|
|
30
47
|
import type { Elevate } from './sudo-write.ts';
|
|
31
48
|
import { parseShow } from './systemctl.ts';
|
|
32
49
|
|
|
50
|
+
/**
|
|
51
|
+
* ★ ONLY THE `.late` VARIANT ADDED — `container-generator.ts`'s own header names it as part of
|
|
52
|
+
* systemd's real Unit File Load Path; `.early` is not named anywhere in this repo and no
|
|
53
|
+
* generator this family drives (Quadlet writes only the base directory) uses it, so it stays
|
|
54
|
+
* out rather than widen the check on an unmeasured guess.
|
|
55
|
+
*/
|
|
56
|
+
const GENERATOR_DIRECTORIES: readonly string[] = [
|
|
57
|
+
GENERATED_UNIT_DIRECTORY,
|
|
58
|
+
`${GENERATED_UNIT_DIRECTORY}.late`,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const isUnderGeneratorDirectory = (fragmentPath: string): boolean =>
|
|
62
|
+
GENERATOR_DIRECTORIES.some((dir) => fragmentPath.startsWith(`${dir}/`));
|
|
63
|
+
|
|
33
64
|
const DIR_EXPECT: Readonly<Record<string, ChainExpect>> = {
|
|
34
65
|
chmod: 'directory',
|
|
35
66
|
chown: 'directory',
|
|
@@ -43,26 +74,33 @@ const EMPTY_FRAGMENT_OK: ReadonlySet<string> = new Set(['stop', 'disable']);
|
|
|
43
74
|
const unitStatusOf = async (
|
|
44
75
|
base: HostRunner,
|
|
45
76
|
unit: string,
|
|
46
|
-
): Promise<{
|
|
77
|
+
): Promise<{
|
|
78
|
+
readonly fragmentPath: string;
|
|
79
|
+
readonly loadState: string;
|
|
80
|
+
readonly sourcePath: string;
|
|
81
|
+
}> => {
|
|
47
82
|
const result = await base.exec([
|
|
48
83
|
SYSTEMCTL_ABS,
|
|
49
84
|
'show',
|
|
50
85
|
'--no-pager',
|
|
51
86
|
'-p',
|
|
52
|
-
|
|
87
|
+
// ★ SourcePath added for the Quadlet-generated-unit exception below — same property
|
|
88
|
+
// systemctl.ts's own `showUnit` already reads for `Podman.Container`.
|
|
89
|
+
'FragmentPath,LoadState,SourcePath',
|
|
53
90
|
'--',
|
|
54
91
|
unit,
|
|
55
92
|
]);
|
|
56
93
|
if (result.exitCode !== 0) {
|
|
57
94
|
throw new SudoRefusedError(
|
|
58
|
-
`sshSudoRunner ${unit}: could not read FragmentPath/LoadState (systemctl show
|
|
59
|
-
|
|
95
|
+
`sshSudoRunner ${unit}: could not read FragmentPath/LoadState/SourcePath (systemctl show ` +
|
|
96
|
+
`exit ${String(result.exitCode)}). Nothing ran as root.`,
|
|
60
97
|
);
|
|
61
98
|
}
|
|
62
99
|
const fields = parseShow(result.stdout);
|
|
63
100
|
return {
|
|
64
101
|
fragmentPath: fields.get('FragmentPath') ?? '',
|
|
65
102
|
loadState: fields.get('LoadState') ?? '',
|
|
103
|
+
sourcePath: fields.get('SourcePath') ?? '',
|
|
66
104
|
};
|
|
67
105
|
};
|
|
68
106
|
|
|
@@ -93,7 +131,7 @@ export const elevatedExec = (
|
|
|
93
131
|
const verb = canonical[1] ?? '';
|
|
94
132
|
const unit = canonical[3] ?? '';
|
|
95
133
|
return elevate(canonical, {}, async () => {
|
|
96
|
-
const { fragmentPath, loadState } = await unitStatusOf(base, unit);
|
|
134
|
+
const { fragmentPath, loadState, sourcePath } = await unitStatusOf(base, unit);
|
|
97
135
|
if (loadState === 'masked') {
|
|
98
136
|
throw new SudoRefusedError(
|
|
99
137
|
`sshSudoRunner ${unit}: is masked. Masking is someone's decision, not drift; unmask it ` +
|
|
@@ -108,11 +146,23 @@ export const elevatedExec = (
|
|
|
108
146
|
`${unit} is (a kernel-generated unit, say). Nothing ran as root.`,
|
|
109
147
|
);
|
|
110
148
|
}
|
|
111
|
-
if (prefixOf(fragmentPath, prefixes)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
if (prefixOf(fragmentPath, prefixes) !== undefined) return;
|
|
150
|
+
// ⛔ THE QUADLET CASE — see this file's header. A generator's OWN output directory is never a
|
|
151
|
+
// prefix, so this is not "found it under a prefix, done" — it is "found it under the
|
|
152
|
+
// generator, so check what GENERATED it instead."
|
|
153
|
+
if (
|
|
154
|
+
isUnderGeneratorDirectory(fragmentPath) &&
|
|
155
|
+
sourcePath !== '' &&
|
|
156
|
+
prefixOf(sourcePath, prefixes) !== undefined
|
|
157
|
+
) {
|
|
158
|
+
return;
|
|
116
159
|
}
|
|
160
|
+
const sourceNote =
|
|
161
|
+
sourcePath === '' ? '' : `, and its SourcePath ${sourcePath} is not under one either`;
|
|
162
|
+
throw new SudoRefusedError(
|
|
163
|
+
`sshSudoRunner ${unit}: FragmentPath ${fragmentPath} is outside every declared prefix ` +
|
|
164
|
+
`(${prefixes.join(', ')})${sourceNote}; refusing to touch a unit this runner does not ` +
|
|
165
|
+
'own. Nothing ran as root.',
|
|
166
|
+
);
|
|
117
167
|
});
|
|
118
168
|
};
|