@homeflare/alchemy 0.37.7 โ 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/docs/linux-sudo.md +18 -7
- package/package.json +1 -1
- 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/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
|
};
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `Systemd.Unit` and `
|
|
3
|
-
* the fake Linux host โ proving the
|
|
4
|
-
* not just that the runner's own primitives do. No sudo runs anywhere but through
|
|
2
|
+
* `Systemd.Unit`, `Remote.File` and `Podman.Container`'s own lifecycle functions, driven through
|
|
3
|
+
* sshSudoRunner over the fake Linux host โ proving the families this runner exists for actually
|
|
4
|
+
* work end to end, not just that the runner's own primitives do. No sudo runs anywhere but through
|
|
5
|
+
* the fake.
|
|
5
6
|
*/
|
|
6
7
|
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { fakeQuadletHost } from './fake-quadlet.ts';
|
|
7
9
|
import { fakeSudoHost } from './fake-sudo.ts';
|
|
8
10
|
import { SudoRefusedError } from './sudo-runner.ts';
|
|
9
11
|
import { deleteFile, reconcileFile } from './remote-file-lifecycle.ts';
|
|
10
12
|
import type { RemoteFileProps } from './remote-file-form.ts';
|
|
11
13
|
import { deleteUnit, diffUnit, reconcileUnit } from './unit-lifecycle.ts';
|
|
12
14
|
import type { SystemdUnitProps } from './unit-form.ts';
|
|
15
|
+
import { reconcileContainer } from './container-lifecycle.ts';
|
|
16
|
+
import type { ContainerProps } from './container-form.ts';
|
|
13
17
|
|
|
14
18
|
const UNIT: SystemdUnitProps = {
|
|
15
19
|
name: 'hf-thing.service',
|
|
@@ -124,6 +128,63 @@ describe('Systemd.Unit through sshSudoRunner', () => {
|
|
|
124
128
|
});
|
|
125
129
|
});
|
|
126
130
|
|
|
131
|
+
// ๐ด REGRESSION, CT100 deploy 2026-09-26 00:11Z: `Podman.Container caddy`'s generated unit's
|
|
132
|
+
// FragmentPath (`/run/systemd/generator/โฆ`) was refused outright by the ownership check above,
|
|
133
|
+
// even though its SourcePath (`/etc/containers/systemd/caddy.container`) was this runner's own
|
|
134
|
+
// `.container` file, under a declared prefix. `fakeQuadletHost` in place of the plain
|
|
135
|
+
// `fakeLinuxHost` (via `fakeSudoHost`'s `hostFactory`) reproduces the exact CT100 shape: a
|
|
136
|
+
// generated unit whose FragmentPath is the generator's own output directory.
|
|
137
|
+
const CADDY_PREFIXES = ['/etc/containers/systemd', '/etc/systemd/system'];
|
|
138
|
+
const CADDY: ContainerProps = {
|
|
139
|
+
container: { image: 'docker.io/library/caddy:2', network: 'host' },
|
|
140
|
+
install: { wantedBy: ['multi-user.target'] },
|
|
141
|
+
name: 'caddy',
|
|
142
|
+
service: { restart: 'always' },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
describe('Podman.Container through sshSudoRunner', () => {
|
|
146
|
+
test(
|
|
147
|
+
'a full reconcile writes, reloads and starts โ the generated unitโs FragmentPath under ' +
|
|
148
|
+
'the generator is reachable because its SourcePath is under a declared prefix',
|
|
149
|
+
async () => {
|
|
150
|
+
const { privileged, runner } = fakeSudoHost({}, undefined, {
|
|
151
|
+
hostFactory: fakeQuadletHost,
|
|
152
|
+
prefixes: CADDY_PREFIXES,
|
|
153
|
+
});
|
|
154
|
+
const attrs = await reconcileContainer(runner, CADDY, undefined);
|
|
155
|
+
expect(attrs.active).toBe(true);
|
|
156
|
+
expect(attrs.sourcePath).toBe('/etc/containers/systemd/caddy.container');
|
|
157
|
+
const verbs = privileged()
|
|
158
|
+
.filter((c) => c[0] === '/usr/bin/systemctl')
|
|
159
|
+
.map((c) => c[1]);
|
|
160
|
+
expect(verbs).toContain('daemon-reload');
|
|
161
|
+
expect(verbs).toContain('start');
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
test('a generated unit whose SourcePath is outside every declared prefix still refuses', async () => {
|
|
166
|
+
const { fake, privileged, runner } = fakeSudoHost({}, undefined, {
|
|
167
|
+
hostFactory: fakeQuadletHost,
|
|
168
|
+
prefixes: CADDY_PREFIXES,
|
|
169
|
+
});
|
|
170
|
+
// A `.container` file Quadlet's OWN search path would also read (podman-systemd.unit(5)), but
|
|
171
|
+
// not under any prefix THIS runner declared โ a foreign Quadlet source, same idea as the
|
|
172
|
+
// vendor `pveproxy.service` test above for a plain unit.
|
|
173
|
+
fake.files.set('/run/containers/systemd/other.container', {
|
|
174
|
+
bytes: new TextEncoder().encode('[Container]\nImage=example/other:1\n'),
|
|
175
|
+
gid: 0,
|
|
176
|
+
kind: 'file',
|
|
177
|
+
mode: 0o644,
|
|
178
|
+
uid: 0,
|
|
179
|
+
});
|
|
180
|
+
await runner.exec(['systemctl', 'daemon-reload']);
|
|
181
|
+
await expect(runner.exec(['systemctl', 'restart', '--', 'other.service'])).rejects.toThrow(
|
|
182
|
+
/SourcePath .* is not under one either/,
|
|
183
|
+
);
|
|
184
|
+
expect(privileged().some((c) => c[1] === 'restart')).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
127
188
|
const SCRIPT: RemoteFileProps = {
|
|
128
189
|
content: '#!/bin/sh\necho hi\n',
|
|
129
190
|
mode: 0o755,
|
package/src/linux/unit-form.ts
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* โ `content` IS NEVER A SECRET. Props sit unencrypted in Alchemy state and the unit file is
|
|
13
13
|
* world-readable at 0644; a unit that needs a secret takes `EnvironmentFile=` pointing at a file
|
|
14
14
|
* a secret renderer maintains.
|
|
15
|
+
* โ
THE TEXT-SHAPE CHECKS (comments/blank lines before [Section], [Install] key lookup) live in
|
|
16
|
+
* `unit-text.ts`, split out to keep this file under the house line cap.
|
|
15
17
|
*/
|
|
16
18
|
import { sha256Hex } from '../launchd/job-form.ts';
|
|
19
|
+
import { hasInstallSection, textProblems } from './unit-text.ts';
|
|
17
20
|
|
|
18
21
|
export type UnitSection = {
|
|
19
22
|
/** `Unit`, `Service`, `Timer`, `Install`, โฆ โ verbatim, including its capitalisation. */
|
|
@@ -172,8 +175,6 @@ const TYPES = new Set([
|
|
|
172
175
|
'timer',
|
|
173
176
|
]);
|
|
174
177
|
|
|
175
|
-
const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
|
|
176
|
-
|
|
177
178
|
/**
|
|
178
179
|
* Every refusal at once, so one plan shows the whole list.
|
|
179
180
|
* โ THE TWO STRUCTURAL RULES, both proven by systemd's own output rather than assumed:
|
|
@@ -206,7 +207,7 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
|
|
|
206
207
|
`${directory} is not one of them. Move it, or declare enabled: false.`,
|
|
207
208
|
);
|
|
208
209
|
}
|
|
209
|
-
if (props.enabled !== false && !
|
|
210
|
+
if (props.enabled !== false && !hasInstallSection(unitText(props))) {
|
|
210
211
|
found.push(
|
|
211
212
|
'enabled units need an [Install] section with WantedBy/RequiredBy/Also/Alias; without one ' +
|
|
212
213
|
'systemd calls the unit "static" and `systemctl enable` has nothing to link. Add one, or ' +
|
|
@@ -218,18 +219,3 @@ export const unitProblems = (props: SystemdUnitProps, expect?: string): string[]
|
|
|
218
219
|
}
|
|
219
220
|
return found;
|
|
220
221
|
};
|
|
221
|
-
|
|
222
|
-
const hasInstall = (props: SystemdUnitProps): boolean => {
|
|
223
|
-
const text = unitText(props);
|
|
224
|
-
if (!text.includes('[Install]')) return false;
|
|
225
|
-
const after = text.slice(text.indexOf('[Install]'));
|
|
226
|
-
return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
const textProblems = (text: string): string[] => {
|
|
230
|
-
const found: string[] = [];
|
|
231
|
-
if (text.trim() === '') found.push('the unit file is empty');
|
|
232
|
-
if (text.includes('\u0000')) found.push('the unit file contains NUL');
|
|
233
|
-
if (!/^\s*\[[A-Za-z]+]/.test(text)) found.push('a unit file must begin with a [Section]');
|
|
234
|
-
return found;
|
|
235
|
-
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unit-text.ts`'s own tests โ the INI text-shape checks, on their own, separate from
|
|
3
|
+
* `unit-lifecycle.test.ts`'s end-to-end fake-host behaviour.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { hasInstallSection, textProblems } from './unit-text.ts';
|
|
7
|
+
|
|
8
|
+
// ๐ด REGRESSION, CT100 deploy 2026-09-26 00:11Z: `assertValid` refused this EXACT content โ "a
|
|
9
|
+
// unit file must begin with a [Section]" โ even though it is byte-identical to what systemd
|
|
10
|
+
// already loads on CT100 (sha256 verified against the live host). Leading lines copied
|
|
11
|
+
// verbatim from homeflare-ct100's src/openbao-agent/files/openbao-agent.service.
|
|
12
|
+
const CT100_OPENBAO_AGENT = [
|
|
13
|
+
"# CT100's openbao-agent.",
|
|
14
|
+
'#',
|
|
15
|
+
"# THIS FILE WAS RECOVERED FROM THE RUNNING BOX, NOT PUSHED TO IT. The repo's copy was STALE",
|
|
16
|
+
'# and would have REMOVED `Environment=BAO_NAMESPACE=homeflare` -- without which the agent',
|
|
17
|
+
'# authenticates into the root namespace, every template 403s, and the failure reads as a',
|
|
18
|
+
'# missing grant rather than a missing variable. That is the exact shape the vault policies',
|
|
19
|
+
'# README warns about: a deploy converging a stale copy over a working live one.',
|
|
20
|
+
'# So the FIRST run of deploy-ct100.sh was a --dry-run, and this is what it was for.',
|
|
21
|
+
'[Unit]',
|
|
22
|
+
'Description=OpenBao agent',
|
|
23
|
+
'After=network-online.target',
|
|
24
|
+
'Wants=network-online.target',
|
|
25
|
+
'[Service]',
|
|
26
|
+
'Environment=BAO_NAMESPACE=homeflare',
|
|
27
|
+
'ExecStart=/usr/bin/bao agent -config=/etc/openbao/agent.hcl',
|
|
28
|
+
'RuntimeDirectory=openbao',
|
|
29
|
+
'Restart=on-failure',
|
|
30
|
+
'RestartSec=10',
|
|
31
|
+
'[Install]',
|
|
32
|
+
'WantedBy=multi-user.target',
|
|
33
|
+
'',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
describe('textProblems', () => {
|
|
37
|
+
test('the CT100 openbao-agent.service fixture validates โ comments may precede [Unit]', () => {
|
|
38
|
+
expect(textProblems(CT100_OPENBAO_AGENT)).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('blank lines alone may also precede the first section', () => {
|
|
42
|
+
expect(textProblems('\n\n[Unit]\nDescription=x\n')).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a ; comment is accepted the same as a # comment', () => {
|
|
46
|
+
expect(textProblems('; a semicolon comment\n[Unit]\nDescription=x\n')).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a genuine key=value before any section still refuses โ comments are not a blanket exemption', () => {
|
|
50
|
+
expect(textProblems('Description=no section at all\n[Unit]\n').join(' ')).toMatch(
|
|
51
|
+
/must begin with a \[Section\]/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('an empty file and one with NUL are still refused', () => {
|
|
56
|
+
expect(textProblems(' \n').join(' ')).toMatch(/is empty/);
|
|
57
|
+
expect(textProblems('[Unit]\nDescription=a\u0000b\n').join(' ')).toMatch(/contains NUL/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('hasInstallSection', () => {
|
|
62
|
+
test('true only once an [Install] key actually appears after the header', () => {
|
|
63
|
+
expect(hasInstallSection('[Unit]\n[Install]\nWantedBy=multi-user.target\n')).toBe(true);
|
|
64
|
+
expect(hasInstallSection('[Unit]\nDescription=x\n')).toBe(false);
|
|
65
|
+
expect(hasInstallSection('[Unit]\n[Install]\n')).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of `unit-form.ts`'s validation that inspect the unit file's TEXT rather than its
|
|
3
|
+
* props struct โ split out so unit-form.ts stays under the file cap, and because these are one
|
|
4
|
+
* coherent concern: is this INI shape something systemd would actually load. Takes plain strings,
|
|
5
|
+
* never `SystemdUnitProps`, so it never needs to import back from unit-form.ts.
|
|
6
|
+
*
|
|
7
|
+
* โ `[Install]` KEYS ARE `unit-form.ts`'s OWN LIST, NOT SYSTEMD'S FULL VOCABULARY โ WantedBy,
|
|
8
|
+
* RequiredBy, UpsertedBy, Also, Alias, per `systemd.unit(5)` ยง[Install]. `container-form.ts`'s
|
|
9
|
+
* own header notes it deliberately does NOT reuse this set: a `.container` file's `[Install]`
|
|
10
|
+
* supports only Alias/WantedBy/RequiredBy (`podman-systemd.unit(5)`), narrower than this.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const INSTALL_KEYS = new Set(['WantedBy', 'RequiredBy', 'UpsertedBy', 'Also', 'Alias']);
|
|
14
|
+
|
|
15
|
+
/** Whether `text` has an `[Install]` section carrying at least one key systemd actually links on. */
|
|
16
|
+
export const hasInstallSection = (text: string): boolean => {
|
|
17
|
+
if (!text.includes('[Install]')) return false;
|
|
18
|
+
const after = text.slice(text.indexOf('[Install]'));
|
|
19
|
+
return [...INSTALL_KEYS].some((key) => new RegExp(`^${key}=`, 'm').test(after));
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The first line that is neither blank nor a comment โ the line whose shape actually decides
|
|
24
|
+
* whether the file "begins with a [Section]", per `textProblems` below.
|
|
25
|
+
* โ
systemd.syntax(7): "Empty lines and lines starting with '#' or ';' are ignored", with no
|
|
26
|
+
* carve-out for the position before the first section โ a comment header is ordinary content
|
|
27
|
+
* there, same as anywhere else in the file.
|
|
28
|
+
*/
|
|
29
|
+
export const firstSubstantiveLine = (text: string): string | undefined =>
|
|
30
|
+
text
|
|
31
|
+
.split('\n')
|
|
32
|
+
.find((line) => !(line.trim() === '' || line.startsWith('#') || line.startsWith(';')));
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ๐ด MEASURED FALSE, CT100 deploy 2026-09-26 00:11Z: the openbao-agent.service asset's first eight
|
|
36
|
+
* lines are `#`-comments recording where the file was recovered from (verbatim from the live
|
|
37
|
+
* host โ homeflare-ct100's `src/openbao-agent/files/openbao-agent.service`), and the file is
|
|
38
|
+
* byte-identical to what systemd already loads there. The old check tested the WHOLE text
|
|
39
|
+
* against `/^\s*\[[A-Za-z]+]/` โ `\s*` skips blank lines and indentation, never a `#`/`;`
|
|
40
|
+
* comment โ so a leading comment line failed it outright. This checks only the first
|
|
41
|
+
* SUBSTANTIVE line, so any number of leading blank lines and comments are fine and a genuine
|
|
42
|
+
* `key=value` before any section โ the one real error this exists to catch โ still fails, since
|
|
43
|
+
* that line is neither blank nor a comment either.
|
|
44
|
+
*/
|
|
45
|
+
export const textProblems = (text: string): string[] => {
|
|
46
|
+
const found: string[] = [];
|
|
47
|
+
if (text.trim() === '') found.push('the unit file is empty');
|
|
48
|
+
if (text.includes('\u0000')) found.push('the unit file contains NUL');
|
|
49
|
+
const first = firstSubstantiveLine(text);
|
|
50
|
+
// โ
`\s*` kept from the original regex โ leading indentation on the SECTION line itself was
|
|
51
|
+
// already tolerated before this fix and stays that way; only the leading-comment shape changes.
|
|
52
|
+
if (first === undefined || !/^\s*\[[A-Za-z]+]/.test(first)) {
|
|
53
|
+
found.push(
|
|
54
|
+
'a unit file must begin with a [Section] (blank lines and #/; comments before it are fine)',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
};
|