diff --git a/src/filesystem/StableFileHash.ts b/src/filesystem/StableFileHash.ts index 890de4d9ce..26f0f58d19 100644 --- a/src/filesystem/StableFileHash.ts +++ b/src/filesystem/StableFileHash.ts @@ -19,6 +19,20 @@ export interface StableFileHashOptions { readonly retainFailedClose: (owner: OwnedFileHandle) => void; } +export interface StableFileObservation { + readonly sha256: string; + readonly bytes: number; + /** The first two bytes, read from the same descriptor and stream as the hash. */ + readonly prefix: Buffer; +} + +const STABLE_FILE_PREFIX_BYTES = 2; + +interface StableFileFingerprint { + readonly sha256: string; + readonly bytes: number; +} + /** Physical hash admission/read failure with no outstanding descriptor cleanup. */ export class StableFileHashFailure extends Error { readonly _tag = "StableFileHashFailure"; @@ -43,6 +57,30 @@ export const hashStableFile = async ( path: string, options: StableFileHashOptions, ): Promise<{ readonly sha256: string; readonly bytes: number }> => { + return await readStableFile(path, options, false); +}; + +/** Hash a stable file and capture exactly its first two bytes from that read. */ +export const observeStableFile = ( + path: string, + options: StableFileHashOptions, +): Promise => readStableFile(path, options, true); + +function readStableFile( + path: string, + options: StableFileHashOptions, + includePrefix: false, +): Promise; +function readStableFile( + path: string, + options: StableFileHashOptions, + includePrefix: true, +): Promise; +async function readStableFile( + path: string, + options: StableFileHashOptions, + includePrefix: boolean, +): Promise { try { return await withOwnedRegularFile(path, options, async (handle) => { const initial = await handle.stat(); @@ -52,6 +90,9 @@ export const hashStableFile = async ( ); const hash = createHash("sha256"); const chunk = Buffer.allocUnsafe(64 * 1024); + const prefix = Buffer.alloc( + includePrefix ? Math.min(STABLE_FILE_PREFIX_BYTES, initial.size) : 0, + ); let position = 0; while (position <= initial.size) { assertRegularFileActive(options.signal); @@ -65,6 +106,13 @@ export const hashStableFile = async ( throw new RegularFileChangedError(path); assertRegularFileActive(options.signal); if (read.bytesRead === 0) break; + if (position < prefix.length) { + const prefixBytes = Math.min( + read.bytesRead, + prefix.length - position, + ); + chunk.copy(prefix, position, 0, prefixBytes); + } hash.update(chunk.subarray(0, read.bytesRead)); position += read.bytesRead; } @@ -80,7 +128,8 @@ export const hashStableFile = async ( ) throw new RegularFileChangedError(path); assertRegularFileActive(options.signal); - return { sha256: hash.digest("hex"), bytes: position }; + const fingerprint = { sha256: hash.digest("hex"), bytes: position }; + return includePrefix ? { ...fingerprint, prefix } : fingerprint; }); } catch (cause: unknown) { if ( @@ -91,4 +140,4 @@ export const hashStableFile = async ( throw cause; throw new StableFileHashFailure(path, cause); } -}; +} diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts index f723b1e648..1538ffa17e 100644 --- a/src/kuna/KunaInstallation.ts +++ b/src/kuna/KunaInstallation.ts @@ -17,6 +17,7 @@ import { } from "../artifacts/AnalysisCleanupLease.js"; import { hashStableFile, + observeStableFile, stableFileHashCause, } from "../filesystem/StableFileHash.js"; import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; @@ -24,7 +25,6 @@ import { RegularFileCleanupFailure, RegularFileCancelled, regularFileCancellation, - withOwnedRegularFile, } from "../filesystem/OwnedRegularFile.js"; import { OwnedCommandFailure, @@ -251,6 +251,103 @@ const failure = ( const message = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +interface InstallationFileObservation { + readonly phase: "fingerprint" | "identity"; + readonly completedHash?: { readonly sha256: string; readonly bytes: number }; + readonly script?: boolean; + readonly beforeProbeFingerprint?: { + readonly sha256: string; + readonly bytes: number; + }; + readonly probe?: { + readonly deadlineMs: number; + readonly output: ReturnType; + }; +} + +/** Keep completed file facts attached when cancellation or close failure interrupts inspection. */ +const recordPartialInstallationFileOperation = ( + cause: unknown, + facts: { + readonly command: string; + readonly executable: string; + readonly version?: string; + readonly observed: InstallationFileObservation; + }, +): boolean => { + const cancellation = regularFileCancellation(cause); + if ( + !(cause instanceof Error) || + (!(cause instanceof RegularFileCleanupFailure) && + cancellation === undefined) + ) + return false; + + const outcome = + cause instanceof RegularFileCancelled || + cause instanceof RegularFileCleanupFailure + ? cause.outcome + : undefined; + const completed = outcome?.kind === "completed" ? outcome.value : undefined; + const completedRecord = + completed !== null && + typeof completed === "object" && + !Array.isArray(completed) + ? (completed as Record) + : undefined; + const completedFingerprint = + facts.observed.phase === "fingerprint" + ? completed + : completedRecord === undefined + ? undefined + : { + sha256: completedRecord["sha256"], + bytes: completedRecord["bytes"], + }; + const completedHash = + facts.observed.completedHash ?? + (outcome?.kind === "completed" && completedFingerprint !== undefined + ? jsonValueSchema.parse(completedFingerprint) + : undefined); + const completedScript = + facts.observed.script ?? + (facts.observed.phase === "identity" && + Buffer.isBuffer(completedRecord?.["prefix"]) + ? completedRecord["prefix"].toString("latin1") === "#!" + : undefined); + installationFailureDiagnostics.set(cause, { + kuna_installation: { + status: "partial", + command: facts.command, + executable: facts.executable, + ...(facts.version === undefined + ? {} + : { + version: facts.version, + verified: facts.version === KUNA_VERIFIED_VERSION, + }), + ...(completedHash === undefined ? {} : { completed_hash: completedHash }), + ...(completedScript === undefined ? {} : { script: completedScript }), + ...(facts.observed.beforeProbeFingerprint === undefined + ? {} + : { + before_probe_sha256: facts.observed.beforeProbeFingerprint.sha256, + before_probe_bytes: facts.observed.beforeProbeFingerprint.bytes, + }), + }, + ...(facts.observed.probe === undefined + ? {} + : { + kuna_version_probe: { + status: "completed", + deadline_ms: facts.observed.probe.deadlineMs, + ...versionProbeOutputDiagnostics(facts.observed.probe.output), + }, + }), + }); + return true; +}; + /** Resolve, version-probe and fingerprint the configured executable. */ export const inspectKunaInstallation = async ( environment: Readonly, @@ -287,6 +384,38 @@ export const inspectKunaInstallation = async ( assertRegularFileActive(options.signal); + let beforeProbeFingerprint: { + readonly sha256: string; + readonly bytes: number; + }; + const beforeProbeObservation: InstallationFileObservation = { + phase: "fingerprint", + }; + try { + beforeProbeFingerprint = await options.resourceScope.run(() => + hashStableFile(executable, { + ...(options.signal === undefined ? {} : { signal: options.signal }), + retainFailedClose: (owner) => + options.resourceScope.retain(owner, executable), + }), + ); + } catch (cause: unknown) { + if (cause instanceof ArtifactReaderFailure) throw cause; + if ( + recordPartialInstallationFileOperation(cause, { + command, + executable, + observed: beforeProbeObservation, + }) + ) + throw cause; + return failure( + "executable_missing", + `Cannot fingerprint ${executable}: ${message(stableFileHashCause(cause))}`, + { command, executable }, + ); + } + const deadline = options.probeTimeoutMs ?? VERSION_PROBE_TIMEOUT_MS; let snapshot: ProviderProcessSnapshot; try { @@ -335,7 +464,6 @@ export const inspectKunaInstallation = async ( deadline_ms: deadline, ...versionProbeOutputDiagnostics(output), probe_failure_reason: cause.reason, - stop_requested: cause.snapshot !== null, cleanup_incomplete: cleanupIncomplete, }, }); @@ -348,7 +476,6 @@ export const inspectKunaInstallation = async ( command, executable, probe_failure_reason: cause.reason, - stop_requested: cause.snapshot !== null, cleanup_incomplete: cleanupIncomplete, deadline_ms: deadline, ...versionProbeOutputDiagnostics(output), @@ -392,74 +519,94 @@ export const inspectKunaInstallation = async ( let fingerprint: { readonly sha256: string; readonly bytes: number }; let script: boolean; const observed: { - phase: "fingerprint" | "shebang"; - completed_hash?: { readonly sha256: string; readonly bytes: number }; + phase: "fingerprint" | "identity"; + completedHash?: { readonly sha256: string; readonly bytes: number }; script?: boolean; - } = { phase: "fingerprint" }; + beforeProbeFingerprint?: { + readonly sha256: string; + readonly bytes: number; + }; + probe?: { + readonly deadlineMs: number; + readonly output: ReturnType; + }; + } = { + phase: "fingerprint", + beforeProbeFingerprint, + probe: { deadlineMs: deadline, output: probeOutput }, + }; try { ({ fingerprint, script } = await options.resourceScope.run(async () => { - const fingerprint = await hashStableFile(executable, { + observed.phase = "identity"; + const observation = await observeStableFile(executable, { ...(options.signal === undefined ? {} : { signal: options.signal }), retainFailedClose: (owner) => options.resourceScope.retain(owner, executable), }); - observed.completed_hash = fingerprint; - assertRegularFileActive(options.signal); - observed.phase = "shebang"; - const script = await startsWithShebang( - executable, - options.resourceScope, - options.signal, - ); + const fingerprint = { + sha256: observation.sha256, + bytes: observation.bytes, + }; + const script = + observation.prefix.length === 2 && + observation.prefix.toString("latin1") === "#!"; + observed.completedHash = fingerprint; observed.script = script; assertRegularFileActive(options.signal); return { fingerprint, script }; })); } catch (cause: unknown) { - const cancellation = regularFileCancellation(cause); + if (cause instanceof ArtifactReaderFailure) throw cause; if ( - cause instanceof Error && - (cause instanceof RegularFileCleanupFailure || cancellation !== undefined) + recordPartialInstallationFileOperation(cause, { + command, + executable, + version, + observed, + }) ) { - const outcome = - cause instanceof RegularFileCancelled || - cause instanceof RegularFileCleanupFailure - ? cause.outcome - : undefined; - const completed = - outcome?.kind === "completed" ? outcome.value : undefined; - const completedHash = - observed.completed_hash ?? - (observed.phase === "fingerprint" && outcome?.kind === "completed" - ? jsonValueSchema.parse(completed) - : undefined); - const completedScript = - observed.script ?? - (observed.phase === "shebang" && typeof completed === "boolean" - ? completed - : undefined); - installationFailureDiagnostics.set(cause, { - kuna_installation: { - status: "partial", - command, - executable, - version, - verified: version === KUNA_VERIFIED_VERSION, - ...(completedHash === undefined - ? {} - : { completed_hash: completedHash }), - ...(completedScript === undefined ? {} : { script: completedScript }), - }, - }); throw cause; } return failure( "executable_missing", - `Cannot fingerprint ${executable}: ${message(stableFileHashCause(cause))}`, - { command, executable }, + `Cannot observe ${executable} after its --version probe: ${message(stableFileHashCause(cause))}`, + { + command, + executable, + version, + verified: version === KUNA_VERIFIED_VERSION, + identity_observation_phase: "post_probe", + identity_observation_failure: message(stableFileHashCause(cause)), + before_probe_sha256: beforeProbeFingerprint.sha256, + before_probe_bytes: beforeProbeFingerprint.bytes, + deadline_ms: deadline, + ...versionProbeOutputDiagnostics(probeOutput), + }, ); } + if ( + beforeProbeFingerprint.sha256 !== fingerprint.sha256 || + beforeProbeFingerprint.bytes !== fingerprint.bytes + ) + return failure( + "version_unresolved", + `${executable} changed during the --version probe. ${KUNA_VERSION_PROBE_REMEDIATION}`, + { + command, + executable, + version, + verified: version === KUNA_VERIFIED_VERSION, + installation_identity_changed: true, + before_sha256: beforeProbeFingerprint.sha256, + before_bytes: beforeProbeFingerprint.bytes, + after_sha256: fingerprint.sha256, + after_bytes: fingerprint.bytes, + deadline_ms: deadline, + ...versionProbeOutputDiagnostics(probeOutput), + }, + ); + return ok({ configuration: configuration.value, executable, @@ -469,22 +616,3 @@ export const inspectKunaInstallation = async ( script, }); }; - -const startsWithShebang = ( - path: string, - resourceScope: RetainedResourceScope, - signal?: AbortSignal, -): Promise => - withOwnedRegularFile( - path, - { - symlinks: "follow", - ...(signal === undefined ? {} : { signal }), - retainFailedClose: (owner) => resourceScope.retain(owner, path), - }, - async (handle) => { - const prefix = Buffer.alloc(2); - const { bytesRead } = await handle.read(prefix, 0, 2, 0); - return bytesRead === 2 && prefix.toString("latin1") === "#!"; - }, - ); diff --git a/tests/boundary/cli/kunaDoctorHeldOutput.test.ts b/tests/boundary/cli/kunaDoctorHeldOutput.test.ts index c404ac8818..69d3fe9cf3 100644 --- a/tests/boundary/cli/kunaDoctorHeldOutput.test.ts +++ b/tests/boundary/cli/kunaDoctorHeldOutput.test.ts @@ -106,7 +106,6 @@ process.exit(0); code: "version_unresolved", details: expect.objectContaining({ probe_failure_reason: "timeout", - stop_requested: true, exit_code: 0, signal: null, stdout: "kuna 1.833\n", diff --git a/tests/boundary/cli/kunaDoctorIdentity.test.ts b/tests/boundary/cli/kunaDoctorIdentity.test.ts new file mode 100644 index 0000000000..26b3b9a8bc --- /dev/null +++ b/tests/boundary/cli/kunaDoctorIdentity.test.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest + .skipIf(process.platform === "win32") + .for(["stable", "replaced"] as const)( + "doctor binds Kuna version output to a %s executable identity", + async (identity, { cli }) => { + const root = await createTestTempDirectory("rea-kuna-doctor-identity-"); + const home = join(root, "home"); + const executable = join(root, "kuna"); + await mkdir(home); + const replacement = `#!${process.execPath}\nconsole.log("kuna 1.900");\n`; + const mutation = + identity === "replaced" + ? `const fs = require("node:fs"); +fs.writeFileSync(process.argv[1] + ".new", ${JSON.stringify(replacement)}, { mode: 0o700 }); +fs.renameSync(process.argv[1] + ".new", process.argv[1]);` + : ""; + const source = `#!${process.execPath} +console.log("kuna 1.833"); +${mutation} +`; + await writeFile(executable, source, { mode: 0o700 }); + const beforeHash = createHash("sha256").update(source).digest("hex"); + + const result = await cli.run({ + arguments: ["doctor", "--provider", "kuna", "--json"], + environment: { + HOME: home, + USERPROFILE: home, + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + GHIDRA_INSTALL_DIR: join(root, "missing-ghidra"), + REA_KUNA_COMMAND: executable, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_LOG_LEVEL: "silent", + }, + timeoutMs: 20_000, + }); + const after = await readFile(executable); + const afterHash = createHash("sha256").update(after).digest("hex"); + + if (identity === "stable") { + expect(afterHash).toBe(beforeHash); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + providerInspections: expect.arrayContaining([ + expect.objectContaining({ + id: "kuna", + available: true, + providerVersion: "1.833", + checks: expect.arrayContaining([ + expect.objectContaining({ name: "installation", ok: true }), + ]), + }), + ]), + }); + } else { + expect(afterHash).not.toBe(beforeHash); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + providerInspections: expect.arrayContaining([ + expect.objectContaining({ + id: "kuna", + available: false, + providerVersion: null, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "installation", + ok: false, + code: "version_unresolved", + details: expect.objectContaining({ + installation_identity_changed: true, + version: "1.833", + before_sha256: beforeHash, + before_bytes: Buffer.byteLength(source), + after_sha256: afterHash, + after_bytes: after.byteLength, + stdout: "kuna 1.833\n", + exit_code: 0, + }), + }), + ]), + }), + ]), + }); + } + }, +); diff --git a/tests/boundary/filesystem/stableFileHashOwnership.test.ts b/tests/boundary/filesystem/stableFileHashOwnership.test.ts index f6dbad92c6..02cd4efba8 100644 --- a/tests/boundary/filesystem/stableFileHashOwnership.test.ts +++ b/tests/boundary/filesystem/stableFileHashOwnership.test.ts @@ -8,7 +8,10 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import * as regularFile from "../../../src/filesystem/RegularFile.js"; import { RegularFileAdmissionFailure } from "../../../src/filesystem/RegularFile.js"; -import { hashStableFile } from "../../../src/filesystem/StableFileHash.js"; +import { + hashStableFile, + observeStableFile, +} from "../../../src/filesystem/StableFileHash.js"; import { LinuxHopperPublisher } from "../../../src/application/LinuxHopperPublisher.js"; import { RegularFileCancelled, @@ -17,6 +20,53 @@ import { import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; import type { VerifiedLinuxHopperPackage } from "../../../src/application/LinuxHopperPublisher.js"; +it("captures a two-byte prefix across short reads and buffer reuse", async () => { + const root = await createTestTempDirectory("rea-stable-observation-prefix-"); + const path = join(root, "script"); + const bytes = Buffer.from("#!rest-of-file"); + await writeFile(path, bytes); + const retained: OwnedFileHandle[] = []; + const originalOpen = regularFile.openRegularFile; + const open = vi + .spyOn(regularFile, "openRegularFile") + .mockImplementation(async (...args) => { + const handle = await originalOpen(...args); + return { + get fd() { + return handle.fd; + }, + stat: (...statArgs: Parameters) => + handle.stat(...statArgs), + read: ( + buffer: Buffer, + offset: number, + length: number, + position: number, + ) => + handle.read( + buffer, + offset, + position === 0 ? Math.min(length, 1) : length, + position, + ), + close: () => handle.close(), + } as unknown as Awaited>; + }); + try { + const observation = await observeStableFile(path, { + retainFailedClose: (owner) => retained.push(owner), + }); + expect(observation).toMatchObject({ + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + prefix: Buffer.from("#!"), + }); + } finally { + open.mockRestore(); + for (const owner of retained) if (owner.handle.fd >= 0) await owner.close(); + } +}); + it("adopts and retries the descriptor when hashing succeeded but close failed", async () => { const root = await createTestTempDirectory("rea-stable-hash-close-"); const path = join(root, "input.bin"); diff --git a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts index f8f2599438..47d6775676 100644 --- a/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts +++ b/tests/boundary/providers/kuna/kunaHashOwnerPropagation.test.ts @@ -18,23 +18,40 @@ import type { RetainedResourceScope } from "../../../../src/application/Analysis const injected = vi.hoisted(() => ({ failNextClose: false, + failReadClose: true, holdCloseFailures: false, releaseCloseFailures: false, closeCalls: 0, - failCloseReadStage: undefined as "fingerprint" | "shebang" | undefined, - failReadStage: undefined as "fingerprint" | "shebang" | undefined, + fingerprintReads: 0, + installationPath: undefined as string | undefined, + failCloseReadStage: undefined as + | "preProbeFingerprint" + | "fingerprint" + | "identity" + | undefined, + failReadStage: undefined as + | "preProbeFingerprint" + | "fingerprint" + | "identity" + | undefined, abortOnReadFailure: undefined as (() => void) | undefined, abortOnClose: undefined as - | { stage: "fingerprint" | "shebang"; abort: () => void } + | { + stage: "preProbeFingerprint" | "fingerprint" | "identity"; + abort: () => void; + } | undefined, handles: [] as import("node:fs/promises").FileHandle[], })); afterEach(async () => { injected.failNextClose = false; + injected.failReadClose = true; injected.holdCloseFailures = false; injected.releaseCloseFailures = false; injected.closeCalls = 0; + injected.fingerprintReads = 0; + injected.installationPath = undefined; injected.failCloseReadStage = undefined; injected.failReadStage = undefined; injected.abortOnReadFailure = undefined; @@ -53,6 +70,7 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => { openRegularFile: vi.fn( async (...args: Parameters) => { const handle = await original.openRegularFile(...args); + const isInstallation = String(args[0]) === injected.installationPath; injected.handles.push(handle); const failNextClose = injected.failNextClose; const failCloseReadStage = injected.failCloseReadStage; @@ -70,6 +88,21 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => { let readStageMatched = false; let failedClose = false; let abortCloseMatched = false; + let fingerprintStage: + | "preProbeFingerprint" + | "fingerprint" + | "identity" + | undefined; + const tracksFingerprint = [ + failCloseReadStage, + failReadStage, + abortOnClose?.stage, + ].some( + (stage) => + stage === "preProbeFingerprint" || + stage === "fingerprint" || + stage === "identity", + ); return { get fd() { return handle.fd; @@ -83,21 +116,33 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => { position: number, ) => { if ( - (abortOnClose?.stage === "fingerprint" && length > 2) || - (abortOnClose?.stage === "shebang" && length === 2) - ) - abortCloseMatched = true; - if ( - (failReadStage === "fingerprint" && length > 2) || - (failReadStage === "shebang" && length === 2) + length > 2 && + tracksFingerprint && + fingerprintStage === undefined ) { + fingerprintStage = isInstallation + ? injected.fingerprintReads === 0 + ? "preProbeFingerprint" + : "identity" + : "fingerprint"; + if (isInstallation) injected.fingerprintReads += 1; + } + const readStage = + length === 2 + ? undefined + : length > 2 + ? fingerprintStage + : undefined; + if (abortOnClose?.stage === readStage) abortCloseMatched = true; + if (failReadStage !== undefined && failReadStage === readStage) { readStageMatched = true; injected.abortOnReadFailure?.(); throw new Error("injected read fault"); } - if (failCloseReadStage === "fingerprint" && length > 2) - readStageMatched = true; - if (failCloseReadStage === "shebang" && length === 2) + if ( + failCloseReadStage !== undefined && + failCloseReadStage === readStage + ) readStageMatched = true; return handle.read(buffer, offset, length, position); }, @@ -106,7 +151,7 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => { if (abortCloseMatched) abortOnClose?.abort(); if ( (failNextClose && !failedClose) || - (readStageMatched && !failedClose) || + (readStageMatched && injected.failReadClose && !failedClose) || (injected.holdCloseFailures && !injected.releaseCloseFailures) ) { failedClose = true; @@ -125,12 +170,13 @@ import { composeBinarySession } from "../../../../src/application/binary/BinaryS import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; import { inspectKunaProvider } from "../../../../src/kuna/KunaDoctor.js"; +import { kunaInstallationCancellation } from "../../../../src/kuna/KunaInstallation.js"; import { inspectKunaInstallationOwned } from "./kunaResourceScope.js"; import { fakeKunaCalls } from "./fakeKuna.js"; import { run, session } from "./kunaSessionFixture.js"; it.skipIf(process.platform === "win32").each( - (["fingerprint", "shebang"] as const).flatMap((stage) => + (["identity"] as const).flatMap((stage) => (["health", "search_procedures"] as const).map((operation) => ({ stage, operation, @@ -142,6 +188,8 @@ it.skipIf(process.platform === "win32").each( const fixture = await session("rea-kuna-installation-owner-resolution-"); const client = fixture.open(); injected.closeCalls = 0; + injected.fingerprintReads = 0; + injected.installationPath = fixture.kuna; injected.failCloseReadStage = stage; try { const inspected = await client.execute( @@ -174,7 +222,7 @@ it.skipIf(process.platform === "win32").each( expect(producer.cause).toMatchObject({ name: "RegularFileCleanupFailure", }); - const failedCloseCall = stage === "fingerprint" ? 1 : 2; + const failedCloseCall = 2; expect(injected.closeCalls).toBe(failedCloseCall); expect(await client.close()).toEqual({ ok: true, value: null }); @@ -203,6 +251,7 @@ it.skipIf(process.platform === "win32")( const commands = new KunaTargetCommands(fixture.target, { signal: controller.signal, }); + injected.fingerprintReads = 0; injected.failReadStage = "fingerprint"; injected.abortOnReadFailure = () => controller.abort(); let originatingError: @@ -252,6 +301,7 @@ it.skipIf(process.platform === "win32")( const cache = new KunaProjectCache(fixture.cacheRoot, { maxBytes: 64 * 1024 ** 2, }); + injected.fingerprintReads = 0; injected.failReadStage = "fingerprint"; try { const result = await cache.build({ @@ -289,6 +339,7 @@ it.skipIf(process.platform === "win32")( const commands = new KunaTargetCommands(fixture.target, { signal: controller.signal, }); + injected.fingerprintReads = 0; injected.failReadStage = "fingerprint"; try { const result = await commands.run({ @@ -493,9 +544,7 @@ it.skipIf(process.platform === "win32")( }, ); -it - .skipIf(process.platform === "win32") - .each([{ stage: "fingerprint" }, { stage: "shebang" }] as const)( +it.skipIf(process.platform === "win32").each([{ stage: "identity" }] as const)( "resolves the Kuna profile cleanup error after its $stage descriptor closes", async ({ stage }) => { const fixture = await session("rea-kuna-profile-owner-resolution-"); @@ -503,6 +552,8 @@ it new AnalysisProviderRegistry([fixture.provider]), ); injected.closeCalls = 0; + injected.fingerprintReads = 0; + injected.installationPath = fixture.kuna; injected.failCloseReadStage = stage; try { const resolved = await binary.previewTarget(fixture.target); @@ -518,7 +569,7 @@ it expect(await binary.close()).toEqual({ ok: true, value: null }); expect(originatingError.cleanupIncomplete).toBe(false); - expect(injected.closeCalls).toBe(stage === "fingerprint" ? 2 : 3); + expect(injected.closeCalls).toBe(3); expect(await binary.status()).toMatchObject({ cleanup_pending: false }); } finally { injected.failCloseReadStage = undefined; @@ -563,7 +614,7 @@ it.skipIf(process.platform === "win32")( expect(injected.closeCalls).toBe(3); expect(await binary.status()).toMatchObject({ cleanup_pending: false }); expect((await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv)).toEqual( - [["--version"], ["--version"]], + [["--version"]], ); }, ); @@ -604,11 +655,115 @@ it.skipIf(process.platform === "win32")( await resources.close(); } expect((await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv)).toEqual( - [["--version"], ["--version"]], + [["--version"]], ); }, ); +it.skipIf(process.platform === "win32")( + "preserves the completed pre-probe fingerprint when cancellation arrives during its close", + async () => { + const fixture = await session("rea-kuna-pre-probe-fingerprint-cancel-"); + const bytes = await readFile(fixture.kuna); + const fingerprint = { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }; + const beforeCalls = await fakeKunaCalls(fixture.kuna); + const controller = new AbortController(); + const reason = Object.freeze({ request: "cancel before version probe" }); + injected.fingerprintReads = 0; + injected.closeCalls = 0; + injected.installationPath = fixture.kuna; + injected.abortOnClose = { + stage: "preProbeFingerprint", + abort: () => controller.abort(reason), + }; + try { + const cause: unknown = await inspectKunaInstallationOwned( + { + REA_KUNA_COMMAND: fixture.kuna, + REA_KUNA_CACHE_DIR: fixture.cacheRoot, + }, + { signal: controller.signal }, + ).then( + () => undefined, + (failure: unknown) => failure, + ); + const cancellation = kunaInstallationCancellation(cause); + expect(cancellation?.reason).toBe(reason); + expect(cancellation?.diagnostics).toMatchObject({ + kuna_installation: { + status: "partial", + command: fixture.kuna, + executable: fixture.kuna, + completed_hash: fingerprint, + }, + }); + expect( + cancellation?.diagnostics?.["kuna_installation"], + ).not.toHaveProperty("version"); + expect(injected.closeCalls).toBe(1); + expect(await fakeKunaCalls(fixture.kuna)).toEqual(beforeCalls); + } finally { + injected.abortOnClose = undefined; + controller.abort(reason); + } + }, +); + +it.skipIf(process.platform === "win32")( + "keeps a physical post-probe read failure primary with the version and prior fingerprint", + async () => { + const fixture = await session("rea-kuna-post-probe-read-failure-"); + const bytes = await readFile(fixture.kuna); + const beforeProbeFingerprint = { + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }; + const controller = new AbortController(); + injected.fingerprintReads = 0; + injected.installationPath = fixture.kuna; + injected.failReadStage = "identity"; + injected.failReadClose = false; + injected.abortOnReadFailure = () => + controller.abort({ request: "abort after physical read failure" }); + try { + const result = await inspectKunaInstallationOwned( + { + REA_KUNA_COMMAND: fixture.kuna, + REA_KUNA_CACHE_DIR: fixture.cacheRoot, + }, + { signal: controller.signal }, + ); + expect(controller.signal.aborted).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { + code: "executable_missing", + reason: expect.stringContaining("injected read fault"), + diagnostics: { + version: "1.833", + verified: true, + identity_observation_phase: "post_probe", + identity_observation_failure: "injected read fault", + before_probe_sha256: beforeProbeFingerprint.sha256, + before_probe_bytes: beforeProbeFingerprint.bytes, + deadline_ms: 10_000, + stdout: "kuna 1.833\n", + stdout_bytes: 11, + output_truncated: false, + }, + }, + }); + } finally { + injected.failReadStage = undefined; + injected.abortOnReadFailure = undefined; + injected.failReadClose = true; + } + }, +); + it.skipIf(process.platform === "win32")( "drains shared cache cleanup outside a retired client's target key", async () => { @@ -652,7 +807,7 @@ it.skipIf(process.platform === "win32")( it.skipIf(process.platform === "win32").each( (["doctor", "profile", "health"] as const).flatMap((consumer) => - (["fingerprint", "shebang"] as const).flatMap((stage) => + (["identity"] as const).flatMap((stage) => [false, true].map((closeRefusal) => ({ consumer, stage, @@ -688,6 +843,8 @@ it.skipIf(process.platform === "win32").each( run: (operation) => resources.run(operation), }; const client = consumer === "health" ? fixture.open() : undefined; + injected.fingerprintReads = 0; + injected.installationPath = fixture.kuna; injected.abortOnClose = { stage, abort: () => controller.abort(reason) }; injected.failCloseReadStage = closeRefusal ? stage : undefined; let failure: AnalysisError | undefined; @@ -756,11 +913,9 @@ it.skipIf(process.platform === "win32").each( executable, version: "1.833", completed_hash: fingerprint, - ...(stage === "shebang" ? { script: true } : {}), + ...(stage === "identity" ? { script: true } : {}), }, }); - if (stage === "fingerprint") - expect(diagnostics?.["kuna_installation"]).not.toHaveProperty("script"); expect(injected.handles.some((handle) => handle.fd >= 0)).toBe( closeRefusal, ); diff --git a/tests/boundary/providers/kuna/kunaInstallation.test.ts b/tests/boundary/providers/kuna/kunaInstallation.test.ts index 535860690f..a223ca1898 100644 --- a/tests/boundary/providers/kuna/kunaInstallation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallation.test.ts @@ -57,6 +57,48 @@ it("identifies a caller-selected kuna by version and digest", async () => { ]); }); +it("refuses to pair version output with executable bytes replaced during the probe", async () => { + const root = await createTestTempDirectory( + "rea-kuna-replaced-during-version-", + ); + const kuna = join(root, "kuna"); + const replacement = `#!${process.execPath}\nconsole.log("kuna 1.900");\n`; + const source = `#!${process.execPath} +const fs = require("node:fs"); +const replacement = ${JSON.stringify(replacement)}; +console.log("kuna 1.833"); +fs.writeFileSync(process.argv[1] + ".new", replacement, { mode: 0o700 }); +fs.renameSync(process.argv[1] + ".new", process.argv[1]); +`; + await writeFile(kuna, source, { mode: 0o700 }); + await chmod(kuna, 0o700); + const before = await readFile(kuna); + + const result = await inspectKunaInstallation({ + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + }); + const after = await readFile(kuna); + + expect(createHash("sha256").update(after).digest("hex")).not.toBe( + createHash("sha256").update(before).digest("hex"), + ); + expect(result).toMatchObject({ + ok: false, + error: { + code: "version_unresolved", + diagnostics: { + installation_identity_changed: true, + version: "1.833", + before_sha256: createHash("sha256").update(before).digest("hex"), + after_sha256: createHash("sha256").update(after).digest("hex"), + before_bytes: before.length, + after_bytes: after.length, + }, + }, + }); +}); + it("accepts another release but reports it as unverified", async () => { const root = await createTestTempDirectory("rea-kuna-unverified-"); const kuna = await writeFakeKuna(root, { @@ -99,7 +141,7 @@ it("reports a failed version probe with its exit status", async () => { error: { code: "version_unresolved", reason: expect.stringContaining("version probe failed"), - diagnostics: { exit_code: 3, stop_requested: true }, + diagnostics: { exit_code: 3, probe_failure_reason: "process" }, }, }); }); @@ -219,7 +261,10 @@ it("stops a version probe that ignores SIGTERM instead of waiting on it", async ok: false, error: { code: "version_unresolved", - diagnostics: { stop_requested: true, deadline_ms: 4_000 }, + diagnostics: { + probe_failure_reason: "timeout", + deadline_ms: 4_000, + }, }, }); // The owned process-group stop is bounded and must not wait on inherited output forever. @@ -300,7 +345,10 @@ setTimeout(() => process.exit(9), 10000).unref(); ok: false, error: { code: "version_unresolved", - diagnostics: { deadline_ms: 1000, stop_requested: true }, + diagnostics: { + deadline_ms: 1000, + probe_failure_reason: "timeout", + }, }, }, }); diff --git a/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts b/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts index af5012f70b..00e5031a42 100644 --- a/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallationLifetime.test.ts @@ -15,9 +15,11 @@ const gates = vi.hoisted(() => { return { createGate, hashClose: createGate(), - shebangRead: createGate(), + identityRead: createGate(), opens: 0, failClose: false, + holdHashClose: false, + holdIdentityRead: false, }; }); @@ -39,14 +41,14 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => { stat: (...statArgs: Parameters) => handle.stat(...statArgs), read: async (...readArgs: Parameters) => { - if (index === 1) { - gates.shebangRead.enter(); - await gates.shebangRead.hold; + if (index === 1 && gates.holdIdentityRead) { + gates.identityRead.enter(); + await gates.identityRead.hold; } return handle.read(...readArgs); }, close: async () => { - if (index === 0) { + if (index === 1 && gates.holdHashClose) { gates.hashClose.enter(); await gates.hashClose.hold; } @@ -73,19 +75,21 @@ import { session } from "./kunaSessionFixture.js"; beforeEach(() => { gates.opens = 0; gates.failClose = false; + gates.holdHashClose = false; + gates.holdIdentityRead = false; gates.hashClose = gates.createGate(); - gates.shebangRead = gates.createGate(); + gates.identityRead = gates.createGate(); }); afterEach(() => { gates.failClose = false; gates.hashClose.release(); - gates.shebangRead.release(); + gates.identityRead.release(); }); it .skipIf(process.platform === "win32") - .each(["hash-close", "shebang-read"] as const)( + .each(["hash-close", "identity-read"] as const)( "keeps installation cleanup waiting for its active phase when cancelled during %s", async (phase) => { const root = await createTestTempDirectory("rea-kuna-inspection-lifetime-"); @@ -101,6 +105,8 @@ it run: (operation) => scope.run(operation), }; const controller = new AbortController(); + gates.holdHashClose = phase === "hash-close"; + gates.holdIdentityRead = phase === "identity-read"; const inspection = inspectKunaInstallation( { REA_KUNA_COMMAND: executable }, { resourceScope, signal: controller.signal }, @@ -112,7 +118,9 @@ it ); let cleanup: Promise | undefined; try { - await gates.hashClose.started; + await (phase === "hash-close" + ? gates.hashClose.started + : gates.identityRead.started); cleanup = scope.retryCleanup(); let cleanupFinished = false; void cleanup.then(() => { @@ -120,23 +128,11 @@ it }); await Promise.resolve(); expect(cleanupFinished).toBe(false); - if (phase === "hash-close") controller.abort(); - gates.hashClose.release(); - const reachedShebang = await Promise.race([ - gates.shebangRead.started.then(() => true), - inspection.then( - () => false, - () => false, - ), - ]); - expect(reachedShebang).toBe(phase === "shebang-read"); - if (phase === "shebang-read") { - controller.abort(); - await Promise.resolve(); - expect(cleanupFinished).toBe(false); - } - - gates.shebangRead.release(); + controller.abort(); + if (phase === "hash-close") gates.hashClose.release(); + else gates.identityRead.release(); + await Promise.resolve(); + expect(cleanupFinished).toBe(false); const failure: unknown = await inspection.catch( (cause: unknown) => cause, ); @@ -144,11 +140,11 @@ it expect(cancellation).toBeDefined(); expect(cancellation?.reason).toBe(controller.signal.reason); await expect(cleanup).resolves.toBeUndefined(); - expect(gates.opens).toBe(phase === "hash-close" ? 1 : 2); + expect(gates.opens).toBe(2); expect(scope.hasPending).toBe(false); } finally { gates.hashClose.release(); - gates.shebangRead.release(); + gates.identityRead.release(); await inspectionOutcome; await cleanup; await scope.close(); @@ -158,15 +154,17 @@ it it .skipIf(process.platform === "win32") - .each(["hash-close", "shebang-read", "close-refusal"] as const)( + .each(["hash-close", "identity-read", "close-refusal"] as const)( "joins session acquisition finalization and retains cleanup retry during %s cancellation", async (phase) => { gates.hashClose.release(); - gates.shebangRead.release(); + gates.identityRead.release(); const fixture = await session("rea-kuna-session-finalization-"); gates.opens = 0; gates.hashClose = gates.createGate(); - gates.shebangRead = gates.createGate(); + gates.identityRead = gates.createGate(); + gates.holdHashClose = phase !== "identity-read"; + gates.holdIdentityRead = phase === "identity-read"; const client = fixture.open(); const controller = new AbortController(); const reason = { selected: "caller reason" }; @@ -177,18 +175,16 @@ it finished = true; }); try { - await gates.hashClose.started; - if (phase === "shebang-read") { - gates.hashClose.release(); - await gates.shebangRead.started; - } + await (phase === "identity-read" + ? gates.identityRead.started + : gates.hashClose.started); controller.abort(reason); await new Promise((resolve) => setImmediate(resolve)); expect(finished).toBe(false); if (phase === "hash-close") replacement = client.execute("health", {}); gates.failClose = phase === "close-refusal"; gates.hashClose.release(); - gates.shebangRead.release(); + gates.identityRead.release(); const result = await pending; if (result.ok) throw new Error("Expected interrupted installation"); if (phase === "close-refusal") { @@ -211,7 +207,7 @@ it } finally { gates.failClose = false; gates.hashClose.release(); - gates.shebangRead.release(); + gates.identityRead.release(); await pending; await replacement; expect((await client.close()).ok).toBe(true); diff --git a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts index 6dd5f8128a..5d23bf7d7a 100644 --- a/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts +++ b/tests/boundary/providers/kuna/kunaVersionProbeOwnership.test.ts @@ -120,7 +120,7 @@ it.skipIf(process.platform === "win32")( diagnostics: { exit_code: 0, signal: null, - stop_requested: true, + probe_failure_reason: "timeout", stdout: "kuna 1.833\n", stderr: "x".repeat(1024), stdout_bytes: 11, @@ -188,7 +188,6 @@ it.skipIf(process.platform === "win32")( exit_code: 0, signal: null, probe_failure_reason: "process", - stop_requested: true, cleanup_incomplete: true, stdout: "kuna 1.833\n", stderr: "x".repeat(1024),