fix(cli): harden native service manager preflight

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 01:28:04 +02:00
parent dde48b3b11
commit 767e653028
15 changed files with 829 additions and 248 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch "@jmfederico/pi-web": patch
--- ---
Validate install and doctor service requirements in the real systemd or launchd manager context before changing native services, with plan-specific PATH guidance and safe probe cleanup. Thanks to @blain3white for the original report, reproduction, and diagnosis. Validate install and doctor service requirements in the real systemd or launchd manager context before changing native services, with plan-specific PATH guidance and safe probe cleanup. Thanks to @blain3white for the original report, reproduction, and root-cause analysis.
+1 -1
View File
@@ -64,7 +64,7 @@ pi-web version
pi-web uninstall pi-web uninstall
``` ```
`pi-web install` validates the exact production or development service plan inside the native user-service manager before changing config or replacing services. `pi-web doctor` repeats manager-context diagnostics, labels prospective production checks when an installed command strategy cannot be reconstructed, and keeps general shell/Pi/npm readiness separate from service-start requirements. `pi-web install` validates the safely verifiable requirements of the exact production or development service plan inside the native user-service manager before changing config or replacing services; arbitrary configured command overrides are preserved but not executed by preflight. `pi-web doctor` repeats manager-context diagnostics, labels prospective production checks when an installed command strategy cannot be reconstructed, and keeps general shell/Pi/npm readiness separate from service-start requirements.
For more install options, including one-line install, Pi package install, WSL/manual usage, and remote access, see the [installation guide](https://pi-web.dev/install). For more install options, including one-line install, Pi package install, WSL/manual usage, and remote access, see the [installation guide](https://pi-web.dev/install).
+3 -2
View File
@@ -114,8 +114,9 @@
<strong>Important PATH detail:</strong> <strong>Important PATH detail:</strong>
PI WEB services run through a non-interactive login shell with <code>-lc</code>. Setup that only lives in PI WEB services run through a non-interactive login shell with <code>-lc</code>. Setup that only lives in
interactive shell files or prompt hooks may not be visible to the systemd or launchd manager. The installer interactive shell files or prompt hooks may not be visible to the systemd or launchd manager. The installer
probes the exact candidate plan in that manager context before changing config or replacing services; run probes the safely verifiable requirements of the exact candidate plan in that manager context before changing
<code>pi-web doctor</code> later to repeat plan-specific diagnostics. config or replacing services. Arbitrary configured command overrides are preserved but not executed by
preflight; run <code>pi-web doctor</code> later to repeat plan-specific diagnostics.
</div> </div>
</section> </section>
+14
View File
@@ -7,6 +7,7 @@ import {
doctorExitCode, doctorExitCode,
isCliEntrypoint, isCliEntrypoint,
launchdRuntimeDetails, launchdRuntimeDetails,
regularFileExists,
serviceBackendForPlatform, serviceBackendForPlatform,
} from "./cli.js"; } from "./cli.js";
@@ -53,6 +54,19 @@ describe("native-service doctor CLI contracts", () => {
expect(doctorExitCode(true, true, false)).toBe(1); expect(doctorExitCode(true, true, false)).toBe(1);
}); });
it("accepts only regular files as bundled entrypoints", () => {
const dir = mkdtempSync(join(tmpdir(), "pi-web-entrypoint-test-"));
try {
const file = join(dir, "entrypoint.js");
writeFileSync(file, "export {};\n");
expect(regularFileExists(file)).toBe(true);
expect(regularFileExists(dir)).toBe(false);
expect(regularFileExists(join(dir, "missing.js"))).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("surfaces launchd last exit code 127 in service status", () => { it("surfaces launchd last exit code 127 in service status", () => {
expect(launchdRuntimeDetails("state = exited\nlast exit code = 127\n")).toEqual({ expect(launchdRuntimeDetails("state = exited\nlast exit code = 127\n")).toEqual({
state: "exited", state: "exited",
+15 -7
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, realpathSync } from "node:fs"; import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises"; import { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir, userInfo } from "node:os"; import { homedir, userInfo } from "node:os";
import { basename, dirname, join, resolve } from "node:path"; import { basename, dirname, join, resolve } from "node:path";
@@ -10,6 +10,7 @@ import { packageVersion, printPiWebVersionReport } from "./piWebVersionReport.js
import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js"; import { checkNodePtyDarwinSpawnHelper, formatNodePtyDarwinSpawnHelperCheck } from "./server/diagnostics/nodePtySpawnHelper.js";
import { import {
installNativeServiceCandidate, installNativeServiceCandidate,
nativeServiceInstallFailureNeedsPathAdvice,
type NativeServiceInstallCandidate, type NativeServiceInstallCandidate,
type NativeServiceInstallFailure, type NativeServiceInstallFailure,
} from "./nativeServices/serviceInstall.js"; } from "./nativeServices/serviceInstall.js";
@@ -218,6 +219,10 @@ function packageEntrypointPath(name: "server" | "sessiond"): string {
return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js"); return join(packageRootPath(), "dist", "server", name === "server" ? "index.js" : "sessiond.js");
} }
export function regularFileExists(path: string): boolean {
return existsSync(path) && statSync(path).isFile();
}
function detectServiceShell(): NativeServiceShell { function detectServiceShell(): NativeServiceShell {
const userShell = userInfo().shell ?? undefined; const userShell = userInfo().shell ?? undefined;
const envShell = process.env["SHELL"]?.trim(); const envShell = process.env["SHELL"]?.trim();
@@ -660,15 +665,18 @@ async function install(args: string[]): Promise<void> {
console.log(`Service shell: ${describeServiceShell()}`); console.log(`Service shell: ${describeServiceShell()}`);
const result = await installNativeServiceCandidate(candidate, { const result = await installNativeServiceCandidate(candidate, {
probe: createNativeServiceAuthoritativeProbe(), probe: createNativeServiceAuthoritativeProbe(),
fileExists: existsSync, fileExists: regularFileExists,
writeInitialConfig: () => writeInitialConfig(options, configPath), writeInitialConfig: () => writeInitialConfig(options, configPath),
replaceServices: installNativeServices, replaceServices: installNativeServices,
}); });
if (!result.ok) { if (!result.ok) {
printNativeServiceInstallFailure(result.failure); printNativeServiceInstallFailure(result.failure);
printPathSetupAdvice(); if (nativeServiceInstallFailureNeedsPathAdvice(result.failure)) printPathSetupAdvice();
throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail."); throw new Error("Install preflight checks failed without changing config or services. Fix the failure above, then run `pi-web doctor` for more detail.");
} }
for (const service of result.plan.services.filter((item) => item.strategy.kind === "configured-override")) {
console.log(`! ${service.description} uses a configured command override; preflight did not execute that arbitrary command.`);
}
console.log(`\nPI WEB ${options.mode} services are installed and starting.`); console.log(`\nPI WEB ${options.mode} services are installed and starting.`);
console.log(`Config: ${configPath}`); console.log(`Config: ${configPath}`);
@@ -893,7 +901,7 @@ function nativeServiceDoctorTarget(backend: ServiceBackend): NativeServiceDoctor
async function printNativeServiceDoctorChecks(backend: ServiceBackend): Promise<NativeServiceDoctorReport> { async function printNativeServiceDoctorChecks(backend: ServiceBackend): Promise<NativeServiceDoctorReport> {
const result = await runNativeServiceDoctor(nativeServiceDoctorTarget(backend), { const result = await runNativeServiceDoctor(nativeServiceDoctorTarget(backend), {
probe: createNativeServiceAuthoritativeProbe(), probe: createNativeServiceAuthoritativeProbe(),
fileExists: existsSync, fileExists: regularFileExists,
}); });
const report = formatNativeServiceDoctorResult(result); const report = formatNativeServiceDoctorResult(result);
for (const line of report.lines) console.log(line); for (const line of report.lines) console.log(line);
@@ -994,11 +1002,11 @@ async function doctor(): Promise<void> {
} }
const nativeServicePlanOk = nativeServiceReport?.ok ?? true; const nativeServicePlanOk = nativeServiceReport?.ok ?? true;
const pathFailure = !generalReadinessOk || nativeServiceReport?.failureKind === "requirements"; const pathFailure = !generalReadinessOk || nativeServiceReport?.pathAdviceRecommended === true;
if (pathFailure) { if (pathFailure) {
console.log("\nIf a command works in your terminal but fails in the service-manager check, compare the caller and manager contexts above."); console.log("\nIf a command works in your terminal but fails in the service-manager check, compare the caller and manager contexts above.");
const adviceShell = nativeServiceReport?.failureKind === "requirements" && nativeServiceReport.plan !== null const adviceShell = nativeServiceReport?.pathAdviceRecommended === true && nativeServiceReport.adviceShell !== null
? nativeServiceReport.plan.shell ? nativeServiceReport.adviceShell
: detectServiceShell(); : detectServiceShell();
printPathSetupAdvice(adviceShell); printPathSetupAdvice(adviceShell);
} }
+143 -2
View File
@@ -98,13 +98,39 @@ describe("installed native-service mode and definition inspection", () => {
}); });
}); });
it("reconstructs escaped systemd paths, substitutions, and line controls exactly", () => {
const plan = createDevelopmentNativeServicePlan({
backend: { kind: "systemd", label: "systemd" },
shell: {
name: "zsh",
executable: "/shell $HOME/%h/zsh",
source: "detected",
detectedExecutable: "/shell $HOME/%h/zsh",
},
environment: { PI_WEB_CONFIG: "/config/%h\nnext" },
workingDirectory: "/checkout %h\nnext",
packageJsonPath: "/checkout %h\nnext/package.json",
});
expect(inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan))).toEqual({
ok: true,
value: {
backend: plan.backend,
shell: plan.shell,
environment: plan.services[0]?.environment,
workingDirectory: "/checkout %h\nnext",
packageJsonPath: "/checkout %h\nnext/package.json",
},
});
});
it("inspects legacy systemd definitions without /usr/bin/env or quoted working directories", () => { it("inspects legacy systemd definitions without /usr/bin/env or quoted working directories", () => {
const plan = developmentPlan("systemd"); const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({ const definitions = renderedDefinitions(plan).map((definition) => ({
...definition, ...definition,
contents: definition.contents contents: definition.contents
.replace("ExecStart=/usr/bin/env ", "ExecStart=") .replace("ExecStart=/usr/bin/env ", "ExecStart=")
.replace('WorkingDirectory="/checkout with space"', "WorkingDirectory=/checkout with space"), .replace("WorkingDirectory=/checkout\\x20with\\x20space", "WorkingDirectory=/checkout with space"),
})); }));
expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions)).toMatchObject({ expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions)).toMatchObject({
@@ -113,6 +139,80 @@ describe("installed native-service mode and definition inspection", () => {
}); });
}); });
it("rejects quoted systemd working directories that the manager treats as non-absolute", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace(
"WorkingDirectory=/checkout\\x20with\\x20space",
'WorkingDirectory="/checkout with space"',
),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected quoted working directory inspection to fail");
expect(inspection.message).toContain("invalid quoted working directory");
});
it("rejects unconsumed systemd environment syntax rather than checking a different context", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace("[Service]\n", "[Service]\nEnvironment=PATH=/custom/bin\n"),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected systemd environment inspection to fail");
expect(inspection.message).toContain("environment entry");
});
it.each([
'Environment="PI_WEB_CONFIG=/config" "PATH=/broken"',
"EnvironmentFile=/tmp/pi-web.env",
])("rejects noncanonical systemd environment context: %s", (directive) => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace("[Service]\n", `[Service]\n${directive}\n`),
}));
expect(inspectInstalledDevelopmentServiceInput(plan.backend, definitions).ok).toBe(false);
});
it("rejects duplicate systemd ExecStart directives", () => {
const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace(
"Restart=no",
'ExecStart=/usr/bin/env "/bin/zsh" -lc "exec true"\nRestart=no',
),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected duplicate ExecStart inspection to fail");
expect(inspection.message).toContain("exactly one recognized ExecStart");
});
it("rejects malformed launchd environment dictionaries rather than dropping entries", () => {
const plan = developmentPlan("launchd");
const definitions = renderedDefinitions(plan).map((definition) => ({
...definition,
contents: definition.contents.replace(
" </dict>\n <key>RunAtLoad</key>",
" <key>BROKEN</key>\n <integer>1</integer>\n </dict>\n <key>RunAtLoad</key>",
),
}));
const inspection = inspectInstalledDevelopmentServiceInput(plan.backend, definitions);
expect(inspection.ok).toBe(false);
if (inspection.ok) throw new Error("Expected launchd environment inspection to fail");
expect(inspection.message).toContain("environment dictionary");
});
it("rejects a modified development command rather than claiming to check the installed plan", () => { it("rejects a modified development command rather than claiming to check the installed plan", () => {
const plan = developmentPlan("systemd"); const plan = developmentPlan("systemd");
const definitions = renderedDefinitions(plan); const definitions = renderedDefinitions(plan);
@@ -168,6 +268,31 @@ describe("native-service doctor planning and reporting", () => {
); );
}); });
it("does not recommend PATH changes for checkout metadata failures", async () => {
const plan = developmentPlan("systemd");
const inspected = inspectInstalledDevelopmentServiceInput(plan.backend, renderedDefinitions(plan));
if (!inspected.ok) throw new Error(inspected.message);
const result = await runNativeServiceDoctor(
{ kind: "installed-development", input: inspected.value },
{
probe: {
run: (request) => Promise.resolve({
kind: "completed",
outcomes: request.prerequisites.map((prerequisite) => ({
prerequisiteId: prerequisite.id,
status: prerequisite.kind === "package-scripts" ? "unsatisfied" as const : "satisfied" as const,
detail: prerequisite.kind === "package-scripts" ? "scripts missing" : null,
})),
}),
},
fileExists: () => true,
},
);
const report = formatNativeServiceDoctorResult(result);
expect(report).toMatchObject({ ok: false, failureKind: "requirements", pathAdviceRecommended: false });
});
it("labels a production check as prospective and reports manager-context requirements", async () => { it("labels a production check as prospective and reports manager-context requirements", async () => {
const target: NativeServiceDoctorTarget = { const target: NativeServiceDoctorTarget = {
kind: "prospective-production", kind: "prospective-production",
@@ -190,6 +315,22 @@ describe("native-service doctor planning and reporting", () => {
])); ]));
}); });
it("retains the installed production shell when resolution fails before a plan exists", async () => {
const result = await runNativeServiceDoctor(
{ kind: "prospective-production", input: productionInput(), reason: "installed strategy is unknown" },
{ probe: probeWithStatus("unsatisfied"), fileExists: () => false },
);
const report = formatNativeServiceDoctorResult(result);
expect(report).toMatchObject({
ok: false,
failureKind: "requirements",
plan: null,
adviceShell: shell,
pathAdviceRecommended: true,
});
});
it("preserves configured overrides as unverified and does not probe arbitrary commands", async () => { it("preserves configured overrides as unverified and does not probe arbitrary commands", async () => {
let calls = 0; let calls = 0;
const result = await runNativeServiceDoctor( const result = await runNativeServiceDoctor(
@@ -201,7 +342,7 @@ describe("native-service doctor planning and reporting", () => {
); );
const report = formatNativeServiceDoctorResult(result); const report = formatNativeServiceDoctorResult(result);
expect(calls).toBe(0); expect(calls).toBe(1);
expect(report.ok).toBe(true); expect(report.ok).toBe(true);
expect(report.lines.join("\n")).toContain("does not execute arbitrary configured commands"); expect(report.lines.join("\n")).toContain("does not execute arbitrary configured commands");
}); });
+216 -49
View File
@@ -1,6 +1,7 @@
import { basename, join } from "node:path"; import { basename, join } from "node:path";
import { import {
createDevelopmentNativeServicePlan, createDevelopmentNativeServicePlan,
nativeServicePrerequisiteNeedsPathAdvice,
resolveProductionNativeServicePlan, resolveProductionNativeServicePlan,
validateNativeServicePlan, validateNativeServicePlan,
type DevelopmentNativeServicePlanInput, type DevelopmentNativeServicePlanInput,
@@ -49,6 +50,7 @@ export type NativeServiceDoctorTarget =
interface NativeServiceDoctorScope { interface NativeServiceDoctorScope {
kind: "installed-development" | "prospective-production"; kind: "installed-development" | "prospective-production";
reason: string | null; reason: string | null;
shell: NativeServiceShell;
} }
export type NativeServiceDoctorResult = export type NativeServiceDoctorResult =
@@ -73,6 +75,8 @@ export interface NativeServiceDoctorReport {
failureKind: "none" | "requirements" | "infrastructure" | "inspection"; failureKind: "none" | "requirements" | "infrastructure" | "inspection";
lines: readonly string[]; lines: readonly string[];
plan: NativeServicePlan | null; plan: NativeServicePlan | null;
adviceShell: NativeServiceShell | null;
pathAdviceRecommended: boolean;
failedPrerequisites: readonly NativeServicePrerequisite[]; failedPrerequisites: readonly NativeServicePrerequisite[];
} }
@@ -153,8 +157,8 @@ export async function runNativeServiceDoctor(
if (target.kind === "inspection-failure") return target; if (target.kind === "inspection-failure") return target;
const scope: NativeServiceDoctorScope = target.kind === "installed-development" const scope: NativeServiceDoctorScope = target.kind === "installed-development"
? { kind: target.kind, reason: null } ? { kind: target.kind, reason: null, shell: target.input.shell }
: { kind: target.kind, reason: target.reason }; : { kind: target.kind, reason: target.reason, shell: target.input.shell };
let plan: NativeServicePlan; let plan: NativeServicePlan;
if (target.kind === "installed-development") { if (target.kind === "installed-development") {
plan = createDevelopmentNativeServicePlan(target.input); plan = createDevelopmentNativeServicePlan(target.input);
@@ -180,6 +184,8 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul
" Run `pi-web install` or `pi-web install --dev` to replace mixed, partial, or outdated service definitions.", " Run `pi-web install` or `pi-web install --dev` to replace mixed, partial, or outdated service definitions.",
], ],
plan: null, plan: null,
adviceShell: null,
pathAdviceRecommended: false,
failedPrerequisites: [], failedPrerequisites: [],
}; };
} }
@@ -205,6 +211,9 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul
failureKind: infrastructure ? "infrastructure" : "requirements", failureKind: infrastructure ? "infrastructure" : "requirements",
lines, lines,
plan: null, plan: null,
adviceShell: result.scope.shell,
pathAdviceRecommended: !infrastructure
&& result.failures.some((failure) => failure.kind === "executable-unavailable"),
failedPrerequisites: [], failedPrerequisites: [],
}; };
} }
@@ -215,7 +224,15 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul
} }
if (result.validation.ok) { if (result.validation.ok) {
lines.push("✓ All verifiable native-service plan requirements are satisfied in the service-manager context."); lines.push("✓ All verifiable native-service plan requirements are satisfied in the service-manager context.");
return { ok: true, failureKind: "none", lines, plan: result.plan, failedPrerequisites: [] }; return {
ok: true,
failureKind: "none",
lines,
plan: result.plan,
adviceShell: result.plan.shell,
pathAdviceRecommended: false,
failedPrerequisites: [],
};
} }
const failedPrerequisites: NativeServicePrerequisite[] = []; const failedPrerequisites: NativeServicePrerequisite[] = [];
@@ -236,6 +253,9 @@ export function formatNativeServiceDoctorResult(result: NativeServiceDoctorResul
failureKind: infrastructure ? "infrastructure" : "requirements", failureKind: infrastructure ? "infrastructure" : "requirements",
lines, lines,
plan: result.plan, plan: result.plan,
adviceShell: result.plan.shell,
pathAdviceRecommended: !infrastructure
&& failedPrerequisites.some(nativeServicePrerequisiteNeedsPathAdvice),
failedPrerequisites, failedPrerequisites,
}; };
} }
@@ -275,33 +295,85 @@ function parseConsistentDefinitions(
return { ok: true, value: parsed }; return { ok: true, value: parsed };
} }
interface ParsedSystemdDirective {
name: string;
value: string;
}
function systemdServiceDirectives(contents: string): ParsedSystemdDirective[] | undefined {
const allowed = new Set(["Type", "WorkingDirectory", "Environment", "ExecStart", "Restart", "RestartSec"]);
const directives: ParsedSystemdDirective[] = [];
let inServiceSection = false;
let foundServiceSection = false;
for (const line of contents.split(/\r?\n/u)) {
const trimmed = line.trim();
if (/^\[[^\]]+\]$/u.test(trimmed)) {
inServiceSection = trimmed === "[Service]";
foundServiceSection ||= inServiceSection;
continue;
}
if (!inServiceSection || trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
const match = /^\s*([A-Za-z][A-Za-z0-9]*)=(.*)$/u.exec(line);
const name = match?.[1];
const value = match?.[2];
if (name === undefined || value === undefined || !allowed.has(name)) return undefined;
directives.push({ name, value });
}
return foundServiceSection ? directives : undefined;
}
function parseSystemdDefinition( function parseSystemdDefinition(
definition: InstalledNativeServiceDefinition, definition: InstalledNativeServiceDefinition,
): InstalledNativeServiceInspection<ParsedServiceDefinition> { ): InstalledNativeServiceInspection<ParsedServiceDefinition> {
const execStart = /^ExecStart=(?:\/usr\/bin\/env )?(.+?) -lc (.+)$/mu.exec(definition.contents); const directives = systemdServiceDirectives(definition.contents);
if (execStart?.[1] === undefined || execStart[2] === undefined) { if (directives === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized ExecStart.` }; return { ok: false, message: `Installed ${definition.id} systemd unit has unrecognized service directives.` };
} }
const shell = installedShell(execStart[1]); const execStarts = directives.filter((directive) => directive.name === "ExecStart");
const execStart = execStarts.length === 1
? /^(?:\/usr\/bin\/env )?(.+?) -lc (.+)$/u.exec(execStarts[0]?.value ?? "")
: null;
if (execStart?.[1] === undefined || execStart[2] === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit must have exactly one recognized ExecStart.` };
}
const shellExecutable = parseSystemdExecArgument(execStart[1]);
if (shellExecutable === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized login shell argument.` };
}
const shell = installedShell(shellExecutable);
if (!shell.ok) return shell; if (!shell.ok) return shell;
const shellCommand = parseShellQuotedValue(shell.value.name, execStart[2]); const shellCommand = parseSystemdShellCommand(shell.value.name, execStart[2]);
if (shellCommand === undefined) { if (shellCommand === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized shell command.` }; return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized shell command.` };
} }
const environment: Record<string, string> = {}; const environment: Record<string, string> = {};
for (const match of definition.contents.matchAll(/^Environment="((?:\\.|[^"])*)"$/gmu)) { for (const directive of directives.filter((item) => item.name === "Environment")) {
const assignment = systemdUnescape(match[1] ?? ""); const rawValue = directive.value;
const separator = assignment.indexOf("="); if (!/^"(?:\\.|[^"])*"$/u.test(rawValue)) {
if (separator <= 0) return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed environment entry.` }; return { ok: false, message: `Installed ${definition.id} systemd unit has an unrecognized environment entry.` };
environment[assignment.slice(0, separator)] = assignment.slice(separator + 1); }
const assignment = parseSystemdDirectiveValue(rawValue);
const separator = assignment?.indexOf("=") ?? -1;
const key = assignment?.slice(0, separator) ?? "";
if (separator <= 0 || Object.hasOwn(environment, key)) {
return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed environment entry.` };
}
environment[key] = assignment?.slice(separator + 1) ?? "";
} }
const workingDirectoryMatch = /^WorkingDirectory=(.+)$/mu.exec(definition.contents); const workingDirectories = directives.filter((directive) => directive.name === "WorkingDirectory");
const workingDirectory = workingDirectoryMatch?.[1] === undefined if (workingDirectories.length > 1) {
return { ok: false, message: `Installed ${definition.id} systemd unit has duplicate working directories.` };
}
const rawWorkingDirectory = workingDirectories[0]?.value;
if (rawWorkingDirectory?.startsWith('"') === true || rawWorkingDirectory?.startsWith("'") === true) {
return { ok: false, message: `Installed ${definition.id} systemd unit has an invalid quoted working directory.` };
}
const workingDirectory = rawWorkingDirectory === undefined
? null ? null
: parseSystemdValue(workingDirectoryMatch[1]); : parseSystemdDirectiveValue(rawWorkingDirectory);
if (workingDirectoryMatch !== null && workingDirectory === undefined) { if (workingDirectories.length === 1 && workingDirectory === undefined) {
return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed working directory.` }; return { ok: false, message: `Installed ${definition.id} systemd unit has a malformed working directory.` };
} }
@@ -314,25 +386,42 @@ function parseSystemdDefinition(
function parseLaunchdDefinition( function parseLaunchdDefinition(
definition: InstalledNativeServiceDefinition, definition: InstalledNativeServiceDefinition,
): InstalledNativeServiceInspection<ParsedServiceDefinition> { ): InstalledNativeServiceInspection<ParsedServiceDefinition> {
const argumentsBlock = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/u.exec(definition.contents)?.[1]; const argumentsMatches = [...definition.contents.matchAll(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/gu)];
if (argumentsBlock === undefined) { const arguments_ = argumentsMatches.length === 1
return { ok: false, message: `Installed ${definition.id} LaunchAgent has no ProgramArguments array.` }; ? parseXmlStringSequence(argumentsMatches[0]?.[1] ?? "")
} : undefined;
const arguments_ = [...argumentsBlock.matchAll(/<string>([\s\S]*?)<\/string>/gu)].map((match) => xmlUnescape(match[1] ?? "")); if (arguments_?.length !== 4 || arguments_[0] !== "/usr/bin/env" || arguments_[2] !== "-lc") {
if (arguments_.length !== 4 || arguments_[0] !== "/usr/bin/env" || arguments_[2] !== "-lc") {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has unrecognized ProgramArguments.` }; return { ok: false, message: `Installed ${definition.id} LaunchAgent has unrecognized ProgramArguments.` };
} }
const shell = installedShell(arguments_[1] ?? ""); const shell = installedShell(arguments_[1] ?? "");
if (!shell.ok) return shell; if (!shell.ok) return shell;
const environment: Record<string, string> = {}; const environmentMatches = [...definition.contents.matchAll(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/gu)];
const environmentBlock = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/u.exec(definition.contents)?.[1]; const environmentKeyCount = [...definition.contents.matchAll(/<key>EnvironmentVariables<\/key>/gu)].length;
if (environmentBlock !== undefined) { if (environmentMatches.length > 1 || environmentKeyCount !== environmentMatches.length) {
for (const match of environmentBlock.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/gu)) { return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` };
environment[xmlUnescape(match[1] ?? "")] = xmlUnescape(match[2] ?? ""); }
} const environment = environmentMatches.length === 0
? {}
: parseXmlStringDictionary(environmentMatches[0]?.[1] ?? "");
if (environment === undefined) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed environment dictionary.` };
}
const contentsWithoutEnvironment = environmentMatches[0]?.[0] === undefined
? definition.contents
: definition.contents.replace(environmentMatches[0][0], "");
const workingDirectoryMatches = [...contentsWithoutEnvironment.matchAll(/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/gu)];
const workingDirectoryKeyCount = [...contentsWithoutEnvironment.matchAll(/<key>WorkingDirectory<\/key>/gu)].length;
if (workingDirectoryMatches.length > 1 || workingDirectoryKeyCount !== workingDirectoryMatches.length) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` };
}
const workingDirectory = workingDirectoryMatches[0]?.[1] === undefined
? null
: xmlUnescapeStrict(workingDirectoryMatches[0][1]);
if (workingDirectoryMatches.length === 1 && workingDirectory === undefined) {
return { ok: false, message: `Installed ${definition.id} LaunchAgent has a malformed working directory.` };
} }
const workingDirectory = launchdString(definition.contents, "WorkingDirectory");
return { return {
ok: true, ok: true,
@@ -340,7 +429,7 @@ function parseLaunchdDefinition(
id: definition.id, id: definition.id,
shell: shell.value, shell: shell.value,
environment, environment,
workingDirectory, workingDirectory: workingDirectory ?? null,
shellCommand: arguments_[3] ?? "", shellCommand: arguments_[3] ?? "",
}, },
}; };
@@ -357,31 +446,87 @@ function installedShell(executable: string): InstalledNativeServiceInspection<Na
}; };
} }
function parseSystemdValue(value: string): string | undefined { function parseSystemdExecArgument(value: string): string | undefined {
if (!value.startsWith('"') && !value.endsWith('"')) return value; const decoded = parseSystemdEscapedValue(value);
if (!value.startsWith('"') || !value.endsWith('"')) return undefined; return decoded === undefined ? undefined : decodeSystemdSubstitutions(decoded, true);
return systemdUnescape(value.slice(1, -1));
} }
function systemdUnescape(value: string): string { function parseSystemdDirectiveValue(value: string): string | undefined {
const decoded = parseSystemdEscapedValue(value);
return decoded === undefined ? undefined : decodeSystemdSubstitutions(decoded, false);
}
function parseSystemdEscapedValue(value: string): string | undefined {
const quoted = value.startsWith('"') || value.endsWith('"');
if (quoted && (!value.startsWith('"') || !value.endsWith('"'))) return undefined;
return systemdUnescape(quoted ? value.slice(1, -1) : value);
}
function systemdUnescape(value: string): string | undefined {
let result = ""; let result = "";
for (let index = 0; index < value.length; index += 1) { for (let index = 0; index < value.length; index += 1) {
const character = value[index]; const character = value[index];
if (character === "\\" && index + 1 < value.length) { if (character !== "\\") {
result += value[index + 1] ?? "";
index += 1;
} else {
result += character ?? ""; result += character ?? "";
continue;
} }
const escape = value[index + 1];
if (escape === undefined) return undefined;
const simpleEscapes: Readonly<Record<string, string>> = {
"\\": "\\",
'"': '"',
"'": "'",
a: "\u0007",
b: "\b",
e: "\u001b",
f: "\f",
n: "\n",
r: "\r",
s: " ",
t: "\t",
v: "\v",
};
const simple = simpleEscapes[escape];
if (simple !== undefined) {
result += simple;
index += 1;
continue;
}
const length = escape === "x" ? 2 : escape === "u" ? 4 : escape === "U" ? 8 : 0;
if (length === 0) return undefined;
const encoded = value.slice(index + 2, index + 2 + length);
if (encoded.length !== length || !new RegExp(`^[0-9a-fA-F]{${String(length)}}$`, "u").test(encoded)) return undefined;
const codePoint = Number.parseInt(encoded, 16);
if (codePoint === 0 || codePoint > 0x10ffff) return undefined;
result += String.fromCodePoint(codePoint);
index += length + 1;
} }
return result; return result;
} }
function parseShellQuotedValue(shell: NativeServiceShell["name"], value: string): string | undefined { function decodeSystemdSubstitutions(value: string, decodeDollars: boolean): string | undefined {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== "%" && !(decodeDollars && character === "$")) {
result += character ?? "";
continue;
}
if (value[index + 1] !== character) return undefined;
result += character;
index += 1;
}
return result;
}
function parseSystemdShellCommand(shell: NativeServiceShell["name"], value: string): string | undefined {
if (value.startsWith('"') || value.endsWith('"')) return parseSystemdExecArgument(value);
if (!value.startsWith("'") || !value.endsWith("'")) return undefined; if (!value.startsWith("'") || !value.endsWith("'")) return undefined;
const inner = value.slice(1, -1); const inner = value.slice(1, -1);
if (shell === "fish") return fishSingleQuoteUnescape(inner); const unquoted = shell === "fish" ? fishSingleQuoteUnescape(inner) : inner.replaceAll("'\\''", "'");
return inner.replaceAll("'\\''", "'").replaceAll("$$", "$").replaceAll("%%", "%"); return decodeSystemdSubstitutions(unquoted, true);
} }
function fishSingleQuoteUnescape(value: string): string { function fishSingleQuoteUnescape(value: string): string {
@@ -395,16 +540,38 @@ function fishSingleQuoteUnescape(value: string): string {
result += character ?? ""; result += character ?? "";
} }
} }
return result.replaceAll("$$", "$").replaceAll("%%", "%"); return result;
} }
function launchdString(contents: string, key: string): string | null { function parseXmlStringSequence(contents: string): string[] | undefined {
const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const values: string[] = [];
const value = new RegExp(`<key>${escapedKey}<\\/key>\\s*<string>([\\s\\S]*?)<\\/string>`, "u").exec(contents)?.[1]; let cursor = 0;
return value === undefined ? null : xmlUnescape(value); for (const match of contents.matchAll(/<string>([\s\S]*?)<\/string>/gu)) {
if (contents.slice(cursor, match.index).trim() !== "") return undefined;
const value = xmlUnescapeStrict(match[1] ?? "");
if (value === undefined) return undefined;
values.push(value);
cursor = match.index + match[0].length;
}
return contents.slice(cursor).trim() === "" ? values : undefined;
} }
function xmlUnescape(value: string): string { function parseXmlStringDictionary(contents: string): Record<string, string> | undefined {
const values: Record<string, string> = {};
let cursor = 0;
for (const match of contents.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/gu)) {
if (contents.slice(cursor, match.index).trim() !== "") return undefined;
const key = xmlUnescapeStrict(match[1] ?? "");
const value = xmlUnescapeStrict(match[2] ?? "");
if (key === undefined || value === undefined || Object.hasOwn(values, key)) return undefined;
values[key] = value;
cursor = match.index + match[0].length;
}
return contents.slice(cursor).trim() === "" ? values : undefined;
}
function xmlUnescapeStrict(value: string): string | undefined {
if (/[<>]/u.test(value) || /&(?!(?:apos|quot|gt|lt|amp);)/u.test(value)) return undefined;
return value return value
.replaceAll("&apos;", "'") .replaceAll("&apos;", "'")
.replaceAll("&quot;", '"') .replaceAll("&quot;", '"')
+42 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { installNativeServiceCandidate } from "./serviceInstall.js"; import {
installNativeServiceCandidate,
nativeServiceInstallFailureNeedsPathAdvice,
} from "./serviceInstall.js";
import type { import type {
NativeServiceAuthoritativeProbe, NativeServiceAuthoritativeProbe,
NativeServicePlan, NativeServicePlan,
@@ -76,6 +79,41 @@ describe("native service install orchestration", () => {
expect(replaceServices).toHaveBeenCalledWith(expect.objectContaining({ mode: "production" })); expect(replaceServices).toHaveBeenCalledWith(expect.objectContaining({ mode: "production" }));
}); });
it("validates manager and shell readiness without executing configured overrides", async () => {
const writeInitialConfig = vi.fn<() => Promise<void>>(() => Promise.resolve());
const replaceServices = vi.fn<() => Promise<void>>(() => Promise.resolve());
const requests: NativeServiceProbeRequest[] = [];
const configuredInput: ProductionNativeServicePlanInput = {
...productionInput,
executables: {
sessiond: { ...productionInput.executables.sessiond, configuredCommand: "custom-sessiond --flag" },
web: { ...productionInput.executables.web, configuredCommand: "custom-web --flag" },
},
};
const result = await installNativeServiceCandidate(
{ mode: "production", input: configuredInput },
{
probe: {
run: (request) => {
requests.push(request);
return Promise.resolve({ kind: "infrastructure-failure", reason: "manager", message: "manager unavailable" });
},
},
fileExists: () => false,
writeInitialConfig,
replaceServices,
},
);
expect(result).toMatchObject({ ok: false, failure: { kind: "plan-validation" } });
if (result.ok) throw new Error("Expected manager validation failure");
expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(false);
expect(requests).toEqual([expect.objectContaining({ purpose: "plan-validation", prerequisites: [] })]);
expect(writeInitialConfig).not.toHaveBeenCalled();
expect(replaceServices).not.toHaveBeenCalled();
});
it("does not make durable changes when exact plan requirements are unsatisfied", async () => { it("does not make durable changes when exact plan requirements are unsatisfied", async () => {
const writeInitialConfig = vi.fn<() => Promise<void>>(() => Promise.resolve()); const writeInitialConfig = vi.fn<() => Promise<void>>(() => Promise.resolve());
const replaceServices = vi.fn<() => Promise<void>>(() => Promise.resolve()); const replaceServices = vi.fn<() => Promise<void>>(() => Promise.resolve());
@@ -99,6 +137,7 @@ describe("native service install orchestration", () => {
if (result.ok || result.failure.kind !== "plan-validation") throw new Error("Expected validation failure"); if (result.ok || result.failure.kind !== "plan-validation") throw new Error("Expected validation failure");
expect(result.failure.failures).not.toHaveLength(0); expect(result.failure.failures).not.toHaveLength(0);
expect(result.failure.failures.every((failure) => failure.kind === "prerequisite-unsatisfied")).toBe(true); expect(result.failure.failures.every((failure) => failure.kind === "prerequisite-unsatisfied")).toBe(true);
expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(true);
expect(writeInitialConfig).not.toHaveBeenCalled(); expect(writeInitialConfig).not.toHaveBeenCalled();
expect(replaceServices).not.toHaveBeenCalled(); expect(replaceServices).not.toHaveBeenCalled();
}); });
@@ -135,6 +174,8 @@ describe("native service install orchestration", () => {
}], }],
}, },
}); });
if (result.ok) throw new Error("Expected infrastructure failure");
expect(nativeServiceInstallFailureNeedsPathAdvice(result.failure)).toBe(false);
expect(writeInitialConfig).not.toHaveBeenCalled(); expect(writeInitialConfig).not.toHaveBeenCalled();
expect(replaceServices).not.toHaveBeenCalled(); expect(replaceServices).not.toHaveBeenCalled();
}); });
+11
View File
@@ -1,5 +1,6 @@
import { import {
createDevelopmentNativeServicePlan, createDevelopmentNativeServicePlan,
nativeServicePrerequisiteNeedsPathAdvice,
resolveProductionNativeServicePlan, resolveProductionNativeServicePlan,
validateNativeServicePlan, validateNativeServicePlan,
type DevelopmentNativeServicePlanInput, type DevelopmentNativeServicePlanInput,
@@ -29,6 +30,16 @@ export type NativeServiceInstallResult =
| { ok: true; plan: NativeServicePlan } | { ok: true; plan: NativeServicePlan }
| { ok: false; failure: NativeServiceInstallFailure }; | { ok: false; failure: NativeServiceInstallFailure };
export function nativeServiceInstallFailureNeedsPathAdvice(failure: NativeServiceInstallFailure): boolean {
if (failure.kind === "plan-resolution") {
return failure.failures.every((item) => item.kind === "executable-unavailable")
&& failure.failures.length > 0;
}
return failure.failures.some((item) =>
item.kind === "prerequisite-unsatisfied"
&& nativeServicePrerequisiteNeedsPathAdvice(item.prerequisite));
}
/** /**
* Keeps preflight effects ahead of durable install effects. The authoritative * Keeps preflight effects ahead of durable install effects. The authoritative
* probes may create bounded temporary artifacts, but they must clean those up * probes may create bounded temporary artifacts, but they must clean those up
+8 -1
View File
@@ -164,7 +164,14 @@ describe("production native service planning", () => {
prerequisites: [], prerequisites: [],
}, },
]); ]);
expect(planValidationProbeRequests(resolution.plan)).toEqual([]); expect(planValidationProbeRequests(resolution.plan)).toEqual([{
purpose: "plan-validation",
backend,
shell,
environment: { PI_WEB_CONFIG: "/home/user/.config/pi-web/config.json" },
workingDirectory: null,
prerequisites: [],
}]);
}); });
it("falls back per service to bundled entrypoints when named commands are unavailable", async () => { it("falls back per service to bundled entrypoints when named commands are unavailable", async () => {
+7 -3
View File
@@ -162,6 +162,7 @@ export interface DevelopmentNativeServicePlanInput {
export interface NativeServicePlanDependencies { export interface NativeServicePlanDependencies {
probe: NativeServiceAuthoritativeProbe; probe: NativeServiceAuthoritativeProbe;
/** Returns true only when the path exists and is a regular file. */
fileExists(path: string): boolean; fileExists(path: string): boolean;
} }
@@ -206,6 +207,10 @@ export type NativeServicePlanValidation =
| { ok: true } | { ok: true }
| { ok: false; failures: readonly NativeServicePlanValidationFailure[] }; | { ok: false; failures: readonly NativeServicePlanValidationFailure[] };
export function nativeServicePrerequisiteNeedsPathAdvice(prerequisite: NativeServicePrerequisite): boolean {
return prerequisite.kind === "command-available" || prerequisite.kind === "node-version";
}
export const nativeServiceManagerRefs: Readonly<Record<NativeServiceId, NativeServiceManagerRef>> = { export const nativeServiceManagerRefs: Readonly<Record<NativeServiceId, NativeServiceManagerRef>> = {
sessiond: { sessiond: {
systemdName: "pi-web-sessiond.service", systemdName: "pi-web-sessiond.service",
@@ -386,7 +391,6 @@ export function createDevelopmentNativeServicePlan(input: DevelopmentNativeServi
export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] { export function planValidationProbeRequests(plan: NativeServicePlan): readonly NativeServiceProbeRequest[] {
const requests: (Omit<NativeServiceProbeRequest, "prerequisites"> & { prerequisites: NativeServicePrerequisite[] })[] = []; const requests: (Omit<NativeServiceProbeRequest, "prerequisites"> & { prerequisites: NativeServicePrerequisite[] })[] = [];
for (const service of plan.services) { for (const service of plan.services) {
if (service.prerequisites.length === 0) continue;
const existing = requests.find((request) => const existing = requests.find((request) =>
request.workingDirectory === service.workingDirectory request.workingDirectory === service.workingDirectory
&& environmentsEqual(request.environment, service.environment)); && environmentsEqual(request.environment, service.environment));
@@ -558,7 +562,7 @@ function commandRequirement(serviceId: NativeServiceId, command: string): Native
id: commandRequirementId(serviceId, command), id: commandRequirementId(serviceId, command),
kind: "command-available", kind: "command-available",
command, command,
description: `${command} is available to the service shell`, description: `${command} resolves to an external executable for the service shell`,
}; };
} }
@@ -577,7 +581,7 @@ function readableFileRequirement(serviceId: NativeServiceId, path: string): Nati
id: `${serviceId}.entrypoint`, id: `${serviceId}.entrypoint`,
kind: "readable-file", kind: "readable-file",
path, path,
description: `bundled entrypoint is readable: ${path}`, description: `bundled entrypoint is a readable regular file: ${path}`,
}; };
} }
+137 -53
View File
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
LaunchdNativeServiceProbe, LaunchdNativeServiceProbe,
SpawnProbeCommandRunner,
SystemdNativeServiceProbe, SystemdNativeServiceProbe,
launchdProbePlist, launchdProbePlist,
nativeServicePrerequisiteShellCheck,
systemdRunArguments, systemdRunArguments,
type LaunchdProbeFileSystem, type LaunchdProbeFileSystem,
type ProbeCommandResult, type ProbeCommandResult,
@@ -74,6 +76,8 @@ describe("systemd authoritative native-service probe", () => {
"--pipe", "--pipe",
"--quiet", "--quiet",
"--unit=pi-web-authoritative-probe-fixed.service", "--unit=pi-web-authoritative-probe-fixed.service",
"--property=RuntimeMaxSec=15s",
"--property=TimeoutStopSec=5s",
"--setenv=PI_WEB_CONFIG=/home/user/config with space.json", "--setenv=PI_WEB_CONFIG=/home/user/config with space.json",
"--working-directory=/checkout with space", "--working-directory=/checkout with space",
"/usr/bin/env", "/usr/bin/env",
@@ -91,7 +95,7 @@ describe("systemd authoritative native-service probe", () => {
outcomes: [{ outcomes: [{
prerequisiteId: "sessiond.command.npm", prerequisiteId: "sessiond.command.npm",
status: "unsatisfied", status: "unsatisfied",
detail: "npm was not found in the native service environment.", detail: "npm did not resolve to an external executable in the native service environment.",
}], }],
}); });
@@ -106,7 +110,7 @@ describe("systemd authoritative native-service probe", () => {
const runner = queuedRunner([ const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" }, { kind: "timeout", stdout: "", stderr: "" },
completed(0), completed(0),
completed(0), completed(0, "not-found\n"),
]); ]);
const probe = new SystemdNativeServiceProbe({ const probe = new SystemdNativeServiceProbe({
commandRunner: runner, commandRunner: runner,
@@ -125,7 +129,7 @@ describe("systemd authoritative native-service probe", () => {
const runner = queuedRunner([ const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" }, { kind: "timeout", stdout: "", stderr: "" },
completed(0), completed(0),
completed(1, "", "unit still loaded"), completed(0, "loaded\n", "unit still loaded"),
]); ]);
const probe = new SystemdNativeServiceProbe({ const probe = new SystemdNativeServiceProbe({
commandRunner: runner, commandRunner: runner,
@@ -139,22 +143,43 @@ describe("systemd authoritative native-service probe", () => {
expect(runner.calls.map(({ command, args }) => [command, ...args.slice(0, 3)])).toEqual([ expect(runner.calls.map(({ command, args }) => [command, ...args.slice(0, 3)])).toEqual([
["systemd-run", "--user", "--wait", "--collect"], ["systemd-run", "--user", "--wait", "--collect"],
["systemctl", "--user", "stop", "pi-web-authoritative-probe-fixed.service"], ["systemctl", "--user", "stop", "pi-web-authoritative-probe-fixed.service"],
["systemctl", "--user", "reset-failed", "pi-web-authoritative-probe-fixed.service"], ["systemctl", "--user", "show", "pi-web-authoritative-probe-fixed.service"],
]); ]);
}); });
}); });
describe("spawn probe command runner", () => {
it("bounds captured command output", async () => {
const runner = new SpawnProbeCommandRunner();
const result = await runner.run(
process.execPath,
["-e", "process.stdout.write('x'.repeat(2 * 1024 * 1024))"],
5_000,
);
expect(result).toMatchObject({ kind: "output-limit" });
expect(result.stdout.length).toBeLessThanOrEqual(1024 * 1024);
});
it("settles a timeout without waiting for inherited pipes to close", async () => {
const runner = new SpawnProbeCommandRunner();
const childScript = [
"const { spawn } = require('node:child_process');",
"const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'inherit'] });",
"child.unref();",
].join(" ");
const startedAt = performance.now();
await expect(runner.run(process.execPath, ["-e", childScript], 20)).resolves.toMatchObject({ kind: "timeout" });
expect(performance.now() - startedAt).toBeLessThan(500);
});
});
describe("launchd authoritative native-service probe", () => { describe("launchd authoritative native-service probe", () => {
it("bootstraps a uniquely labelled one-shot agent in gui/<uid> and always cleans it up", async () => { it("bootstraps a uniquely labelled one-shot agent in gui/<uid> and always cleans it up", async () => {
const runner = queuedRunner([ const runner = queuedRunner([completed(0), completed(0)]);
completed(0),
completed(0, "state = running\n"),
completed(0, "state = not running\nlast exit code = 0\n"),
completed(0),
]);
const fileSystem = launchdFileSystem({ const fileSystem = launchdFileSystem({
"/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), "/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"),
"/tmp/probe/stderr.log": "",
}); });
let now = 0; let now = 0;
const probe = new LaunchdNativeServiceProbe({ const probe = new LaunchdNativeServiceProbe({
@@ -172,28 +197,28 @@ describe("launchd authoritative native-service probe", () => {
await expect(probe.run(request("launchd"))).resolves.toMatchObject({ kind: "completed" }); await expect(probe.run(request("launchd"))).resolves.toMatchObject({ kind: "completed" });
expect(runner.calls.map(({ command, args }) => [command, ...args])).toEqual([ expect(runner.calls.map(({ command, args }) => [command, ...args])).toEqual([
["launchctl", "bootstrap", "gui/501", "/tmp/probe/probe.plist"], ["launchctl", "bootstrap", "gui/501", "/tmp/probe/probe.plist"],
["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"],
["launchctl", "print", "gui/501/com.pi-web.authoritative-probe.501.fixed"],
["launchctl", "bootout", "gui/501/com.pi-web.authoritative-probe.501.fixed"], ["launchctl", "bootout", "gui/501/com.pi-web.authoritative-probe.501.fixed"],
]); ]);
expect(fileSystem.writeFile).toHaveBeenCalledWith( expect(fileSystem.writeFile).toHaveBeenCalledWith(
"/tmp/probe/probe.plist", "/tmp/probe/probe.plist",
expect.stringContaining("<string>/bin/zsh</string>"), expect.stringContaining("<string>/bin/zsh</string>"),
0o600,
); );
expect(fileSystem.writeFile).toHaveBeenCalledWith( expect(fileSystem.writeFile).toHaveBeenCalledWith(
"/tmp/probe/probe.plist", "/tmp/probe/probe.plist",
expect.stringContaining("<key>WorkingDirectory</key>\n <string>/checkout with space</string>"), expect.stringContaining("<key>WorkingDirectory</key>\n <string>/checkout with space</string>"),
0o600,
);
expect(fileSystem.writeFile).toHaveBeenCalledWith(
"/tmp/probe/probe.plist",
expect.stringContaining("/bin/mv &apos;/tmp/probe/result.pending&apos; &apos;/tmp/probe/result.log&apos;"),
0o600,
); );
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
}); });
it("times out deterministically, boots out the agent, and removes temporary files", async () => { it("times out deterministically, boots out the agent, and removes temporary files", async () => {
const runner = queuedRunner([ const runner = queuedRunner([completed(0), completed(0)]);
completed(0),
completed(0, "state = running\n"),
completed(0, "state = running\n"),
completed(0),
]);
const fileSystem = launchdFileSystem({}); const fileSystem = launchdFileSystem({});
let now = 0; let now = 0;
const probe = new LaunchdNativeServiceProbe({ const probe = new LaunchdNativeServiceProbe({
@@ -219,15 +244,34 @@ describe("launchd authoritative native-service probe", () => {
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
}); });
it("bounds a stalled result-file read before cleaning up", async () => {
const runner = queuedRunner([completed(0), completed(0)]);
const fileSystem = launchdFileSystem({});
fileSystem.readOptionalFile.mockReturnValueOnce(new Promise(() => undefined));
const probe = new LaunchdNativeServiceProbe({
commandRunner: runner,
fileSystem,
uid: 502,
createUniqueId: () => "fixed",
now: () => 0,
sleep: () => Promise.resolve(),
probeTimeoutMs: 10,
pollIntervalMs: 1,
commandTimeoutMs: 100,
});
await expect(probe.run(request("launchd"))).resolves.toMatchObject({
kind: "infrastructure-failure",
reason: "timeout",
});
expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
});
it("surfaces cleanup failure instead of returning an otherwise successful probe", async () => { it("surfaces cleanup failure instead of returning an otherwise successful probe", async () => {
const runner = queuedRunner([ const runner = queuedRunner([completed(0), completed(1, "", "bootout denied")]);
completed(0),
completed(0, "state = not running\nlast exit code = 0\n"),
completed(1, "", "bootout denied"),
]);
const fileSystem = launchdFileSystem({ const fileSystem = launchdFileSystem({
"/tmp/probe/stdout.log": marker("sessiond.command.npm", "satisfied"), "/tmp/probe/result.log": marker("sessiond.command.npm", "satisfied"),
"/tmp/probe/stderr.log": "",
}); });
const probe = new LaunchdNativeServiceProbe({ const probe = new LaunchdNativeServiceProbe({
commandRunner: runner, commandRunner: runner,
@@ -247,10 +291,9 @@ describe("launchd authoritative native-service probe", () => {
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
}); });
it("checks and boots out a label when bootstrap itself times out", async () => { it("boots out a label when bootstrap itself times out", async () => {
const runner = queuedRunner([ const runner = queuedRunner([
{ kind: "timeout", stdout: "", stderr: "" }, { kind: "timeout", stdout: "", stderr: "" },
completed(0, "state = running\n"),
completed(0), completed(0),
]); ]);
const fileSystem = launchdFileSystem({}); const fileSystem = launchdFileSystem({});
@@ -270,12 +313,15 @@ describe("launchd authoritative native-service probe", () => {
kind: "infrastructure-failure", kind: "infrastructure-failure",
reason: "timeout", reason: "timeout",
}); });
expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "print", "bootout"]); expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
}); });
it("removes temporary files when bootstrap fails without booting out an unloaded label", async () => { it("treats an explicit not-loaded bootout response as successful cleanup after bootstrap fails", async () => {
const runner = queuedRunner([completed(1, "", "bootstrap denied")]); const runner = queuedRunner([
completed(1, "", "bootstrap denied"),
completed(3, "", "Could not find service in domain"),
]);
const fileSystem = launchdFileSystem({}); const fileSystem = launchdFileSystem({});
const probe = new LaunchdNativeServiceProbe({ const probe = new LaunchdNativeServiceProbe({
commandRunner: runner, commandRunner: runner,
@@ -292,17 +338,13 @@ describe("launchd authoritative native-service probe", () => {
const result = await probe.run(request("launchd")); const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" }); expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("bootstrap denied"); expect(result.kind === "infrastructure-failure" && result.message).toContain("bootstrap denied");
expect(runner.calls).toHaveLength(1); expect(runner.calls.map(({ args }) => args[0])).toEqual(["bootstrap", "bootout"]);
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
}); });
it("cleans a loaded label after malformed launchctl output", async () => { it("cleans a loaded label after malformed private result output", async () => {
const runner = queuedRunner([ const runner = queuedRunner([completed(0), completed(0)]);
completed(0), const fileSystem = launchdFileSystem({ "/tmp/probe/result.log": "malformed result" });
completed(0, "pid = 123\n"),
completed(0),
]);
const fileSystem = launchdFileSystem({});
const probe = new LaunchdNativeServiceProbe({ const probe = new LaunchdNativeServiceProbe({
commandRunner: runner, commandRunner: runner,
fileSystem, fileSystem,
@@ -323,13 +365,10 @@ describe("launchd authoritative native-service probe", () => {
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
}); });
it("cleans a loaded label when probe output cannot be read", async () => { it("cleans a loaded label when the private result cannot be read", async () => {
const runner = queuedRunner([ const runner = queuedRunner([completed(0), completed(0)]);
completed(0),
completed(0, "state = not running\nlast exit code = 0\n"),
completed(0),
]);
const fileSystem = launchdFileSystem({}); const fileSystem = launchdFileSystem({});
fileSystem.readOptionalFile.mockRejectedValueOnce(new Error("read denied"));
const probe = new LaunchdNativeServiceProbe({ const probe = new LaunchdNativeServiceProbe({
commandRunner: runner, commandRunner: runner,
fileSystem, fileSystem,
@@ -344,13 +383,16 @@ describe("launchd authoritative native-service probe", () => {
const result = await probe.run(request("launchd")); const result = await probe.run(request("launchd"));
expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" }); expect(result).toMatchObject({ kind: "infrastructure-failure", reason: "manager" });
expect(result.kind === "infrastructure-failure" && result.message).toContain("Could not read launchd probe output"); expect(result.kind === "infrastructure-failure" && result.message).toContain("Could not read launchd probe result");
expect(runner.calls.at(-1)?.args[0]).toBe("bootout"); expect(runner.calls.at(-1)?.args[0]).toBe("bootout");
expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe"); expect(fileSystem.removeDirectory).toHaveBeenCalledWith("/tmp/probe");
}); });
it("reports temporary-file cleanup failures", async () => { it("reports temporary-file cleanup failures", async () => {
const runner = queuedRunner([completed(1, "", "bootstrap denied")]); const runner = queuedRunner([
completed(1, "", "bootstrap denied"),
completed(3, "", "Could not find service in domain"),
]);
const fileSystem = launchdFileSystem({}); const fileSystem = launchdFileSystem({});
fileSystem.removeDirectory.mockRejectedValueOnce(new Error("rm denied")); fileSystem.removeDirectory.mockRejectedValueOnce(new Error("rm denied"));
const probe = new LaunchdNativeServiceProbe({ const probe = new LaunchdNativeServiceProbe({
@@ -372,28 +414,70 @@ describe("launchd authoritative native-service probe", () => {
}); });
describe("probe service definitions", () => { describe("probe service definitions", () => {
it("requires external executables instead of accepting shell functions or aliases", () => {
const commandRequirement = request().prerequisites[0];
if (commandRequirement === undefined) throw new Error("Expected a command prerequisite");
const bashCheck = nativeServicePrerequisiteShellCheck("bash", commandRequirement);
expect(bashCheck).toContain("case \"$pi_web_probe_executable\" in */*)");
expect(bashCheck).toContain("test -f \"$pi_web_probe_executable\"");
expect(bashCheck).toContain("test -x \"$pi_web_probe_executable\"");
const fishCheck = nativeServicePrerequisiteShellCheck("fish", commandRequirement);
expect(fishCheck).toContain("string match -q '*/*'");
expect(fishCheck).toContain("test -f $pi_web_probe_executable[1]");
expect(fishCheck).toContain("test -x $pi_web_probe_executable[1]");
});
it("invokes the resolved external Node executable for version checks", () => {
const check = nativeServicePrerequisiteShellCheck("zsh", {
id: "sessiond.node",
kind: "node-version",
command: "node",
minimumMajor: 22,
description: "node >= 22",
});
expect(check).toContain("\"$pi_web_probe_executable\" '-e'");
expect(check).not.toContain("&& node -e");
});
it("requires bundled entrypoints to be readable regular files", () => {
const check = nativeServicePrerequisiteShellCheck("bash", {
id: "sessiond.entrypoint",
kind: "readable-file",
path: "/package/server.js",
description: "entrypoint",
});
expect(check).toBe("test -f '/package/server.js' && test -r '/package/server.js'");
});
it("renders backend inputs without inheriting the caller PATH", () => { it("renders backend inputs without inheriting the caller PATH", () => {
const probeRequest = request(); const probeRequest = request();
expect(systemdRunArguments(probeRequest, "probe.service", "echo ok")).not.toContain(expect.stringContaining("PATH=")); const systemdArguments = systemdRunArguments(probeRequest, "probe.service", "echo ok");
expect(systemdArguments.some((argument) => argument.includes("PATH="))).toBe(false);
const plist = launchdProbePlist(probeRequest, "com.example.probe", "echo ok", "/tmp/out", "/tmp/err"); const plist = launchdProbePlist(probeRequest, "com.example.probe", "echo ok", "/tmp/out", "/tmp/err");
expect(plist).not.toContain("<key>PATH</key>"); expect(plist).not.toContain("<key>PATH</key>");
expect(plist).toContain("<key>PI_WEB_CONFIG</key>"); expect(plist).toContain("<key>PI_WEB_CONFIG</key>");
expect(plist).toContain("<key>HardResourceLimits</key>");
});
it("escapes manager-side substitutions in the systemd probe payload", () => {
const args = systemdRunArguments(request(), "probe.service", "test -r '/tmp/$HOME/%h'");
expect(args.at(-1)).toBe("test -r '/tmp/$$HOME/%h'");
}); });
}); });
function launchdFileSystem(contents: Record<string, string>): LaunchdProbeFileSystem & { function launchdFileSystem(contents: Record<string, string>): LaunchdProbeFileSystem & {
writeFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["writeFile"]>>; writeFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["writeFile"]>>;
readOptionalFile: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["readOptionalFile"]>>;
removeDirectory: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["removeDirectory"]>>; removeDirectory: ReturnType<typeof vi.fn<LaunchdProbeFileSystem["removeDirectory"]>>;
} { } {
const writeFileMock = vi.fn<LaunchdProbeFileSystem["writeFile"]>(() => Promise.resolve()); const writeFileMock = vi.fn<LaunchdProbeFileSystem["writeFile"]>(() => Promise.resolve());
const readOptionalFileMock = vi.fn<LaunchdProbeFileSystem["readOptionalFile"]>((path) => Promise.resolve(contents[path] ?? null));
const removeDirectoryMock = vi.fn<LaunchdProbeFileSystem["removeDirectory"]>(() => Promise.resolve()); const removeDirectoryMock = vi.fn<LaunchdProbeFileSystem["removeDirectory"]>(() => Promise.resolve());
return { return {
createTemporaryDirectory: () => Promise.resolve("/tmp/probe"), createTemporaryDirectory: () => Promise.resolve("/tmp/probe"),
writeFile: writeFileMock, writeFile: writeFileMock,
readFile: (path) => { readOptionalFile: readOptionalFileMock,
const content = contents[path];
return content === undefined ? Promise.reject(new Error(`missing ${path}`)) : Promise.resolve(content);
},
removeDirectory: removeDirectoryMock, removeDirectory: removeDirectoryMock,
}; };
} }
+182 -110
View File
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir, userInfo } from "node:os"; import { tmpdir, userInfo } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { import type {
NativeServiceAuthoritativeProbe, NativeServiceAuthoritativeProbe,
@@ -15,7 +16,8 @@ import type {
export type ProbeCommandResult = export type ProbeCommandResult =
| { kind: "completed"; status: number; stdout: string; stderr: string } | { kind: "completed"; status: number; stdout: string; stderr: string }
| { kind: "timeout"; stdout: string; stderr: string } | { kind: "timeout"; stdout: string; stderr: string }
| { kind: "spawn-failure"; message: string; stdout: string; stderr: string }; | { kind: "spawn-failure"; message: string; stdout: string; stderr: string }
| { kind: "output-limit"; stdout: string; stderr: string };
export interface ProbeCommandRunner { export interface ProbeCommandRunner {
run(command: string, args: readonly string[], timeoutMs: number): Promise<ProbeCommandResult>; run(command: string, args: readonly string[], timeoutMs: number): Promise<ProbeCommandResult>;
@@ -23,8 +25,8 @@ export interface ProbeCommandRunner {
export interface LaunchdProbeFileSystem { export interface LaunchdProbeFileSystem {
createTemporaryDirectory(prefix: string): Promise<string>; createTemporaryDirectory(prefix: string): Promise<string>;
writeFile(path: string, contents: string): Promise<void>; writeFile(path: string, contents: string, mode: number): Promise<void>;
readFile(path: string): Promise<string>; readOptionalFile(path: string): Promise<string | null>;
removeDirectory(path: string): Promise<void>; removeDirectory(path: string): Promise<void>;
} }
@@ -48,6 +50,8 @@ export interface LaunchdProbeDependencies extends CommonProbeDependencies {
const defaultCommandTimeoutMs = 15_000; const defaultCommandTimeoutMs = 15_000;
const defaultProbeTimeoutMs = 15_000; const defaultProbeTimeoutMs = 15_000;
const defaultPollIntervalMs = 50; const defaultPollIntervalMs = 50;
const maxCapturedCommandOutputBytes = 1024 * 1024;
const maxLaunchdProbeFileBytes = 1024 * 1024;
export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProbe { export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProbe {
public constructor(private readonly dependencies: SystemdProbeDependencies) {} public constructor(private readonly dependencies: SystemdProbeDependencies) {}
@@ -64,12 +68,12 @@ export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProb
const args = systemdRunArguments(request, unitName, command); const args = systemdRunArguments(request, unitName, command);
const result = await this.dependencies.commandRunner.run("systemd-run", args, this.dependencies.commandTimeoutMs); const result = await this.dependencies.commandRunner.run("systemd-run", args, this.dependencies.commandTimeoutMs);
if (result.kind === "timeout") { if (result.kind === "timeout" || result.kind === "output-limit") {
const cleanupFailure = await this.cleanupTimedOutUnit(unitName); const cleanupFailure = await this.cleanupTimedOutUnit(unitName);
return cleanupFailure ?? infrastructureFailure( if (cleanupFailure !== null) return cleanupFailure;
"timeout", return result.kind === "timeout"
`Timed out waiting for transient systemd unit ${unitName}.`, ? infrastructureFailure("timeout", `Timed out waiting for transient systemd unit ${unitName}.`)
); : infrastructureFailure("manager", `Transient systemd probe ${unitName} exceeded the output limit.`);
} }
if (result.kind === "spawn-failure") { if (result.kind === "spawn-failure") {
return infrastructureFailure("manager", `Could not start systemd-run: ${result.message}`); return infrastructureFailure("manager", `Could not start systemd-run: ${result.message}`);
@@ -89,18 +93,19 @@ export class SystemdNativeServiceProbe implements NativeServiceAuthoritativeProb
["--user", "stop", unitName], ["--user", "stop", unitName],
this.dependencies.commandTimeoutMs, this.dependencies.commandTimeoutMs,
); );
if (stop.kind !== "completed" || stop.status !== 0) { const inspected = await this.dependencies.commandRunner.run(
return infrastructureFailure("cleanup", `Could not stop timed-out transient systemd unit ${unitName}: ${commandFailureDetail(stop)}`);
}
const reset = await this.dependencies.commandRunner.run(
"systemctl", "systemctl",
["--user", "reset-failed", unitName], ["--user", "show", unitName, "--property=LoadState", "--value"],
this.dependencies.commandTimeoutMs, this.dependencies.commandTimeoutMs,
); );
if (reset.kind !== "completed" || reset.status !== 0) { if (inspected.kind === "completed" && inspected.status === 0 && inspected.stdout.trim() === "not-found") {
return infrastructureFailure("cleanup", `Could not collect timed-out transient systemd unit ${unitName}: ${commandFailureDetail(reset)}`); return null;
} }
return null; const details = [
`stop: ${commandFailureDetail(stop)}`,
`load state: ${commandFailureDetail(inspected)}`,
].join("; ");
return infrastructureFailure("cleanup", `Could not confirm cleanup of timed-out transient systemd unit ${unitName}: ${details}`);
} }
} }
@@ -128,10 +133,18 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb
const plistPath = join(directory, "probe.plist"); const plistPath = join(directory, "probe.plist");
const stdoutPath = join(directory, "stdout.log"); const stdoutPath = join(directory, "stdout.log");
const stderrPath = join(directory, "stderr.log"); const stderrPath = join(directory, "stderr.log");
const command = prerequisiteProbeCommand(request.shell.name, request.prerequisites, outputPrefix); const pendingResultPath = join(directory, "result.pending");
const resultPath = join(directory, "result.log");
const command = prerequisiteProbeCommand(
request.shell.name,
request.prerequisites,
outputPrefix,
{ pendingPath: pendingResultPath, completedPath: resultPath },
);
await this.dependencies.fileSystem.writeFile( await this.dependencies.fileSystem.writeFile(
plistPath, plistPath,
launchdProbePlist(request, label, command, stdoutPath, stderrPath), launchdProbePlist(request, label, command, stdoutPath, stderrPath),
0o600,
); );
const bootstrap = await this.dependencies.commandRunner.run( const bootstrap = await this.dependencies.commandRunner.run(
@@ -140,11 +153,11 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb
this.dependencies.commandTimeoutMs, this.dependencies.commandTimeoutMs,
); );
if (bootstrap.kind !== "completed" || bootstrap.status !== 0) { if (bootstrap.kind !== "completed" || bootstrap.status !== 0) {
bootstrapState = bootstrap.kind === "timeout" ? "uncertain" : "not-loaded"; bootstrapState = bootstrap.kind === "spawn-failure" ? "not-loaded" : "uncertain";
result = commandInfrastructureFailure("bootstrap launchd probe", bootstrap); result = commandInfrastructureFailure("bootstrap launchd probe", bootstrap);
} else { } else {
bootstrapState = "loaded"; bootstrapState = "loaded";
result = await this.waitForResult(target, stdoutPath, stderrPath, request.prerequisites, outputPrefix); result = await this.waitForResult(target, resultPath, request.prerequisites, outputPrefix);
} }
} catch (error: unknown) { } catch (error: unknown) {
result = infrastructureFailure("manager", `Could not prepare launchd probe: ${errorMessage(error)}`); result = infrastructureFailure("manager", `Could not prepare launchd probe: ${errorMessage(error)}`);
@@ -156,48 +169,25 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb
private async waitForResult( private async waitForResult(
target: string, target: string,
stdoutPath: string, resultPath: string,
stderrPath: string,
prerequisites: readonly NativeServicePrerequisite[], prerequisites: readonly NativeServicePrerequisite[],
outputPrefix: string, outputPrefix: string,
): Promise<NativeServiceProbeResult> { ): Promise<NativeServiceProbeResult> {
const deadline = this.dependencies.now() + this.dependencies.probeTimeoutMs; const deadline = this.dependencies.now() + this.dependencies.probeTimeoutMs;
while (this.dependencies.now() < deadline) { while (this.dependencies.now() < deadline) {
const printed = await this.dependencies.commandRunner.run( const remainingMs = Math.max(0, deadline - this.dependencies.now());
"launchctl", const boundedRead = await readOptionalFileBounded(
["print", target], this.dependencies.fileSystem,
this.dependencies.commandTimeoutMs, resultPath,
remainingMs,
); );
if (printed.kind !== "completed" || printed.status !== 0) { if (boundedRead.kind === "deadline") break;
return commandInfrastructureFailure("inspect launchd probe", printed); if (boundedRead.kind === "read-failure") {
return infrastructureFailure("manager", `Could not read launchd probe result: ${errorMessage(boundedRead.error)}`);
} }
if (boundedRead.output !== null) return parseProbeOutput(boundedRead.output, prerequisites, outputPrefix);
const state = launchdField(printed.stdout, "state"); const pollDelayMs = Math.min(this.dependencies.pollIntervalMs, Math.max(0, deadline - this.dependencies.now()));
if (state === undefined) { await this.dependencies.sleep(pollDelayMs);
return infrastructureFailure("malformed-output", `launchctl returned no state for ${target}.`);
}
const lastExitCode = launchdIntegerField(printed.stdout, "last exit code");
if (state === "not running" && lastExitCode !== undefined) {
let stdout: string;
let stderr: string;
try {
[stdout, stderr] = await Promise.all([
this.dependencies.fileSystem.readFile(stdoutPath),
this.dependencies.fileSystem.readFile(stderrPath),
]);
} catch (error: unknown) {
return infrastructureFailure("manager", `Could not read launchd probe output: ${errorMessage(error)}`);
}
if (lastExitCode !== 0) {
return infrastructureFailure(
"manager",
`Launchd probe service exited with status ${String(lastExitCode)}: ${firstOutput(stderr, stdout, "no output")}`,
);
}
return parseProbeOutput(stdout, prerequisites, outputPrefix);
}
await this.dependencies.sleep(this.dependencies.pollIntervalMs);
} }
return infrastructureFailure("timeout", `Timed out waiting for launchd probe ${target}.`); return infrastructureFailure("timeout", `Timed out waiting for launchd probe ${target}.`);
} }
@@ -208,28 +198,21 @@ export class LaunchdNativeServiceProbe implements NativeServiceAuthoritativeProb
bootstrapState: "not-loaded" | "loaded" | "uncertain", bootstrapState: "not-loaded" | "loaded" | "uncertain",
): Promise<NativeServiceProbeResult | null> { ): Promise<NativeServiceProbeResult | null> {
const failures: string[] = []; const failures: string[] = [];
let shouldBootout = bootstrapState === "loaded"; const shouldBootout = bootstrapState !== "not-loaded";
if (bootstrapState === "uncertain") {
const inspection = await this.dependencies.commandRunner.run(
"launchctl",
["print", target],
this.dependencies.commandTimeoutMs,
);
if (inspection.kind === "completed") {
shouldBootout = inspection.status === 0;
} else {
// If launchctl cannot tell us whether a timed-out bootstrap loaded the
// label, bootout is the only operation that can make cleanup certain.
shouldBootout = true;
}
}
if (shouldBootout) { if (shouldBootout) {
// A failed or timed-out bootstrap may still have loaded the unique label.
// Bootout is the only race-free cleanup; an explicit not-loaded response
// is success when bootstrap completion was uncertain.
const absenceIsSuccess = bootstrapState === "uncertain";
const bootout = await this.dependencies.commandRunner.run( const bootout = await this.dependencies.commandRunner.run(
"launchctl", "launchctl",
["bootout", target], ["bootout", target],
this.dependencies.commandTimeoutMs, this.dependencies.commandTimeoutMs,
); );
if (bootout.kind !== "completed" || bootout.status !== 0) { if (
(bootout.kind !== "completed" || bootout.status !== 0)
&& !(absenceIsSuccess && launchdTargetNotLoaded(bootout))
) {
failures.push(`bootout failed: ${commandFailureDetail(bootout)}`); failures.push(`bootout failed: ${commandFailureDetail(bootout)}`);
} }
} }
@@ -258,7 +241,7 @@ export function createNativeServiceAuthoritativeProbe(): NativeServiceAuthoritat
...common, ...common,
fileSystem: nodeLaunchdProbeFileSystem, fileSystem: nodeLaunchdProbeFileSystem,
uid: userInfo().uid, uid: userInfo().uid,
now: Date.now, now: performance.now.bind(performance),
sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
probeTimeoutMs: defaultProbeTimeoutMs, probeTimeoutMs: defaultProbeTimeoutMs,
pollIntervalMs: defaultPollIntervalMs, pollIntervalMs: defaultPollIntervalMs,
@@ -280,15 +263,21 @@ export function systemdRunArguments(
"--pipe", "--pipe",
"--quiet", "--quiet",
`--unit=${unitName}`, `--unit=${unitName}`,
"--property=RuntimeMaxSec=15s",
"--property=TimeoutStopSec=5s",
...Object.entries(request.environment).map(([key, value]) => `--setenv=${key}=${value}`), ...Object.entries(request.environment).map(([key, value]) => `--setenv=${key}=${value}`),
...(request.workingDirectory === null ? [] : [`--working-directory=${request.workingDirectory}`]), ...(request.workingDirectory === null ? [] : [`--working-directory=${request.workingDirectory}`]),
"/usr/bin/env", "/usr/bin/env",
request.shell.executable, escapeSystemdCommandExpansion(request.shell.executable),
"-lc", "-lc",
shellCommand, escapeSystemdCommandExpansion(shellCommand),
]; ];
} }
function escapeSystemdCommandExpansion(value: string): string {
return value.replaceAll("$", () => "$$");
}
export function launchdProbePlist( export function launchdProbePlist(
request: NativeServiceProbeRequest, request: NativeServiceProbeRequest,
label: string, label: string,
@@ -316,36 +305,63 @@ ${argumentsXml}
</array> </array>
${workingDirectoryXml}${environmentXml} <key>RunAtLoad</key> ${workingDirectoryXml}${environmentXml} <key>RunAtLoad</key>
<true/> <true/>
<key>HardResourceLimits</key>
<dict>
<key>FileSize</key>
<integer>${String(maxLaunchdProbeFileBytes)}</integer>
</dict>
${plistString("StandardOutPath", stdoutPath)}${plistString("StandardErrorPath", stderrPath)}</dict> ${plistString("StandardOutPath", stdoutPath)}${plistString("StandardErrorPath", stderrPath)}</dict>
</plist> </plist>
`; `;
} }
class SpawnProbeCommandRunner implements ProbeCommandRunner { export class SpawnProbeCommandRunner implements ProbeCommandRunner {
public run(command: string, args: readonly string[], timeoutMs: number): Promise<ProbeCommandResult> { public run(command: string, args: readonly string[], timeoutMs: number): Promise<ProbeCommandResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let capturedBytes = 0;
let spawnFailure: string | null = null; let spawnFailure: string | null = null;
let timedOut = false; let settled = false;
const finish = (result: ProbeCommandResult, terminate: boolean): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (terminate) {
child.kill("SIGKILL");
child.stdout.destroy();
child.stderr.destroy();
child.unref();
}
resolve(result);
};
const capture = (stream: "stdout" | "stderr", chunk: string): void => {
if (settled) return;
const bytes = Buffer.byteLength(chunk);
if (capturedBytes + bytes > maxCapturedCommandOutputBytes) {
finish({ kind: "output-limit", stdout, stderr }, true);
return;
}
capturedBytes += bytes;
if (stream === "stdout") stdout += chunk;
else stderr += chunk;
};
child.stdout.setEncoding("utf8"); child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8"); child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => { stdout += chunk; }); child.stdout.on("data", (chunk: string) => { capture("stdout", chunk); });
child.stderr.on("data", (chunk: string) => { stderr += chunk; }); child.stderr.on("data", (chunk: string) => { capture("stderr", chunk); });
child.on("error", (error) => { spawnFailure = error.message; }); child.on("error", (error) => { spawnFailure = error.message; });
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
timedOut = true; finish({ kind: "timeout", stdout, stderr }, true);
child.kill("SIGKILL");
}, timeoutMs); }, timeoutMs);
child.on("close", (status) => { child.on("close", (status) => {
clearTimeout(timeout); if (spawnFailure !== null) {
if (timedOut) { finish({ kind: "spawn-failure", message: spawnFailure, stdout, stderr }, false);
resolve({ kind: "timeout", stdout, stderr });
} else if (spawnFailure !== null) {
resolve({ kind: "spawn-failure", message: spawnFailure, stdout, stderr });
} else { } else {
resolve({ kind: "completed", status: status ?? 1, stdout, stderr }); finish({ kind: "completed", status: status ?? 1, stdout, stderr }, false);
} }
}); });
}); });
@@ -354,49 +370,109 @@ class SpawnProbeCommandRunner implements ProbeCommandRunner {
const nodeLaunchdProbeFileSystem: LaunchdProbeFileSystem = { const nodeLaunchdProbeFileSystem: LaunchdProbeFileSystem = {
createTemporaryDirectory: (prefix) => mkdtemp(prefix), createTemporaryDirectory: (prefix) => mkdtemp(prefix),
writeFile: (path, contents) => writeFile(path, contents, "utf8"), writeFile: (path, contents, mode) => writeFile(path, contents, { encoding: "utf8", mode }),
readFile: (path) => readFile(path, "utf8"), readOptionalFile: async (path) => {
try {
return await readFile(path, "utf8");
} catch (error: unknown) {
if (isNodeErrorWithCode(error, "ENOENT")) return null;
throw error;
}
},
removeDirectory: (path) => rm(path, { recursive: true, force: true }), removeDirectory: (path) => rm(path, { recursive: true, force: true }),
}; };
function readOptionalFileBounded(
fileSystem: LaunchdProbeFileSystem,
path: string,
timeoutMs: number,
): Promise<
| { kind: "read"; output: string | null }
| { kind: "read-failure"; error: unknown }
| { kind: "deadline" }
> {
return new Promise((resolve) => {
let settled = false;
const finish = (result:
| { kind: "read"; output: string | null }
| { kind: "read-failure"; error: unknown }
| { kind: "deadline" }): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(result);
};
const timeout = setTimeout(() => { finish({ kind: "deadline" }); }, timeoutMs);
void fileSystem.readOptionalFile(path).then(
(output) => { finish({ kind: "read", output }); },
(error: unknown) => { finish({ kind: "read-failure", error }); },
);
});
}
function prerequisiteProbeCommand( function prerequisiteProbeCommand(
shell: NativeServiceShellName, shell: NativeServiceShellName,
prerequisites: readonly NativeServicePrerequisite[], prerequisites: readonly NativeServicePrerequisite[],
outputPrefix: string, outputPrefix: string,
resultFiles?: { pendingPath: string; completedPath: string },
): string { ): string {
return prerequisites.map((prerequisite) => { const markerPath = resultFiles?.pendingPath;
const checks = prerequisites.map((prerequisite) => {
const check = nativeServicePrerequisiteShellCheck(shell, prerequisite); const check = nativeServicePrerequisiteShellCheck(shell, prerequisite);
const encodedId = Buffer.from(prerequisite.id, "utf8").toString("base64"); const encodedId = Buffer.from(prerequisite.id, "utf8").toString("base64");
const satisfied = markerCommand(shell, outputPrefix, encodedId, "satisfied"); const satisfied = markerCommand(shell, outputPrefix, encodedId, "satisfied", markerPath);
const unsatisfied = markerCommand(shell, outputPrefix, encodedId, "unsatisfied"); const unsatisfied = markerCommand(shell, outputPrefix, encodedId, "unsatisfied", markerPath);
return `${check} >/dev/null 2>&1 && ${satisfied} || ${unsatisfied}`; return `${check} >/dev/null 2>&1 && ${satisfied} || ${unsatisfied}`;
}).join("; ") || ":"; }).join("; ") || ":";
if (resultFiles === undefined) return checks;
const pending = shellQuote(shell, resultFiles.pendingPath);
const completed = shellQuote(shell, resultFiles.completedPath);
return `printf '%s' '' > ${pending}; ${checks}; /bin/mv ${pending} ${completed}`;
} }
export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string { export function nativeServicePrerequisiteShellCheck(shell: NativeServiceShellName, prerequisite: NativeServicePrerequisite): string {
switch (prerequisite.kind) { switch (prerequisite.kind) {
case "command-available": case "command-available":
return `command -v ${shellQuote(shell, prerequisite.command)}`; return externalExecutableShellCheck(shell, prerequisite.command);
case "node-version": { case "node-version": {
const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`; const script = `const major=Number(process.versions.node.split('.')[0]);process.exit(major>=${String(prerequisite.minimumMajor)}?0:1)`;
return `node -e ${shellQuote(shell, script)}`; return externalExecutableShellCheck(shell, "node", ["-e", script]);
}
case "readable-file": {
const path = shellQuote(shell, prerequisite.path);
return `test -f ${path} && test -r ${path}`;
} }
case "readable-file":
return `test -r ${shellQuote(shell, prerequisite.path)}`;
case "package-scripts": { case "package-scripts": {
const script = "const p=require(process.argv[1]);const names=process.argv.slice(2);process.exit(names.every((name)=>typeof p.scripts?.[name]==='string')?0:1)"; const script = "const p=require(process.argv[1]);const names=process.argv.slice(2);process.exit(names.every((name)=>typeof p.scripts?.[name]==='string')?0:1)";
return ["node", "-e", shellQuote(shell, script), shellQuote(shell, prerequisite.packageJsonPath), ...prerequisite.scripts.map((name) => shellQuote(shell, name))].join(" "); return externalExecutableShellCheck(shell, "node", ["-e", script, prerequisite.packageJsonPath, ...prerequisite.scripts]);
} }
} }
} }
function externalExecutableShellCheck(
shell: NativeServiceShellName,
command: string,
arguments_: readonly string[] = [],
): string {
const quotedCommand = shellQuote(shell, command);
const quotedArguments = arguments_.map((argument) => shellQuote(shell, argument)).join(" ");
if (shell === "fish") {
const invocation = quotedArguments === "" ? "" : `; and $pi_web_probe_executable[1] ${quotedArguments}`;
return `set -l pi_web_probe_executable (command -v ${quotedCommand}); and test (count $pi_web_probe_executable) -eq 1; and string match -q '*/*' -- $pi_web_probe_executable[1]; and test -f $pi_web_probe_executable[1]; and test -x $pi_web_probe_executable[1]${invocation}`;
}
const invocation = quotedArguments === "" ? "" : ` && "$pi_web_probe_executable" ${quotedArguments}`;
return `pi_web_probe_executable=$(command -v ${quotedCommand}) && case "$pi_web_probe_executable" in */*) test -f "$pi_web_probe_executable" && test -x "$pi_web_probe_executable"${invocation};; *) false;; esac`;
}
function markerCommand( function markerCommand(
shell: NativeServiceShellName, shell: NativeServiceShellName,
outputPrefix: string, outputPrefix: string,
encodedId: string, encodedId: string,
status: "satisfied" | "unsatisfied", status: "satisfied" | "unsatisfied",
outputPath?: string,
): string { ): string {
return `printf '%s\\t%s\\t%s\\n' ${shellQuote(shell, outputPrefix)} ${shellQuote(shell, encodedId)} ${shellQuote(shell, status)}`; const redirect = outputPath === undefined ? "" : ` >> ${shellQuote(shell, outputPath)}`;
return `printf '%s\\t%s\\t%s\\n' ${shellQuote(shell, outputPrefix)} ${shellQuote(shell, encodedId)} ${shellQuote(shell, status)}${redirect}`;
} }
function parseProbeOutput( function parseProbeOutput(
@@ -440,11 +516,11 @@ function parseProbeOutput(
function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string { function unsatisfiedDetail(prerequisite: NativeServicePrerequisite): string {
switch (prerequisite.kind) { switch (prerequisite.kind) {
case "command-available": case "command-available":
return `${prerequisite.command} was not found in the native service environment.`; return `${prerequisite.command} did not resolve to an external executable in the native service environment.`;
case "node-version": case "node-version":
return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`; return `node >= ${String(prerequisite.minimumMajor)} was not available in the native service environment.`;
case "readable-file": case "readable-file":
return `${prerequisite.path} was not readable in the native service environment.`; return `${prerequisite.path} was not a readable regular file in the native service environment.`;
case "package-scripts": case "package-scripts":
return `${prerequisite.packageJsonPath} did not provide scripts ${prerequisite.scripts.join(", ")} in the native service environment.`; return `${prerequisite.packageJsonPath} did not provide scripts ${prerequisite.scripts.join(", ")} in the native service environment.`;
} }
@@ -461,18 +537,9 @@ function safeUniqueId(value: string): string {
return safe === "" ? "probe" : safe; return safe === "" ? "probe" : safe;
} }
function launchdField(output: string, field: string): string | undefined { function launchdTargetNotLoaded(result: ProbeCommandResult): boolean {
return new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*(.+)$`, "mu").exec(output)?.[1]?.trim(); if (result.kind !== "completed" || result.status === 0) return false;
} return /(?:could not find (?:specified )?service|service not found|no such process)/iu.test(`${result.stderr}\n${result.stdout}`);
function launchdIntegerField(output: string, field: string): number | undefined {
const value = launchdField(output, field);
if (value === undefined || !/^-?\d+$/u.test(value)) return undefined;
return Number(value);
}
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
} }
function plistString(key: string, value: string, indent = " "): string { function plistString(key: string, value: string, indent = " "): string {
@@ -496,6 +563,7 @@ function commandInfrastructureFailure(action: string, result: ProbeCommandResult
function commandFailureDetail(result: ProbeCommandResult): string { function commandFailureDetail(result: ProbeCommandResult): string {
if (result.kind === "timeout") return "command timed out"; if (result.kind === "timeout") return "command timed out";
if (result.kind === "spawn-failure") return result.message; if (result.kind === "spawn-failure") return result.message;
if (result.kind === "output-limit") return "command output exceeded the capture limit";
return firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`); return firstOutput(result.stderr, result.stdout, `exit status ${String(result.status)}`);
} }
@@ -517,3 +585,7 @@ function firstOutput(...values: string[]): string {
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
+23 -2
View File
@@ -34,12 +34,33 @@ describe("native service rendering", () => {
expect(unit).toContain("Description=PI WEB UI dev server"); expect(unit).toContain("Description=PI WEB UI dev server");
expect(unit).toContain("After=pi-web-sessiond.service\nWants=pi-web-sessiond.service"); expect(unit).toContain("After=pi-web-sessiond.service\nWants=pi-web-sessiond.service");
expect(unit).toContain('WorkingDirectory="/checkout with space"'); expect(unit).toContain("WorkingDirectory=/checkout\\x20with\\x20space");
expect(unit).toContain('Environment="PI_WEB_CONFIG=/home/user/config with \\"quote\\".json"'); expect(unit).toContain('Environment="PI_WEB_CONFIG=/home/user/config with \\"quote\\".json"');
expect(unit).toContain("ExecStart=/usr/bin/env /bin/zsh -lc 'exec /usr/bin/env bash -c '\\''trap"); expect(unit).toContain('ExecStart=/usr/bin/env "/bin/zsh" -lc "exec /usr/bin/env bash -c \'trap \\"kill 0\\" EXIT;');
expect(unit).toContain("Restart=no"); expect(unit).toContain("Restart=no");
}); });
it("escapes systemd specifiers and line controls without changing directives", () => {
const plan = createDevelopmentNativeServicePlan({
backend: { kind: "systemd", label: "systemd" },
shell: {
name: "bash",
executable: "/shell $HOME/%h/bash",
source: "detected",
detectedExecutable: "/shell $HOME/%h/bash",
},
environment: { PI_WEB_CONFIG: "/config/%h\nEnvironment=INJECTED=yes" },
workingDirectory: "/checkout %h\nwith newline",
packageJsonPath: "/checkout/package.json",
});
const unit = renderSystemdUnit(plan, planService(plan, 0));
expect(unit).toContain("WorkingDirectory=/checkout\\x20%%h\\nwith\\x20newline");
expect(unit).toContain('Environment="PI_WEB_CONFIG=/config/%%h\\nEnvironment=INJECTED=yes"');
expect(unit).toContain('ExecStart=/usr/bin/env "/shell $$HOME/%%h/bash"');
expect(unit.match(/^Environment=/gmu)).toHaveLength(1);
});
it("renders launchd entirely from the canonical plan", () => { it("renders launchd entirely from the canonical plan", () => {
const plan = developmentPlan("launchd"); const plan = developmentPlan("launchd");
const plist = renderLaunchdPlist(plan, planService(plan, 0), "/logs"); const plist = renderLaunchdPlist(plan, planService(plan, 0), "/logs");
+26 -16
View File
@@ -3,7 +3,6 @@ import type {
NativeServiceId, NativeServiceId,
NativeServicePlan, NativeServicePlan,
NativeServicePlanService, NativeServicePlanService,
NativeServiceShellName,
} from "./servicePlan.js"; } from "./servicePlan.js";
export function renderSystemdUnit( export function renderSystemdUnit(
@@ -14,7 +13,7 @@ export function renderSystemdUnit(
assertBackend(plan, "systemd"); assertBackend(plan, "systemd");
const workingDirectory = service.workingDirectory === null const workingDirectory = service.workingDirectory === null
? "" ? ""
: `WorkingDirectory=${systemdQuotedValue(service.workingDirectory)}\n`; : `WorkingDirectory=${systemdPathValue(service.workingDirectory)}\n`;
const restart = service.restart === "on-failure" const restart = service.restart === "on-failure"
? "Restart=on-failure\nRestartSec=2\n" ? "Restart=on-failure\nRestartSec=2\n"
: "Restart=no\n"; : "Restart=no\n";
@@ -22,7 +21,7 @@ export function renderSystemdUnit(
Description=${service.description} Description=${service.description}
${systemdDependencyLine(plan, "After", service.after)}${systemdDependencyLine(plan, "Wants", service.wants)}[Service] ${systemdDependencyLine(plan, "After", service.after)}${systemdDependencyLine(plan, "Wants", service.wants)}[Service]
Type=simple Type=simple
${workingDirectory}${systemdEnvironmentLines(service.environment)}ExecStart=/usr/bin/env ${plan.shell.executable} -lc ${systemdServiceShellQuote(plan.shell.name, service.shellCommand)} ${workingDirectory}${systemdEnvironmentLines(service.environment)}ExecStart=/usr/bin/env ${systemdExecArgument(plan.shell.executable)} -lc ${systemdExecArgument(service.shellCommand)}
${restart} ${restart}
[Install] [Install]
WantedBy=default.target WantedBy=default.target
@@ -83,20 +82,37 @@ function systemdDependencyLine(
function systemdEnvironmentLines(environment: Readonly<Record<string, string>>): string { function systemdEnvironmentLines(environment: Readonly<Record<string, string>>): string {
return Object.entries(environment) return Object.entries(environment)
.map(([key, value]) => `Environment="${systemdEscape(key)}=${systemdEscape(value)}"\n`) .map(([key, value]) => `Environment=${systemdQuotedDirectiveValue(`${key}=${value}`)}\n`)
.join(""); .join("");
} }
function systemdServiceShellQuote(shell: NativeServiceShellName, value: string): string { function systemdExecArgument(value: string): string {
return shellQuote(shell, value.replaceAll("%", "%%").replaceAll("$", "$$")); return `"${systemdEscape(value.replaceAll("%", "%%").replaceAll("$", () => "$$"), false)}"`;
} }
function systemdQuotedValue(value: string): string { function systemdQuotedDirectiveValue(value: string): string {
return `"${systemdEscape(value)}"`; return `"${systemdEscape(value.replaceAll("%", "%%"), false)}"`;
} }
function systemdEscape(value: string): string { function systemdPathValue(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); return systemdEscape(value.replaceAll("%", "%%"), true);
}
function systemdEscape(value: string, escapeSpaces: boolean): string {
let escaped = "";
for (const character of value) {
const code = character.codePointAt(0) ?? 0;
if (character === "\\") escaped += "\\\\";
else if (character === '"') escaped += escapeSpaces ? "\\x22" : '\\"';
else if (character === "'" && escapeSpaces) escaped += "\\x27";
else if (character === " " && escapeSpaces) escaped += "\\x20";
else if (character === "\n") escaped += "\\n";
else if (character === "\r") escaped += "\\r";
else if (character === "\t") escaped += "\\t";
else if (code < 0x20 || code === 0x7f) escaped += `\\x${code.toString(16).padStart(2, "0")}`;
else escaped += character;
}
return escaped;
} }
function plistProgramArguments(arguments_: readonly string[]): string { function plistProgramArguments(arguments_: readonly string[]): string {
@@ -113,12 +129,6 @@ function plistString(key: string, value: string, indent = " "): string {
return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`; return `${indent}<key>${xmlEscape(key)}</key>\n${indent}<string>${xmlEscape(value)}</string>\n`;
} }
function shellQuote(shell: NativeServiceShellName, value: string): string {
return shell === "fish"
? `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`
: `'${value.replaceAll("'", "'\\''")}'`;
}
function xmlEscape(value: string): string { function xmlEscape(value: string): string {
return value return value
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")