Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/quiet-daemons-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 3 additions & 2 deletions test/cli/install-vm/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Optional Firecracker install compatibility suite

This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets.
This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, authenticated daemon upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: `bun install`, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets.

## Run

Expand All @@ -13,6 +13,7 @@ Requirements:
```sh
bun run test:install-vm -- --list
bun run test:install-vm -- --scenario pnpm-global-upgrade
bun run test:install-vm -- --scenario authenticated-daemon-upgrade
bun run test:install-vm
```

Expand All @@ -28,7 +29,7 @@ The runner deliberately does not reclaim a stale `tmp/install-vm/.lock`, because
owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and
remove that lock directory manually before retrying.

Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts <result.json>`.
Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Fixture preparation also compiles two full Hunk binaries from isolated copies of the exact checkout and reflink-capable dependency snapshots: the current authenticated daemon revision and the immediately preceding incompatible revision. The checkout `sourceIdentity` remains source-only; a separate `daemonUpgradeBuildInputIdentity` frames and hashes the ignored `node_modules` snapshot bytes and contained symlink targets plus the Bun executable bytes/version used by the builder. External dependency symlinks are rejected, and an isolated PATH entry forces nested build commands to use that attested Bun executable. The fixture manifest binds both compiled binary SHA-256 digests, which the guest compares with the live A/B daemon executables. Temporary source, dependency, and package staging trees are removed before fixtures become visible. The daemon-upgrade scenario therefore adds two compilation passes and about one production idle timeout to a targeted run. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from `pins.json`. Results include `result.json`, `junit.xml`, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Scenario-specific required-evidence contracts bind required command IDs to exact expectation semantics and make missing daemon lifecycle proof fail aggregation and release validation. The daemon scenario stops one exact test-owned upgraded client across incumbent retirement, waits for the other original client to register on the successor, then resumes and verifies the delayed PID/start-token registers without restart; it never signals a daemon. Release validation rejects symlinked or escaping evidence and reads the referenced health, metadata, executable, fixture-manifest, warning, command-log, and session-list artifacts from the run directory instead of trusting result labels alone. It also requires the locally reusable fixture set to pass checkout, build-input, and package checksum verification, then extracts and hashes the actual A/B package binaries as an independent digest trust input; missing or stale local fixtures fail validation. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with `bun run ./test/cli/install-vm/validate-release-result.ts <result.json>`. A targeted development run can be checked with `--scenario <id>`; that explicit subset check is not complete release evidence.

## Security boundary

Expand Down
40 changes: 40 additions & 0 deletions test/cli/install-vm/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,46 @@ describe("install VM contract", () => {
scenarios: [{ ...manifest.scenarios[0], script: "../escape.sh" }],
}),
).toThrow("unsafe script path");
expect(
validateScenarioManifest({
schemaVersion: 1,
scenarios: [
{
...manifest.scenarios[0],
requiredEvidence: {
commands: ["run-upgrade"],
commandExpectations: { "run-upgrade": "exit 0" },
assertions: ["daemon-preserved"],
observations: ["oldDaemonPid", "transcriptPath"],
},
},
],
}).scenarios[0]?.requiredEvidence,
).toEqual({
commands: ["run-upgrade"],
commandExpectations: { "run-upgrade": "exit 0" },
assertions: ["daemon-preserved"],
observations: ["oldDaemonPid", "transcriptPath"],
});
for (const requiredEvidence of [
{ commands: ["duplicate", "duplicate"] },
{ commands: ["run-upgrade"], commandExpectations: {} },
{ commands: ["run-upgrade"], commandExpectations: { other: "exit 0" } },
{
commands: ["run-upgrade"],
commandExpectations: { "run-upgrade": "anything passed" },
},
{ assertions: ["Uppercase"] },
{ observations: ["not-kebab-case"] },
{ unknown: ["value"] },
]) {
expect(() =>
validateScenarioManifest({
schemaVersion: 1,
scenarios: [{ ...manifest.scenarios[0], requiredEvidence }],
}),
).toThrow();
}
});

test("treats expected nonzero commands as passes only when diagnostics match", () => {
Expand Down
94 changes: 94 additions & 0 deletions test/cli/install-vm/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import path from "node:path";
export type InstallVmProfile = "minimal" | "node";
export type InstallVmNetwork = "local" | "live";

export interface InstallVmScenarioRequiredEvidence {
commands?: string[];
commandExpectations?: Record<string, string>;
assertions?: string[];
observations?: string[];
}

export interface InstallVmScenario {
id: string;
description: string;
profile: InstallVmProfile;
script: string;
network: InstallVmNetwork;
requiredEvidence?: InstallVmScenarioRequiredEvidence;
}

export interface InstallVmScenarioManifest {
Expand Down Expand Up @@ -97,6 +105,36 @@ export interface InstallVmPins {
const SCENARIO_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
const ZERO_EXIT_COMMAND_EXPECTATIONS = new Set([
"background PTY remains live",
"SIGSTOP exact owned B client",
"SIGCONT exact owned B client",
]);

/** Validate one expectation against the closed install-VM command grammar. */
export function validateInstallVmCommandExpectation(expectation: string, exitCode?: number) {
const exitMatch = /^exit (-?(?:0|[1-9][0-9]*))$/.exec(expectation);
if (exitMatch) {
if (exitCode !== undefined && exitCode !== Number(exitMatch[1])) {
throw new Error(`Install VM command has impossible exit expectation: ${expectation}.`);
}
return;
}
if (expectation === "nonzero exit") {
if (exitCode === 0) {
throw new Error("Install VM command has impossible nonzero exit expectation.");
}
return;
}
if (expectation === "observed exit") return;
if (ZERO_EXIT_COMMAND_EXPECTATIONS.has(expectation)) {
if (exitCode !== undefined && exitCode !== 0) {
throw new Error(`Install VM command has impossible zero-exit expectation: ${expectation}.`);
}
return;
}
throw new Error(`Install VM command has unsupported expectation: ${expectation}.`);
}

/** Validate checksum-attested VM inputs and exact versions used by compatibility scenarios. */
export function validateInstallVmPins(value: unknown) {
Expand Down Expand Up @@ -218,6 +256,62 @@ export function validateScenarioManifest(value: unknown): InstallVmScenarioManif
if (path.basename(scenario.script) !== scenario.script || !scenario.script.endsWith(".sh")) {
throw new Error(`Scenario ${scenario.id} has an unsafe script path.`);
}
if (scenario.requiredEvidence !== undefined) {
if (!scenario.requiredEvidence || typeof scenario.requiredEvidence !== "object") {
throw new Error(`Scenario ${scenario.id} has malformed required evidence.`);
}
const requiredEvidence = scenario.requiredEvidence as Record<string, unknown>;
const expectedKeys = ["commands", "commandExpectations", "assertions", "observations"];
if (Object.keys(requiredEvidence).some((key) => !expectedKeys.includes(key))) {
throw new Error(`Scenario ${scenario.id} has unknown required evidence.`);
}
for (const key of ["commands", "assertions", "observations"] as const) {
const entries = requiredEvidence[key];
if (entries === undefined) continue;
if (
!Array.isArray(entries) ||
entries.length === 0 ||
entries.some(
(entry) =>
typeof entry !== "string" ||
(key === "observations"
? !/^[A-Za-z][A-Za-z0-9]*$/.test(entry)
: !SCENARIO_ID_PATTERN.test(entry)),
) ||
new Set(entries).size !== entries.length
) {
throw new Error(`Scenario ${scenario.id} has malformed required ${key}.`);
}
}
const commandExpectations = requiredEvidence.commandExpectations;
if (commandExpectations !== undefined) {
if (
!commandExpectations ||
typeof commandExpectations !== "object" ||
Array.isArray(commandExpectations) ||
(Object.getPrototypeOf(commandExpectations) !== Object.prototype &&
Object.getPrototypeOf(commandExpectations) !== null)
) {
throw new Error(`Scenario ${scenario.id} has malformed command expectations.`);
}
const commands = requiredEvidence.commands;
const expectationRecord = commandExpectations as Record<string, unknown>;
if (
!Array.isArray(commands) ||
Object.keys(expectationRecord).sort().join("\0") !== [...commands].sort().join("\0")
) {
throw new Error(
`Scenario ${scenario.id} command expectations must exactly match required commands.`,
);
}
for (const [commandId, expectation] of Object.entries(expectationRecord)) {
if (!SCENARIO_ID_PATTERN.test(commandId) || typeof expectation !== "string") {
throw new Error(`Scenario ${scenario.id} has malformed command expectations.`);
}
validateInstallVmCommandExpectation(expectation);
}
}
}
}

return manifest as InstallVmScenarioManifest;
Expand Down
Loading
Loading