diff --git a/docs/env.md b/docs/env.md index 584a41bc..640a6b7a 100644 --- a/docs/env.md +++ b/docs/env.md @@ -247,7 +247,7 @@ into a host command, and `--target` on env and host commands chooses the `host` | `hack down` | `--env`, `--target` | At teardown, for lifecycle down hooks only; omitted reuses a named overlay recorded for the running stack, else the configured default. | None; nothing is delivered to containers. | The native runtime backend refuses `--env`. | | `hack restart` | `--env`, `--target` | When services are recreated; omitted reuses a named overlay recorded for the running stack, else the configured default, and an explicit `--env` (including `base`) wins. | This is the refresh path for the whole stack or the named services. | Authored native projects receive the same selection before dispatch. | | `hack run` | `--env` | Per invocation, for the new one-off container. | Each run resolves again. | The native runtime backend refuses `--env`; restart with the selection first. | -| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose warns on stderr when the service's resolved env changed since it started. | `hack restart ` (or `hack restart --env `) | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. | +| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose exec and native-runtime exec (Compose-file and authored native projects) warn on stderr when the service's resolved env changed since it started. | `hack restart ` (or `hack restart --env `); native projects restart as a whole with `hack restart` | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. | | `hack env list` | `--env`, `--service` | Per invocation, read-only; secret values are masked unless `--show-secrets`. | Not needed. | Reads files; no runtime required. | | `hack env get` | `--env`, `--service` | Per invocation; prints exactly one plaintext value to stdout. | Not needed. | Reads files; refuses native project inputs. | | `hack env explain` | `--env`, `--service`, `--target` | Per invocation; reports source, precedence and delivery without the value. | Not needed. | Reads files; no runtime required. | @@ -278,7 +278,30 @@ command's output. The warning never prints a key or value. The check is advisory. Identical env, state written before this check existed, and a service whose last targeted restart failed produce no warning. `hack up `, `hack restart ` and `hack env apply --service ` refresh only the -named services' digests. The native runtime backend does not run this check. +named services' digests. + +Projects on the native runtime backend run the same check. The foreground `hack up` or `hack restart` records +value-free digests of the env selection it delivered once the run is ready, in +`.hack/.internal/native-env-fingerprints/` (one file per instance, mode 0600). The +record is bound to that run and removed when the foreground ends. `hack exec` resolves +the same selection again: for a Compose-file (image) project, the env for the run's +recorded env name; for an authored project, the managed values for the recorded +`--env` and `--profile` selection. When they differ, it prints one fixed warning to +stderr that names the service and suggests `hack restart`, since native restart is +whole-project. An image-project exec receives freshly resolved values itself, so its +warning says the running service still uses the env it started with. A restart drops +the record once the previous run stops, so a failed restart produces no warning. A +record from another run, a missing record, or any resolution failure produces no +warning. An authored-project check is bounded: after five seconds without a result +it is skipped and the command's output and exit code follow as usual. Ctrl-C during +the check ends the command with exit code 130 without waiting for it. + +Two exec paths do not run this check yet: an authored `.hack/hack.project.json` +project on the Compose backend (see +[native configuration with Compose](reference/native-compose-commands.md)), and an +existing Compose project switched with +[`hack config adopt`](reference/native-compose-adoption.md), whose exec runs in the +retained containers. MCP tools accept no env selector. They run the CLI without `--env`, so each row's omitted-`--env` behavior applies. @@ -545,7 +568,13 @@ no key file exists, the check classifies `HACK_ENV_SECRET_KEY` instead. The chec read-only. It never prints key bytes or absolute paths, and `--fix` does not act on it. Each finding names a project-relative subject and the exact command to run from the project root. `--json` adds an `envKey` object to the check with `status` and -`findings` (`code`, `status`, `subject`, `remedy`). +`findings` (`code`, `status`, `subject`, `remedy`). The native runtime backend's +`hack doctor` runs the same check inside a project. + +Nested public artifact directories are found with a bounded walk from the project +root: at most six levels deep, 4096 directories and 100,000 entries. It skips +`node_modules` and `.git`, does not follow symlinked directories, and reports a path +it cannot spell portably as `/.hack.secret.key`. | Code | Status | Meaning | | --- | --- | --- | @@ -559,7 +588,7 @@ project root. `--json` adds an `envKey` object to the check with `status` and | `env-key-not-regular` | error | the key path is a directory or another non-file entry | | `env-key-unreadable` | error | unreadable, oversized or not UTF-8 | | `env-key-tracked` | error | a file named `.hack.secret.key` is tracked by git at any depth | -| `env-key-public-path` | error | a key copy sits in `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` or `.output/` | +| `env-key-public-path` | error | a key copy sits directly in `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` or `.output/`, at the project root or in a nested package such as `apps/web/public/` | Hack derives the AES-256-GCM key by hashing the key text with SHA-256, so key strength is the entropy of that text. Generated keys are 32 random bytes in base64url. diff --git a/src/backends/native-authored-project-restart.ts b/src/backends/native-authored-project-restart.ts index a73cccd2..81e7ae94 100644 --- a/src/backends/native-authored-project-restart.ts +++ b/src/backends/native-authored-project-restart.ts @@ -79,6 +79,7 @@ export type NativeAuthoredProjectRestartOptions = { readonly onReady?: ServeOptions["onReady"]; readonly onExitDiagnostic?: ServeOptions["onExitDiagnostic"]; readonly onHookDiagnostic?: ServeOptions["onHookDiagnostic"]; + readonly onEnvironmentSelected?: ServeOptions["onEnvironmentSelected"]; /** Execution seam for sequencing controls; the CLI always uses the real owners. */ readonly dependencies?: Partial; }; @@ -170,6 +171,7 @@ export async function restartNativeAuthoredProject( onReady: opts.onReady, onExitDiagnostic: opts.onExitDiagnostic, onHookDiagnostic: opts.onHookDiagnostic, + onEnvironmentSelected: opts.onEnvironmentSelected, }); } catch (error: unknown) { const startup = diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 58aaa02e..df631f06 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -1,5 +1,9 @@ import { createHash } from "node:crypto"; import { isAbsolute } from "node:path"; +import { + type ComposeEnvFingerprintRecord, + withComposeServiceEnvFingerprints, +} from "../lib/compose-env-fingerprint.ts"; import { deepFreeze } from "../lib/guards.ts"; import { NativeComposeHostHookError } from "../lib/native-compose-host-contract.ts"; import { NativeConfigCompilerError } from "../lib/native-config-compiler.ts"; @@ -167,8 +171,41 @@ type Options = { ) => undefined; readonly onExitDiagnostic?: (diagnostic: NativeExitDiagnostic) => void; readonly onHookDiagnostic?: (event: NativeHookDiagnostic) => void; + /** + * Advisory stale-env bookkeeping (HACK-1140): value-free digests of the + * managed selection delivered to every selected workload, observed + * synchronously before the graph starts. Grants no authority; a failing + * observer never fails startup. + */ + readonly onEnvironmentSelected?: ( + fingerprint: ComposeEnvFingerprintRecord + ) => undefined; }; +type SelectedEnvironment = Readonly< + Record>> +>; + +function observeSelectedEnvironment( + opts: Options, + selected: SelectedEnvironment +): void { + if (!opts.onEnvironmentSelected) { + return; + } + try { + opts.onEnvironmentSelected( + withComposeServiceEnvFingerprints({ + existing: undefined, + services: Object.keys(selected), + envByService: selected, + }) + ); + } catch { + // Advisory only; startup proceeds without a stale-env record. + } +} + function observedReady( opts: Options, ready: NativeAuthoredProjectRunSelection @@ -216,31 +253,38 @@ function matchingReview( return review; } -/** Project only compiler-selected managed source keys. Rust owns destination remapping. */ -async function privateDelivery( - inputs: Inputs, - review: NativeAuthoredReview, - lifetimeMs: () => number -): Promise { - const sources = Object.entries(inputs.result.environment_plan.workloads) - .map(([name, bindings]) => ({ - name, - keys: [ - ...new Set( - Object.values(bindings).flatMap((binding) => - binding.kind === "managed" ? [binding.key] : [] - ) - ), - ], - })) - .filter(({ keys }) => keys.length > 0); - if (sources.length === 0) { - return undefined; - } - const values = await inputs.resolveManagedValues(); - const services = Object.fromEntries( - sources.map(({ name, keys }) => { - const selected = values[name]; +type EnvironmentPlan = Inputs["result"]["environment_plan"]; + +/** Compiler-selected managed source keys per workload; Rust owns destination remapping. */ +function managedSourceKeys( + environmentPlan: EnvironmentPlan +): readonly { readonly name: string; readonly keys: readonly string[] }[] { + return Object.entries(environmentPlan.workloads).map(([name, bindings]) => ({ + name, + keys: [ + ...new Set( + Object.values(bindings).flatMap((binding) => + binding.kind === "managed" ? [binding.key] : [] + ) + ), + ], + })); +} + +/** + * The managed values startup delivers, per selected workload: only + * compiler-selected source keys. Workloads without managed keys select an + * empty set. Values stay in memory; callers digest or deliver them privately. + */ +export function selectNativeAuthoredManagedEnvironment(opts: { + readonly environmentPlan: EnvironmentPlan; + readonly values: Readonly< + Record> | undefined> + >; +}): Readonly>>> { + return Object.fromEntries( + managedSourceKeys(opts.environmentPlan).map(({ name, keys }) => { + const selected = opts.values[name]; return [ name, Object.fromEntries( @@ -259,6 +303,32 @@ async function privateDelivery( ]; }) ); +} + +/** Private delivery of the selected managed values, or none when nothing is selected. */ +async function privateDelivery( + inputs: Inputs, + review: NativeAuthoredReview, + lifetimeMs: () => number, + observe: (environment: SelectedEnvironment) => void +): Promise { + const environmentPlan = inputs.result.environment_plan; + const managed = managedSourceKeys(environmentPlan).some( + ({ keys }) => keys.length > 0 + ); + const selected = selectNativeAuthoredManagedEnvironment({ + environmentPlan, + values: managed ? await inputs.resolveManagedValues() : {}, + }); + observe(selected); + if (!managed) { + return undefined; + } + const services = Object.fromEntries( + Object.entries(selected).filter( + ([, values]) => Object.keys(values).length > 0 + ) + ); const lifetime = Math.floor(lifetimeMs() / 1000); if (lifetime < 1 || lifetime > 300) { throw new Error("Native managed lifetime expired; values omitted."); @@ -949,7 +1019,12 @@ export async function serveNativeAuthoredProject( attempt = { source, start: await admission.reserve({ review }) }; outcome = "retained"; stage = "runtime"; - payload = await privateDelivery(inputs, review, remaining); + payload = await privateDelivery( + inputs, + review, + remaining, + (selected) => observeSelectedEnvironment(opts, selected) + ); await inputs.assertFresh(); await storageTool?.assertFresh(); await source.assertFresh(); diff --git a/src/backends/native-exec-stale-env.ts b/src/backends/native-exec-stale-env.ts new file mode 100644 index 00000000..6e05dfa3 --- /dev/null +++ b/src/backends/native-exec-stale-env.ts @@ -0,0 +1,158 @@ +import { buildStaleNativeEnvWarning } from "../lib/compose-env-fingerprint.ts"; +import { + evaluateNativeServiceEnvFreshness, + loadNativeEnvFingerprint, + type NativeEnvSelection, +} from "../lib/native-env-fingerprint-state.ts"; +import { acquireNativeExecutionInputs } from "../lib/native-execution-inputs.ts"; +import { + loadNativeAuthoredProjectRun, + type NativeAuthoredProjectRunScope, +} from "./native-authored-project-run.ts"; +import { selectNativeAuthoredManagedEnvironment } from "./native-authored-project-start.ts"; +import { prepareNativeProjectInput } from "./native-project-input.ts"; +import { + loadNativeProjectRun, + type NativeProjectRunScope, +} from "./native-project-run.ts"; + +/** + * Advisory stale-env checks for native `hack exec` (HACK-1140). + * + * Each resolver compares the value-free record the foreground up/restart + * wrote for the current run with the env selection resolved now, and returns + * a closed warning naming the service, or null. Null covers unchanged env, no + * record, a record from another run, and any failure: the check never refuses + * exec and never reports a key or value. Resolution runs only when a record + * for the current run exists. + */ + +/** + * Native image project: the selection is the Compose-file env resolved for + * the run's recorded env name, before adaptation, AWS or runtime metadata, + * exactly as startup digested it. Native exec delivers freshly resolved values + * to the exec itself, so the warning is about the running service. + */ +export async function resolveNativeImageExecStaleEnvWarning(opts: { + readonly scope: NativeProjectRunScope; + readonly service: string; + readonly composeFile: string; + readonly loadRun?: typeof loadNativeProjectRun; + readonly prepare?: typeof prepareNativeProjectInput; +}): Promise { + try { + const entry = await loadNativeEnvFingerprint({ + projectDir: opts.scope.projectDir, + owner: "image", + branch: opts.scope.branch, + }); + if (!entry) { + return null; + } + const mapping = await (opts.loadRun ?? loadNativeProjectRun)(opts.scope); + if ( + !mapping || + mapping.run !== entry.run || + mapping.effectiveEnvName === undefined + ) { + return null; + } + const input = await (opts.prepare ?? prepareNativeProjectInput)({ + projectRoot: opts.scope.projectRoot, + projectDir: opts.scope.projectDir, + composeFile: opts.composeFile, + envName: mapping.effectiveEnvName, + }); + if (input.effectiveEnvName !== mapping.effectiveEnvName) { + return null; + } + const freshness = evaluateNativeServiceEnvFreshness({ + entry, + run: mapping.run, + service: opts.service, + currentEnv: input.managedEnvironment[opts.service] ?? {}, + }); + return freshness === "changed" + ? buildStaleNativeEnvWarning({ service: opts.service, exec: "fresh" }) + : null; + } catch { + return null; + } +} + +/** The run id of the authored scope's published ready run, or null. */ +async function currentAuthoredRun( + scope: NativeAuthoredProjectRunScope +): Promise { + const ready = await loadNativeAuthoredProjectRun(scope); + return ready?.record.receipt.review.provenance.run ?? null; +} + +/** + * The managed selection an authored startup with `selection` would deliver + * now: fresh input acquisition, then the same projection startup digests. + */ +async function resolveAuthoredEnvironment(opts: { + readonly scope: NativeAuthoredProjectRunScope; + readonly selection: NativeEnvSelection; + readonly signal?: AbortSignal; +}): Promise>>>> { + const inputs = await acquireNativeExecutionInputs({ + projectRoot: opts.scope.projectRoot, + ...(opts.selection.profiles === undefined + ? {} + : { profiles: opts.selection.profiles }), + ...(opts.selection.overlay === undefined + ? {} + : { explicitOverlay: opts.selection.overlay }), + compilerBranch: opts.scope.branch ?? undefined, + signal: opts.signal, + }); + return selectNativeAuthoredManagedEnvironment({ + environmentPlan: inputs.result.environment_plan, + values: await inputs.resolveManagedValues(), + }); +} + +/** + * Authored native project: the selection is the compiler-selected managed + * values delivered at startup, resolved again with the recorded env and + * profile selectors. Authored exec runs through the live owner with the env + * the service started with. + */ +export async function resolveNativeAuthoredExecStaleEnvWarning(opts: { + readonly scope: NativeAuthoredProjectRunScope; + readonly service: string; + readonly signal?: AbortSignal; + readonly currentRun?: typeof currentAuthoredRun; + readonly resolveEnvironment?: typeof resolveAuthoredEnvironment; +}): Promise { + try { + const entry = await loadNativeEnvFingerprint({ + projectDir: opts.scope.projectDir, + owner: "authored", + branch: opts.scope.branch, + }); + if (!entry) { + return null; + } + const run = await (opts.currentRun ?? currentAuthoredRun)(opts.scope); + if (run === null || run !== entry.run) { + return null; + } + const environment = await ( + opts.resolveEnvironment ?? resolveAuthoredEnvironment + )({ scope: opts.scope, selection: entry.selection, signal: opts.signal }); + const freshness = evaluateNativeServiceEnvFreshness({ + entry, + run, + service: opts.service, + currentEnv: environment[opts.service] ?? {}, + }); + return freshness === "changed" + ? buildStaleNativeEnvWarning({ service: opts.service, exec: "started" }) + : null; + } catch { + return null; + } +} diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index fa346813..177abc1c 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -2,6 +2,10 @@ import { createHash, randomBytes } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; +import { + type ComposeEnvFingerprintRecord, + withComposeServiceEnvFingerprints, +} from "../lib/compose-env-fingerprint.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { @@ -855,6 +859,17 @@ export async function startNativeProject(opts: { /** Public instance identity delivered as runtime host metadata; omitted delivers none. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; readonly before: (input: NativeProjectInput) => Promise; + /** + * Advisory stale-env bookkeeping (HACK-1140): after the run mapping is + * published, receives the run id and value-free digests of the env + * selection resolved for delivery, before adaptation, AWS or runtime + * metadata. `hack exec` resolves the same selection again to compare. A + * failure here never fails startup. + */ + readonly onEnvironmentReady?: (selection: { + readonly run: string; + readonly fingerprint: ComposeEnvFingerprintRecord; + }) => Promise; readonly signal?: AbortSignal; readonly dependencies?: Partial; }): Promise { @@ -914,6 +929,14 @@ export async function startNativeProject(opts: { composeFile: opts.composeFile, envName: selection.envName, }); + // Digest the selection now: later steps adapt the delivered values. + const selectedEnvironment = opts.onEnvironmentReady + ? withComposeServiceEnvFingerprints({ + existing: undefined, + services: input.serviceNames, + envByService: input.managedEnvironment, + }) + : undefined; if ( retained && !opts.restore && @@ -1308,6 +1331,14 @@ export async function startNativeProject(opts: { }); mapping = persistedMapping; } + if (selectedEnvironment) { + await opts + .onEnvironmentReady?.({ + run, + fingerprint: selectedEnvironment, + }) + .catch(() => undefined); + } await hooks?.ready?.(); await opts.onReady?.(); }, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 51a5e22a..a0c88484 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -533,6 +533,7 @@ const handleDoctor: CommandHandlerFor = async ({ if (native) { return await handleNativeDoctor({ runtime: native, + startDir: resolveDoctorStartDir(args.options.path), browser, json, fix: args.options.fix === true, @@ -1030,6 +1031,8 @@ function resolveDoctorStartDir(pathOption: string | undefined): string { async function handleNativeDoctor(opts: { readonly runtime: NativeRuntimeSelection; + /** Where project discovery starts for project-scoped checks. */ + readonly startDir: string; readonly browser: ReturnType; readonly json: boolean; readonly fix: boolean; @@ -1162,6 +1165,18 @@ async function handleNativeDoctor(opts: { ) ); } + // The env key audit is runtime-independent; outside a project there is no + // key to audit, so native Doctor adds no check there. + if (await findProjectContext(opts.startDir).catch(() => null)) { + results.push( + await runCheck( + progress, + ENV_KEY_CHECK, + () => checkProjectEnvKey({ startDir: opts.startDir }), + { timeoutMs: 5000 } + ) + ); + } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); const reported = await reportDoctorResults({ @@ -4690,16 +4705,17 @@ async function runCheck( opts?: { readonly timeoutMs?: number } ): Promise { const start = Date.now(); + let timer: ReturnType | undefined; try { const res = opts?.timeoutMs ? await Promise.race([ fn(), - new Promise((resolve) => - setTimeout( + new Promise((resolve) => { + timer = setTimeout( () => resolve({ name, status: "warn", message: "Timed out" }), opts.timeoutMs - ) - ), + ); + }), ]) : await fn(); const durationMs = Date.now() - start; @@ -4709,5 +4725,8 @@ async function runCheck( const message = err instanceof Error ? err.message : "Unknown error"; const res: CheckResult = { name, status: "error", message }; return { ...res, durationMs }; + } finally { + // A settled check must not keep the process alive until its deadline. + clearTimeout(timer); } } diff --git a/src/commands/project-exec-native.ts b/src/commands/project-exec-native.ts index f2c8b5ab..4b611292 100644 --- a/src/commands/project-exec-native.ts +++ b/src/commands/project-exec-native.ts @@ -1,3 +1,4 @@ +import { resolveNativeImageExecStaleEnvWarning } from "../backends/native-exec-stale-env.ts"; import { nativeProjectExec } from "../backends/native-project-exec.ts"; import type { NativeProjectRunScope } from "../backends/native-project-run.ts"; import type { NativeRuntimeSelection } from "../backends/native-runtime-client.ts"; @@ -6,6 +7,10 @@ import type { NativeRuntimeSelection } from "../backends/native-runtime-client.t * The native branch of `hack exec`: one bounded noninteractive command through the * selected runtime. Captured output is passed through as returned, with a fixed * truncation notice on stderr; the command's exit code is the result. + * + * An advisory value-free check (HACK-1140) warns on stderr, ahead of the + * captured output, when the service's env selection changed since its run + * started. The check never refuses or changes the exec. */ export async function runNativeProjectExec(input: { readonly runtime: NativeRuntimeSelection; @@ -14,15 +19,31 @@ export async function runNativeProjectExec(input: { readonly argv: readonly string[]; readonly workdir: string | undefined; readonly composeFile: string; + /** Seams for unit tests; the CLI always uses the real owners. */ + readonly exec?: typeof nativeProjectExec; + readonly staleEnv?: typeof resolveNativeImageExecStaleEnvWarning; }): Promise { - const result = await nativeProjectExec({ + const service = input.service ?? ""; + const result = await (input.exec ?? nativeProjectExec)({ runtime: input.runtime, scope: input.scope, - service: input.service ?? "", + service, argv: input.argv, workdir: input.workdir, composeFile: input.composeFile, }); + // Checked after the exec is admitted, so a refused request resolves no env. + // Output is captured, so the warning still precedes it. + const warning = await ( + input.staleEnv ?? resolveNativeImageExecStaleEnvWarning + )({ + scope: input.scope, + service, + composeFile: input.composeFile, + }).catch(() => null); + if (warning) { + process.stderr.write(`WARNING: ${warning}\n`); + } process.stdout.write(result.stdout); process.stderr.write(result.stderr); if (result.truncated) { diff --git a/src/commands/project.ts b/src/commands/project.ts index 2900fd41..2d38f770 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -209,6 +209,10 @@ import { } from "../lib/logs.ts"; import { tryNativeComposeCommand } from "../lib/native-compose-command.ts"; import { tryNativeComposeOpen } from "../lib/native-compose-open.ts"; +import { + createNativeEnvFingerprintEntry, + createNativeEnvFingerprintSession, +} from "../lib/native-env-fingerprint-state.ts"; import { readNodesRegistry } from "../lib/nodes-registry.ts"; import { parseOpenHostPreference, @@ -4947,6 +4951,13 @@ async function handleNativeUp({ branch, }); const devHost = await resolveRuntimeDevHost({ project, branch }); + const envFingerprintScope = { + projectDir: project.projectDir, + owner: "image" as const, + branch, + }; + const envFingerprints = + createNativeEnvFingerprintSession(envFingerprintScope); const startup: Parameters[0] = { runtime: native, scope: { @@ -4975,6 +4986,10 @@ async function handleNativeUp({ aliasHost: resolveBranchAliasHost({ devHost, cfg }), projectName, }, + onEnvironmentReady: async ({ run, fingerprint }) => + await envFingerprints.record( + createNativeEnvFingerprintEntry({ owner: "image", run, fingerprint }) + ), before: async (input) => { const lifecycle = await runLifecycleUpBeforeAndProcesses({ title: "Lifecycle (native up before)", @@ -5014,13 +5029,68 @@ async function handleNativeUp({ }, }; if (!restart) { - return await startNativeProject(startup); + try { + return await startNativeProject(startup); + } finally { + await envFingerprints.release(); + } } if (!startup.sharedSource) { throw new CliUsageError( "Native restart requires HACK_NATIVE_SHARED_SOURCE=1 before cleanup." ); } + try { + return await restartNativeImageProject({ + ctx, + args, + native, + project, + cfg, + projectName, + branch, + composeProject, + startup, + recovery, + // Stopping the previous run makes its record stale; a failed restart + // then leaves no record, so exec stays silent rather than guessing. + dropEnvFingerprint: () => envFingerprints.drop(), + }); + } finally { + await envFingerprints.release(); + } +} + +async function restartNativeImageProject({ + ctx, + args, + native, + project, + cfg, + projectName, + branch, + composeProject, + startup, + recovery, + dropEnvFingerprint, +}: { + readonly ctx: CliContext; + readonly args: UpArgs; + readonly native: NonNullable< + ReturnType + >; + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly projectName: string; + readonly branch: string | null; + readonly composeProject: string; + readonly startup: Parameters[0]; + readonly recovery?: { + readonly expectAttempt: string; + readonly legacyPid?: number; + }; + readonly dropEnvFingerprint: () => void; +}): Promise { return await restartNativeProject({ scope: startup.scope, envName: startup.envName, @@ -5076,6 +5146,7 @@ async function handleNativeUp({ }); }, down: async () => { + dropEnvFingerprint(); const code = await handleDown({ ctx, deferFinalization: true, diff --git a/src/lib/compose-env-fingerprint.ts b/src/lib/compose-env-fingerprint.ts index 381d7664..1d8cd438 100644 --- a/src/lib/compose-env-fingerprint.ts +++ b/src/lib/compose-env-fingerprint.ts @@ -158,6 +158,29 @@ export function buildStaleComposeEnvWarning(opts: { return `Env for ${subject} changed since it started; this exec uses the env it started with. Run \`${restart}\` to apply the current env.`; } +/** + * Closed, value-free warning for a stale running native service (HACK-1140). + * Native restart is whole-project, so the hint never names a service. + * + * - `started`: the exec runs with the env the service started with (authored + * native exec through the live owner). + * - `fresh`: the exec itself received freshly resolved values, but the running + * service keeps its start-time env (native image-project exec). + */ +export function buildStaleNativeEnvWarning(opts: { + readonly service: string; + readonly exec: "started" | "fresh"; +}): string { + const subject = SERVICE_NAME_PATTERN.test(opts.service) + ? `service "${opts.service}"` + : "this service"; + const effect = + opts.exec === "started" + ? "this exec uses the env it started with" + : "the running service still uses the env it started with"; + return `Env for ${subject} changed since it started; ${effect}. Run \`hack restart\` to apply the current env.`; +} + /** Strict parse of a stored record; anything unexpected is treated as absent. */ export function parseComposeEnvFingerprintRecord( value: unknown diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 7f3544a4..5ee08e9d 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -1,5 +1,5 @@ import type { Stats } from "node:fs"; -import { lstat, realpath } from "node:fs/promises"; +import { lstat, readdir, realpath } from "node:fs/promises"; import { join } from "node:path"; import { PROJECT_ENV_KEY_FILENAME } from "../constants.ts"; import { resolveGitPrimaryWorktreeRoot } from "./git-worktree.ts"; @@ -92,7 +92,8 @@ const ENV_KEY_ENV_NAME = "HACK_ENV_SECRET_KEY"; /** * Build-output and static-hosting directories whose contents are commonly - * published. A key file directly inside one is reported as exposed. + * published. A key file directly inside one is reported as exposed, at the + * project root and in nested packages (bounded walk; see below). */ export const ENV_KEY_PUBLIC_ARTIFACT_DIRS = [ "public", @@ -106,6 +107,21 @@ export const ENV_KEY_PUBLIC_ARTIFACT_DIRS = [ ".output", ] as const; +/** Directory names the nested public-artifact walk never enters. */ +const ENV_KEY_PUBLIC_WALK_SKIP: ReadonlySet = new Set([ + "node_modules", + ".git", +]); +/** + * Bounds of the nested public-artifact walk: the deepest artifact directory + * considered (the project root is depth 0), the most directories read, and the + * most directory entries examined. Past a bound the walk stops; it never + * reports a partial path or fails the check. + */ +export const ENV_KEY_PUBLIC_WALK_MAX_DEPTH = 6; +export const ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES = 4096; +export const ENV_KEY_PUBLIC_WALK_MAX_ENTRIES = 100_000; + const STATUS_RANK: Record = { ok: 0, warn: 1, @@ -309,24 +325,138 @@ function portableRelativePath(path: string): string | null { : null; } +function publicPathFinding(relativeDir: string): EnvKeyFinding { + const portable = portableRelativePath(relativeDir); + if (portable === null) { + return { + code: "env-key-public-path", + status: "error", + subject: `/${PROJECT_ENV_KEY_FILENAME}`, + remedy: + "Remove the key copy from the public artifact directory, then rotate the key: a published copy is not secret.", + }; + } + const relative = `${portable}/${PROJECT_ENV_KEY_FILENAME}`; + return { + code: "env-key-public-path", + status: "error", + subject: relative, + remedy: `rm -- ${relative}, then rotate the key: a published copy is not secret.`, + }; +} + +/** Locale-independent order so the walk and its findings are stable. */ +function compareNames(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +const PUBLIC_ARTIFACT_DIR_NAMES: ReadonlySet = new Set( + ENV_KEY_PUBLIC_ARTIFACT_DIRS +); + +/** + * Project-relative paths (with `/` separators) of public artifact directories + * below the top level, such as `apps/web/public` in a monorepo. Bounded + * breadth-first walk in name order: symlinked directories are not followed, + * `node_modules` and `.git` are skipped, and unreadable directories are + * passed over. + */ +export async function findNestedPublicArtifactDirs(opts: { + readonly projectRoot: string; + /** Test seam for the walk bounds; production uses the documented constants. */ + readonly limits?: { + readonly maxDirectories?: number; + readonly maxEntries?: number; + }; +}): Promise { + const maxDirectories = + opts.limits?.maxDirectories ?? ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES; + const maxEntries = opts.limits?.maxEntries ?? ENV_KEY_PUBLIC_WALK_MAX_ENTRIES; + const found: string[] = []; + const queue: { readonly relative: string; readonly depth: number }[] = [ + { relative: "", depth: 0 }, + ]; + let index = 0; + let entries = 0; + while ( + index < queue.length && + index < maxDirectories && + entries < maxEntries + ) { + const current = queue[index++]; + if (!current) { + break; + } + const children = await walkableChildDirectories( + join(opts.projectRoot, current.relative) + ); + entries += children.entries; + const depth = current.depth + 1; + for (const name of children.names) { + const relative = current.relative ? `${current.relative}/${name}` : name; + if (depth >= 2 && PUBLIC_ARTIFACT_DIR_NAMES.has(name)) { + found.push(relative); + } + if (depth < ENV_KEY_PUBLIC_WALK_MAX_DEPTH) { + queue.push({ relative, depth }); + } + } + } + return found; +} + +/** + * Sorted names of the child directories the walk may enter (real directories, + * not symlinks, outside the skip list) and the number of entries read; an + * unreadable directory has none. + */ +async function walkableChildDirectories(path: string): Promise<{ + readonly names: readonly string[]; + readonly entries: number; +}> { + try { + const children = await readdir(path, { withFileTypes: true }); + return { + entries: children.length, + names: children + .filter( + (child) => + child.isDirectory() && !ENV_KEY_PUBLIC_WALK_SKIP.has(child.name) + ) + .map((child) => child.name) + .sort(compareNames), + }; + } catch { + return { names: [], entries: 0 }; + } +} + async function inspectPublicArtifactCopies(opts: { readonly projectRoot: string; }): Promise { const findings: EnvKeyFinding[] = []; - for (const dir of ENV_KEY_PUBLIC_ARTIFACT_DIRS) { - const relative = `${dir}/${PROJECT_ENV_KEY_FILENAME}`; + const present = async (relativeDir: string) => // An entry that exists but cannot be inspected still counts as present. - const present = await lstatOrNull(join(opts.projectRoot, relative)).then( + await lstatOrNull( + join(opts.projectRoot, relativeDir, PROJECT_ENV_KEY_FILENAME) + ).then( (stats) => stats !== null, () => true ); - if (present) { - findings.push({ - code: "env-key-public-path", - status: "error", - subject: relative, - remedy: `rm -- ${relative}, then rotate the key: a published copy is not secret.`, - }); + for (const dir of ENV_KEY_PUBLIC_ARTIFACT_DIRS) { + if (await present(dir)) { + findings.push(publicPathFinding(dir)); + } + } + const nested = await findNestedPublicArtifactDirs({ + projectRoot: opts.projectRoot, + }).catch(() => []); + for (const dir of nested) { + if (await present(dir)) { + findings.push(publicPathFinding(dir)); } } return findings; diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 06365e02..6e059746 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -25,6 +25,7 @@ import { NativeAuthoredProjectStartError, serveNativeAuthoredProject, } from "../backends/native-authored-project-start.ts"; +import { resolveNativeAuthoredExecStaleEnvWarning } from "../backends/native-exec-stale-env.ts"; import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; import { CliUsageError } from "../cli/command.ts"; @@ -34,6 +35,11 @@ import { emitCliResult, HackCliError, okResult } from "./cli-result.ts"; import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import type { NativeComposeCommandOptions } from "./native-compose-command.ts"; import type { NativeComposeSelection } from "./native-compose-selection.ts"; +import { + createNativeEnvFingerprintEntry, + createNativeEnvFingerprintSession, + type NativeEnvFingerprintSession, +} from "./native-env-fingerprint-state.ts"; function unsupported(): never { throw new HackCliError({ @@ -353,6 +359,10 @@ type Seams = { readonly stop?: typeof stopNativeAuthoredProject; readonly exec?: typeof nativeAuthoredProjectExec; readonly run?: typeof nativeAuthoredProjectRun; + /** Advisory stale-env seam; the CLI always uses the real resolver. */ + readonly staleEnv?: typeof resolveNativeAuthoredExecStaleEnvWarning; + /** Test seam for the advisory bound; the CLI always uses the default. */ + readonly staleEnvTimeoutMs?: number; }; type Runtime = NonNullable>; type RunnerInput = { @@ -423,16 +433,51 @@ function nativeHttpsPort( } } -/** Fresh foreground selection shared by up and the start half of restart. */ -function startupSelection( +/** Value-free stale-env bookkeeping for one foreground lifetime (HACK-1140). */ +function envFingerprintSession( input: LifecycleInput +): NativeEnvFingerprintSession { + return createNativeEnvFingerprintSession({ + projectDir: input.scope.projectDir, + owner: "authored", + branch: input.scope.branch, + }); +} + +/** + * Fresh foreground selection shared by up and the start half of restart. The + * delivered env selection is captured as value-free digests and recorded once + * the run is ready, so exec can warn when the env resolved later differs. + */ +function startupSelection( + input: LifecycleInput, + envFingerprints: NativeEnvFingerprintSession ): Parameters[0] { + const run = randomBytes(16).toString("hex"); return { runtime: input.runtime, scope: input.scope, - run: randomBytes(16).toString("hex"), + run, profiles: input.options.profiles, overlay: input.options.overlay, + onEnvironmentSelected: (fingerprint) => { + envFingerprints.capture( + createNativeEnvFingerprintEntry({ + owner: "authored", + run, + fingerprint, + selection: { + ...(input.options.overlay === undefined + ? {} + : { overlay: input.options.overlay }), + ...(input.options.profiles === undefined + ? {} + : { profiles: input.options.profiles }), + }, + }) + ); + return undefined; + }, httpsPort: nativeHttpsPort(input.env), startupTimeoutMs: input.startupTimeoutMs, signal: input.signal, @@ -446,30 +491,43 @@ function startupSelection( } }, onReady: () => { + envFingerprints.commit(); logger.info({ message: "Project is ready. Press Ctrl-C to stop." }); return undefined; }, }; } -function runStart(input: LifecycleInput): Promise { - return (input.seams.serve ?? serveNativeAuthoredProject)( - startupSelection(input) - ); +async function runStart(input: LifecycleInput): Promise { + const envFingerprints = envFingerprintSession(input); + try { + return await (input.seams.serve ?? serveNativeAuthoredProject)( + startupSelection(input, envFingerprints) + ); + } finally { + await envFingerprints.release(); + } } -function runRestart(input: LifecycleInput): Promise { - return restartNativeAuthoredProject({ - ...startupSelection(input), - onStopped: () => { - logger.info({ - message: - "Selected native project stopped; persistent data is retained. Starting a fresh foreground generation.", - }); - return undefined; - }, - dependencies: { stop: input.seams.stop, serve: input.seams.serve }, - }); +async function runRestart(input: LifecycleInput): Promise { + const envFingerprints = envFingerprintSession(input); + try { + return await restartNativeAuthoredProject({ + ...startupSelection(input, envFingerprints), + onStopped: () => { + // The stopped run's record is stale; a failed start leaves none. + envFingerprints.drop(); + logger.info({ + message: + "Selected native project stopped; persistent data is retained. Starting a fresh foreground generation.", + }); + return undefined; + }, + dependencies: { stop: input.seams.stop, serve: input.seams.serve }, + }); + } finally { + await envFingerprints.release(); + } } /** Two-stage cancellation and the bounded startup budget shared by the lifecycle modes. */ @@ -524,6 +582,7 @@ const RUNNERS: Readonly Promise>> = runtime: input.runtime, perform: input.seams.exec ?? nativeAuthoredProjectExec, contract: COMMAND_CONTRACTS.exec, + beforeOutput: (signal) => warnStaleAuthoredEnv(input, signal), }), run: (input) => runAuthoredCommand({ @@ -547,13 +606,93 @@ const RUNNERS: Readonly Promise>> = restart: (input) => runAuthoredLifecycle(input, "restart", runRestart), }; +/** + * Upper bound on the advisory stale-env resolution of one authored exec. Past + * it, or on cancellation, the exec output is written without a warning. + */ +export const NATIVE_AUTHORED_EXEC_STALE_ENV_TIMEOUT_MS = 5000; + +/** + * Advisory stale-env check for an admitted authored exec (HACK-1140). It reads + * the value-free record of the current run and, only when one exists, resolves + * the same env selection again. The resolution receives the exec's cancellation + * signal bounded by {@link NATIVE_AUTHORED_EXEC_STALE_ENV_TIMEOUT_MS}, and the + * check settles as soon as that signal aborts even if the resolution has not. + * The closed warning goes to stderr ahead of the captured output; stdout stays + * the command's output. It never changes the exec or its exit code. + */ +async function warnStaleAuthoredEnv( + input: RunnerInput, + signal: AbortSignal +): Promise { + const bounded = AbortSignal.any([ + signal, + AbortSignal.timeout( + input.seams.staleEnvTimeoutMs ?? NATIVE_AUTHORED_EXEC_STALE_ENV_TIMEOUT_MS + ), + ]); + let root: string; + try { + root = await realpath(input.projectRoot); + } catch { + return; + } + const warning = await settleBeforeAbort( + (input.seams.staleEnv ?? resolveNativeAuthoredExecStaleEnvWarning)({ + scope: { + projectRoot: root, + projectDir: join(root, ".hack"), + nativeHome: input.runtime.home, + branch: input.options.instance ?? null, + }, + service: input.options.service ?? "", + signal: bounded, + }), + bounded + ); + if (warning) { + process.stderr.write(`WARNING: ${warning}\n`); + } +} + +/** + * The advisory value, or null once `signal` aborts or the work fails. The + * abandoned work keeps its own signal and settles on its own; nothing waits + * for it. + */ +function settleBeforeAbort( + work: Promise, + signal: AbortSignal +): Promise { + if (signal.aborted) { + work.catch(() => undefined); + return Promise.resolve(null); + } + return new Promise((resolve) => { + const onAbort = () => resolve(null); + signal.addEventListener("abort", onAbort, { once: true }); + work.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + () => { + signal.removeEventListener("abort", onAbort); + resolve(null); + } + ); + }); +} + /** * Explicit native dispatch after exact authored-family and adoption selection. * Compose and omitted selections retain their existing owner. Foreground up delegates * to the tagged lifetime owner; ordinary down uses its authenticated stop. Foreground * restart sequences that same stop before a fresh foreground up with the given * selection. Ps and finite service logs observe its saved run; finite exec and - * one-off run send one command through its live owner; + * one-off run send one command through its live owner (a completed exec may + * resolve its run's recorded env selection again, for an advisory value-free + * stale-env warning only); * explicit down --recover uses the stored cleanup owner without input acquisition. * Unsupported requests never start input acquisition or runtime work. */ @@ -672,6 +811,12 @@ async function runAuthoredCommand(input: { readonly signal: AbortSignal; }) => Promise; readonly contract: CommandContract; + /** + * Advisory step after a validated completion and before any output. It + * receives the command's cancellation signal and must settle promptly once + * that signal aborts. + */ + readonly beforeOutput?: (signal: AbortSignal) => Promise; }): Promise { const controller = new AbortController(); const cancel = () => controller.abort(); @@ -695,6 +840,10 @@ async function runAuthoredCommand(input: { if (controller.signal.aborted) { return 130; } + await input.beforeOutput?.(controller.signal).catch(() => undefined); + if (controller.signal.aborted) { + return 130; + } await Bun.write(Bun.stdout, result.stdout); if (controller.signal.aborted) { return 130; diff --git a/src/lib/native-env-fingerprint-state.ts b/src/lib/native-env-fingerprint-state.ts new file mode 100644 index 00000000..4c9176c1 --- /dev/null +++ b/src/lib/native-env-fingerprint-state.ts @@ -0,0 +1,342 @@ +import { createHash, randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, rename, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { + type ComposeEnvFingerprintRecord, + type ComposeServiceEnvFreshness, + evaluateComposeServiceEnvFreshness, + parseComposeEnvFingerprintRecord, +} from "./compose-env-fingerprint.ts"; +import { isRecord } from "./guards.ts"; + +/** + * Value-free record of the env selection a native foreground run delivered to + * its services (HACK-1140), so `hack exec` can warn when the env resolved now + * differs from the env a running service started with. + * + * Ownership: the foreground `hack up`/`hack restart` that delivered the env + * owns its record. It writes the record once the run is ready and removes it + * (only if the record is still its own) when the foreground ends. A restart + * drops the scope's record once the previous run is being stopped, so a failed + * restart leaves no record and exec stays silent rather than guessing. + * + * Invariants: + * - The record holds the salted HMAC digests from `compose-env-fingerprint.ts`, + * the run id it belongs to and, for authored projects, the public env and + * profile selectors needed to resolve the same selection again. It never + * holds a key name or value. + * - Comparison is bound to the run id: a record from another run is `unknown`. + * - Everything is advisory. Read failures, malformed or foreign records yield + * no record; write failures never fail startup or exec. + * - Each scope (owner kind plus branch) has its own file, written atomically + * (temporary file plus rename, mode 0600), so concurrent instances of one + * project never overwrite each other's record. + */ + +export type NativeEnvFingerprintOwner = "image" | "authored"; + +export type NativeEnvFingerprintScope = { + /** The project's `.hack` directory. */ + readonly projectDir: string; + readonly owner: NativeEnvFingerprintOwner; + readonly branch: string | null; +}; + +/** Public selectors an authored run was started with; never values. */ +export type NativeEnvSelection = { + /** Absent: the default selection; null: explicit base. */ + readonly overlay?: string | null; + readonly profiles?: readonly string[]; +}; + +export type NativeEnvFingerprintEntry = { + readonly version: typeof NATIVE_ENV_FINGERPRINT_VERSION; + readonly owner: NativeEnvFingerprintOwner; + readonly run: string; + readonly selection: NativeEnvSelection; + readonly fingerprint: ComposeEnvFingerprintRecord; +}; + +export const NATIVE_ENV_FINGERPRINT_VERSION = 1 as const; + +const RUN_PATTERN = /^[a-f0-9]{32}$/; +// Equivalent to normalizeEnvConfigName(value) === value, bounded. +const ENV_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const CONTROL = /\p{Cc}/u; +const MAX_PROFILES = 64; +const MAX_PROFILE_BYTES = 256; +const MAX_RECORD_BYTES = 256 * 1024; +const DIRECTORY = "native-env-fingerprints"; + +/** Absolute path of a scope's record; the file name never carries the branch. */ +export function resolveNativeEnvFingerprintPath( + scope: NativeEnvFingerprintScope +): string { + const key = createHash("sha256") + .update(JSON.stringify({ owner: scope.owner, branch: scope.branch })) + .digest("hex"); + return join(scope.projectDir, ".internal", DIRECTORY, `${key}.json`); +} + +/** Pure, value-free entry for `run`; throws on an invalid run id or selection. */ +export function createNativeEnvFingerprintEntry(opts: { + readonly owner: NativeEnvFingerprintOwner; + readonly run: string; + readonly fingerprint: ComposeEnvFingerprintRecord; + readonly selection?: NativeEnvSelection; +}): NativeEnvFingerprintEntry { + const selection = opts.selection ?? {}; + if (!(RUN_PATTERN.test(opts.run) && validSelection(selection))) { + throw new Error("Invalid native env fingerprint entry; values omitted."); + } + return { + version: NATIVE_ENV_FINGERPRINT_VERSION, + owner: opts.owner, + run: opts.run, + selection: { + ...(selection.overlay === undefined + ? {} + : { overlay: selection.overlay }), + ...(selection.profiles === undefined + ? {} + : { profiles: [...selection.profiles] }), + }, + fingerprint: opts.fingerprint, + }; +} + +/** Atomically replace the scope's record. Throws on I/O failure. */ +export async function saveNativeEnvFingerprint( + scope: NativeEnvFingerprintScope, + entry: NativeEnvFingerprintEntry +): Promise { + if (entry.owner !== scope.owner) { + throw new Error("Native env fingerprint owner mismatch; values omitted."); + } + const path = resolveNativeEnvFingerprintPath(scope); + const directory = join(scope.projectDir, ".internal", DIRECTORY); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = join(directory, `.${randomBytes(8).toString("hex")}.tmp`); + const handle = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + 0o600 + ); + try { + await handle.writeFile(`${JSON.stringify(entry)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, path); + } catch (error) { + await unlink(temporary).catch(() => undefined); + throw error; + } +} + +/** The scope's record, or null when absent, unreadable, malformed or foreign. */ +export async function loadNativeEnvFingerprint( + scope: NativeEnvFingerprintScope +): Promise { + let text: string; + try { + const handle = await open( + resolveNativeEnvFingerprintPath(scope), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const stats = await handle.stat(); + if (!stats.isFile() || stats.size > MAX_RECORD_BYTES) { + return null; + } + text = await handle.readFile({ encoding: "utf8" }); + } finally { + await handle.close(); + } + } catch { + return null; + } + try { + return parseNativeEnvFingerprintEntry(JSON.parse(text), scope.owner); + } catch { + return null; + } +} + +/** + * Remove the scope's record. With `salt`, only a record that still carries + * that salt (the caller's own record) is removed. Never throws. + */ +export async function forgetNativeEnvFingerprint( + scope: NativeEnvFingerprintScope, + opts: { readonly salt?: string } = {} +): Promise { + try { + if (opts.salt !== undefined) { + const current = await loadNativeEnvFingerprint(scope); + if (current?.fingerprint.salt !== opts.salt) { + return; + } + } + await unlink(resolveNativeEnvFingerprintPath(scope)); + } catch { + // Absent or unremovable: exec still compares only within the same run. + } +} + +/** Compare a record for the current `run` with the env resolved now. */ +export function evaluateNativeServiceEnvFreshness(opts: { + readonly entry: NativeEnvFingerprintEntry | null; + readonly run: string; + readonly service: string; + readonly currentEnv: Readonly>; +}): ComposeServiceEnvFreshness { + if (!opts.entry || opts.entry.run !== opts.run) { + return "unknown"; + } + return evaluateComposeServiceEnvFreshness({ + record: opts.entry.fingerprint, + service: opts.service, + currentEnv: opts.currentEnv, + }); +} + +/** + * Bookkeeping for one foreground lifetime. Writes are queued in call order and + * every failure is swallowed: the record is advisory. + */ +export type NativeEnvFingerprintSession = { + /** Remember a value-free entry to commit later (for synchronous observers). */ + readonly capture: (entry: NativeEnvFingerprintEntry) => void; + /** Queue the write of the captured entry, if any. */ + readonly commit: () => void; + /** Queue removal of the scope's record, whoever wrote it. */ + readonly drop: () => void; + /** Capture, commit and wait for the write. */ + readonly record: (entry: NativeEnvFingerprintEntry) => Promise; + /** Wait for queued writes, then remove the record if it is still ours. */ + readonly release: () => Promise; +}; + +export function createNativeEnvFingerprintSession( + scope: NativeEnvFingerprintScope +): NativeEnvFingerprintSession { + let captured: NativeEnvFingerprintEntry | undefined; + let written: string | undefined; + let queue: Promise = Promise.resolve(); + const enqueue = (step: () => Promise) => { + queue = queue.then(step).catch(() => undefined); + }; + const capture = (entry: NativeEnvFingerprintEntry) => { + captured = entry; + }; + const commit = () => { + const entry = captured; + captured = undefined; + if (!entry) { + return; + } + enqueue(async () => { + await saveNativeEnvFingerprint(scope, entry); + written = entry.fingerprint.salt; + }); + }; + return { + capture, + commit, + drop: () => { + enqueue(async () => { + await forgetNativeEnvFingerprint(scope); + }); + }, + record: async (entry) => { + capture(entry); + commit(); + await queue; + }, + release: async () => { + await queue; + const salt = written; + written = undefined; + if (salt !== undefined) { + await forgetNativeEnvFingerprint(scope, { salt }); + } + }, + }; +} + +function validSelection(value: { + readonly overlay?: unknown; + readonly profiles?: unknown; +}): value is NativeEnvSelection { + const overlay = value.overlay; + if ( + overlay !== undefined && + overlay !== null && + !( + typeof overlay === "string" && + overlay.length <= 128 && + ENV_NAME_PATTERN.test(overlay) + ) + ) { + return false; + } + const profiles = value.profiles; + return ( + profiles === undefined || + (Array.isArray(profiles) && + profiles.length <= MAX_PROFILES && + profiles.every( + (profile: unknown) => + typeof profile === "string" && + profile.length > 0 && + Buffer.byteLength(profile) <= MAX_PROFILE_BYTES && + !CONTROL.test(profile) + )) + ); +} + +function parseNativeEnvFingerprintEntry( + value: unknown, + owner: NativeEnvFingerprintOwner +): NativeEnvFingerprintEntry | null { + if ( + !isRecord(value) || + value.version !== NATIVE_ENV_FINGERPRINT_VERSION || + value.owner !== owner || + typeof value.run !== "string" || + !RUN_PATTERN.test(value.run) || + !isRecord(value.selection) + ) { + return null; + } + const rawSelection = value.selection; + const keys = Object.keys(rawSelection); + if (keys.some((key) => key !== "overlay" && key !== "profiles")) { + return null; + } + const selection: { overlay?: unknown; profiles?: unknown } = { + ...(Object.hasOwn(rawSelection, "overlay") + ? { overlay: rawSelection.overlay } + : {}), + ...(Object.hasOwn(rawSelection, "profiles") + ? { profiles: rawSelection.profiles } + : {}), + }; + if (!validSelection(selection)) { + return null; + } + const fingerprint = parseComposeEnvFingerprintRecord(value.fingerprint); + if (!fingerprint) { + return null; + } + return createNativeEnvFingerprintEntry({ + owner, + run: value.run, + fingerprint, + selection, + }); +} diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index 5ff02d38..f3b9283c 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -16,8 +16,10 @@ import { } from "../src/commands/doctor.ts"; import { classifyEnvKeyText, + ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES, type EnvKeyCandidate, type EnvKeyInspection, + findNestedPublicArtifactDirs, gitListTrackedKeyPaths, type InspectEnvKeyOptions, inspectProjectEnvKey, @@ -294,6 +296,193 @@ describe("env key doctor diagnostic", () => { ]); }); + test("nested monorepo public artifact copies are errors with project-relative subjects", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + for (const dir of [ + ["apps", "web", "public"], + ["packages", "site", "dist"], + ["apps", "docs", ".next"], + ]) { + await mkdir(join(root, ...dir), { recursive: true }); + await writeKey({ + path: join(root, ...dir, KEY_FILE), + text: key, + mode: 0o600, + }); + } + + const inspection = await inspect(root); + + expect(inspection.status).toBe("error"); + expect( + inspection.findings.map((finding) => [finding.code, finding.subject]) + ).toEqual([ + ["env-key-public-path", `apps/docs/.next/${KEY_FILE}`], + ["env-key-public-path", `apps/web/public/${KEY_FILE}`], + ["env-key-public-path", `packages/site/dist/${KEY_FILE}`], + ]); + expect(inspection.findings[1]?.remedy).toBe( + `rm -- apps/web/public/${KEY_FILE}, then rotate the key: a published copy is not secret.` + ); + expectValueFree({ output: serialized(inspection), root, secrets: [key] }); + }); + + test("negative control: the nested walk skips node_modules, .git and symlinked directories", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + for (const dir of [ + ["node_modules", "pkg", "dist"], + [".git", "public"], + ["apps", "web", "node_modules", "lib", "public"], + ["outside", "public"], + ]) { + await mkdir(join(root, ...dir), { recursive: true }); + await writeKey({ + path: join(root, ...dir, KEY_FILE), + text: key, + mode: 0o600, + }); + } + await mkdir(join(root, "apps", "linked"), { recursive: true }); + await symlink( + join(root, "outside", "public"), + join(root, "apps", "linked", "public") + ); + // A key directly in a non-artifact directory is not a public copy. + await writeKey({ + path: join(root, "apps", "web", KEY_FILE), + text: key, + mode: 0o600, + }); + + const inspection = await inspect(root); + + expect( + inspection.findings.map((finding) => [finding.code, finding.subject]) + ).toEqual([["env-key-public-path", `outside/public/${KEY_FILE}`]]); + }); + + test("the nested walk is depth bounded", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + const deepest = ["a", "b", "c", "d", "e", "public"]; + const tooDeep = ["a", "b", "c", "d", "e", "f", "public"]; + for (const dir of [deepest, tooDeep]) { + await mkdir(join(root, ...dir), { recursive: true }); + await writeKey({ + path: join(root, ...dir, KEY_FILE), + text: key, + mode: 0o600, + }); + } + + const inspection = await inspect(root); + + expect(inspection.findings.map((finding) => finding.subject)).toEqual([ + `a/b/c/d/e/public/${KEY_FILE}`, + ]); + }); + + test("the nested walk stops after its directory bound", async () => { + const root = await tempProject(); + for (const pkg of ["a", "b", "c"]) { + await mkdir(join(root, pkg, "public"), { recursive: true }); + } + + // Breadth-first in name order: the root, then a, b and c. + expect(await findNestedPublicArtifactDirs({ projectRoot: root })).toEqual([ + "a/public", + "b/public", + "c/public", + ]); + expect( + await findNestedPublicArtifactDirs({ + projectRoot: root, + limits: { maxDirectories: 3 }, + }) + ).toEqual(["a/public", "b/public"]); + }); + + test("the nested walk stops after its entry bound, counting every entry read", async () => { + const root = await tempProject(); + for (const pkg of ["a", "b"]) { + await mkdir(join(root, pkg, "public"), { recursive: true }); + } + for (const file of ["f1", "f2", "f3"]) { + await writeFile(join(root, file), "x"); + } + + // The root holds five entries; reading a adds one more. + expect( + await findNestedPublicArtifactDirs({ + projectRoot: root, + limits: { maxEntries: 6 }, + }) + ).toEqual(["a/public"]); + expect( + await findNestedPublicArtifactDirs({ + projectRoot: root, + limits: { maxEntries: 7 }, + }) + ).toEqual(["a/public", "b/public"]); + }); + + test("the default directory bound is the documented 4096", async () => { + expect(ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES).toBe(4096); + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + // The root is directory 1; "zz" sorts after the fillers. + const fillers = ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES - 2; + for (let index = 0; index < fillers; index++) { + await mkdir(join(root, `a${String(index).padStart(4, "0")}`)); + } + await mkdir(join(root, "zz", "public"), { recursive: true }); + await writeKey({ + path: join(root, "zz", "public", KEY_FILE), + text: key, + mode: 0o600, + }); + + expect( + (await inspect(root)).findings.map((finding) => finding.subject) + ).toEqual([`zz/public/${KEY_FILE}`]); + + // One more filler pushes "zz" past the bound: it is never read. + await mkdir(join(root, "a9999")); + expect(codes(await inspect(root))).toEqual(["env-key-ok"]); + }, 30_000); + + test("an unprintable nested path is reported without echoing it", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + const odd = "we ird$(x)"; + await mkdir(join(root, odd, "public"), { recursive: true }); + await writeKey({ + path: join(root, odd, "public", KEY_FILE), + text: key, + mode: 0o600, + }); + + const inspection = await inspect(root); + + expect(inspection.findings).toEqual([ + { + code: "env-key-public-path", + status: "error", + subject: `/${KEY_FILE}`, + remedy: + "Remove the key copy from the public artifact directory, then rotate the key: a published copy is not secret.", + }, + ]); + expect(serialized(inspection)).not.toContain(odd); + }); + test("findings are ordered most severe first", async () => { const root = await tempProject(); await writeKey({ path: join(root, KEY_FILE), text: "short", mode: 0o644 }); diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 9b4a51fe..20a2aa11 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -26,6 +26,10 @@ import { NATIVE_STORAGE_TOOL_MANIFEST, nativeStorageToolManifest, } from "../src/backends/native-authored-storage-tool.ts"; +import { + type ComposeEnvFingerprintRecord, + evaluateComposeServiceEnvFreshness, +} from "../src/lib/compose-env-fingerprint.ts"; import { restoreEnv } from "./helpers/env.ts"; import { writeLifecycleMuxFixture } from "./helpers/lifecycle-mux-fixture.ts"; import { createNativeAuthoredSettlement } from "./helpers/native-authored-settlement.ts"; @@ -585,6 +589,74 @@ macTest( } ); +macTest( + "native frontend observes value-free digests of the delivered managed selection before ready", + async () => { + const selected = await fixture(); + const events: string[] = []; + let fingerprint: ComposeEnvFingerprintRecord | undefined; + const code = await serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + onEnvironmentSelected: (value) => { + events.push("selected"); + fingerprint = value; + return undefined; + }, + onReady: () => { + events.push("ready"); + return undefined; + }, + }); + expect(code).toBe(0); + expect(events).toEqual(["selected", "ready"]); + expect(Object.keys(fingerprint?.services ?? {})).toEqual(["web"]); + expect(JSON.stringify(fingerprint)).not.toContain(CANARY); + expect(JSON.stringify(fingerprint)).not.toContain("TOKEN"); + expect( + evaluateComposeServiceEnvFreshness({ + record: fingerprint, + service: "web", + currentEnv: { TOKEN: CANARY }, + }) + ).toBe("unchanged"); + expect( + evaluateComposeServiceEnvFreshness({ + record: fingerprint, + service: "web", + currentEnv: { TOKEN: "synthetic-edited" }, + }) + ).toBe("changed"); + } +); + +macTest( + "a selection without managed keys digests an empty set and a throwing observer never fails startup", + async () => { + const selected = await fixture({ noManaged: true }); + let fingerprint: ComposeEnvFingerprintRecord | undefined; + expect( + await serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + onEnvironmentSelected: (value) => { + fingerprint = value; + throw new Error("synthetic observer failure"); + }, + }) + ).toBe(0); + expect( + evaluateComposeServiceEnvFreshness({ + record: fingerprint, + service: "web", + currentEnv: {}, + }) + ).toBe("unchanged"); + } +); + for (const option of [ "planFailure", "foreignPlan", diff --git a/tests/native-ca-doctor-cli.test.ts b/tests/native-ca-doctor-cli.test.ts index 8cbbdc1f..33eb1447 100644 --- a/tests/native-ca-doctor-cli.test.ts +++ b/tests/native-ca-doctor-cli.test.ts @@ -1,5 +1,12 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -11,10 +18,19 @@ afterEach(async () => { ); }); -async function invoke(args: string[]) { +const CLI = join(import.meta.dir, "..", "index.ts"); + +async function fixtureRoot(): Promise { const root = await realpath(await mkdtemp(join(tmpdir(), "native-ca-cli-"))); roots.push(root); - const child = Bun.spawn([process.execPath, "index.ts", ...args], { + return root; +} + +/** Runs the source CLI from `cwd` (default: a fresh directory outside any project). */ +async function invoke(args: string[], cwd?: string) { + const root = await fixtureRoot(); + const child = Bun.spawn([process.execPath, CLI, ...args], { + cwd: cwd ?? root, env: privateCliEnv({ root, env: { @@ -68,3 +84,55 @@ test("native global trust refuses the stale Docker export path", async () => { "No Docker-era CA was installed" ); }); + +test("native Doctor audits the project env key value-free, including nested public copies", async () => { + const project = await fixtureRoot(); + await mkdir(join(project, ".hack")); + await writeFile( + join(project, ".hack", "docker-compose.yml"), + "services:\n web:\n image: busybox\n" + ); + const weakKey = "synthetic-weak-key"; + await writeFile(join(project, ".hack.secret.key"), `${weakKey}\n`, { + mode: 0o600, + }); + await chmod(join(project, ".hack.secret.key"), 0o600); + await mkdir(join(project, "apps", "web", "public"), { recursive: true }); + await writeFile( + join(project, "apps", "web", "public", ".hack.secret.key"), + `${weakKey}\n` + ); + + const result = await invoke(["doctor", "--json"], project); + + expect(result.code).toBe(1); + const envelope: unknown = JSON.parse(result.stdout); + expect(envelope).toMatchObject({ + data: { + checks: expect.arrayContaining([ + expect.objectContaining({ + id: "env key", + status: "error", + envKey: expect.objectContaining({ + findings: expect.arrayContaining([ + expect.objectContaining({ + code: "env-key-public-path", + subject: "apps/web/public/.hack.secret.key", + }), + expect.objectContaining({ code: "env-key-wrong-length" }), + ]), + }), + }), + ]), + }, + }); + const output = result.stdout + result.stderr; + expect(output).not.toContain(weakKey); + expect(output).not.toContain(project); +}); + +test("negative control: native Doctor outside a project adds no env key check", async () => { + const result = await invoke(["doctor", "--json"]); + expect(result.code).toBe(0); + expect(result.stdout).not.toContain('"env key"'); +}); diff --git a/tests/native-exec-stale-env.test.ts b/tests/native-exec-stale-env.test.ts new file mode 100644 index 00000000..9c3d901f --- /dev/null +++ b/tests/native-exec-stale-env.test.ts @@ -0,0 +1,1001 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + resolveNativeAuthoredExecStaleEnvWarning, + resolveNativeImageExecStaleEnvWarning, +} from "../src/backends/native-exec-stale-env.ts"; +import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; +import { runNativeProjectExec } from "../src/commands/project-exec-native.ts"; +import { + buildStaleNativeEnvWarning, + type ComposeEnvFingerprintRecord, + withComposeServiceEnvFingerprints, +} from "../src/lib/compose-env-fingerprint.ts"; +import { tryNativeAuthoredCommand } from "../src/lib/native-authored-command.ts"; +import { + createNativeEnvFingerprintEntry, + createNativeEnvFingerprintSession, + evaluateNativeServiceEnvFreshness, + forgetNativeEnvFingerprint, + loadNativeEnvFingerprint, + type NativeEnvFingerprintScope, + resolveNativeEnvFingerprintPath, + saveNativeEnvFingerprint, +} from "../src/lib/native-env-fingerprint-state.ts"; + +const SECRET_BEFORE = "synthetic-native-start-value"; +const SECRET_AFTER = "synthetic-native-edited-value"; +const KEY = "SYNTHETIC_NATIVE_TOKEN"; +const RUN = "a".repeat(32); +const OTHER_RUN = "b".repeat(32); + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +async function project(): Promise<{ + readonly root: string; + readonly projectDir: string; +}> { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-exec-stale-env-")) + ); + roots.push(root); + const projectDir = join(root, ".hack"); + await mkdir(projectDir, { mode: 0o700 }); + return { root, projectDir }; +} + +function fingerprint( + env: Readonly>>>, + services: readonly string[] = Object.keys(env) +): ComposeEnvFingerprintRecord { + return withComposeServiceEnvFingerprints({ + existing: undefined, + services, + envByService: env, + }); +} + +function imageScope(projectDir: string, branch: string | null = null) { + return { projectDir, owner: "image" as const, branch }; +} + +async function recordImage(opts: { + readonly projectDir: string; + readonly run?: string; + readonly branch?: string | null; + readonly env: Readonly>>>; +}): Promise { + await saveNativeEnvFingerprint( + imageScope(opts.projectDir, opts.branch ?? null), + createNativeEnvFingerprintEntry({ + owner: "image", + run: opts.run ?? RUN, + fingerprint: fingerprint(opts.env), + }) + ); +} + +function expectValueFree(text: string): void { + expect(text).not.toContain(SECRET_BEFORE); + expect(text).not.toContain(SECRET_AFTER); + expect(text).not.toContain(KEY); +} + +describe("native env fingerprint record", () => { + test("round-trips a value-free 0600 record per scope", async () => { + const { projectDir } = await project(); + await recordImage({ projectDir, env: { web: { [KEY]: SECRET_BEFORE } } }); + + const path = resolveNativeEnvFingerprintPath(imageScope(projectDir)); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expectValueFree(await readFile(path, "utf8")); + const entry = await loadNativeEnvFingerprint(imageScope(projectDir)); + expect(entry?.run).toBe(RUN); + expect( + evaluateNativeServiceEnvFreshness({ + entry, + run: RUN, + service: "web", + currentEnv: { [KEY]: SECRET_BEFORE }, + }) + ).toBe("unchanged"); + expect( + evaluateNativeServiceEnvFreshness({ + entry, + run: RUN, + service: "web", + currentEnv: { [KEY]: SECRET_AFTER }, + }) + ).toBe("changed"); + // Bound to the run: a record from another run never compares. + expect( + evaluateNativeServiceEnvFreshness({ + entry, + run: OTHER_RUN, + service: "web", + currentEnv: { [KEY]: SECRET_AFTER }, + }) + ).toBe("unknown"); + }); + + test("scopes do not share records across owners or branches", async () => { + const { projectDir } = await project(); + await recordImage({ projectDir, env: { web: {} } }); + + expect(await loadNativeEnvFingerprint(imageScope(projectDir))).not.toBe( + null + ); + expect( + await loadNativeEnvFingerprint(imageScope(projectDir, "feature")) + ).toBeNull(); + expect( + await loadNativeEnvFingerprint({ + projectDir, + owner: "authored", + branch: null, + }) + ).toBeNull(); + }); + + test("malformed, foreign or tampered records read as absent", async () => { + const { projectDir } = await project(); + const scope = imageScope(projectDir); + await recordImage({ projectDir, env: { web: {} } }); + const path = resolveNativeEnvFingerprintPath(scope); + const valid = JSON.parse(await readFile(path, "utf8")); + for (const tampered of [ + "not json", + JSON.stringify({ ...valid, version: 2 }), + JSON.stringify({ ...valid, owner: "authored" }), + JSON.stringify({ ...valid, run: "../../etc" }), + JSON.stringify({ ...valid, selection: { overlay: "Bad Name" } }), + JSON.stringify({ ...valid, selection: { extra: true } }), + JSON.stringify({ + ...valid, + fingerprint: { ...valid.fingerprint, salt: "short" }, + }), + ]) { + await writeFile(path, tampered); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + } + }); + + test("forget with a salt removes only the caller's own record", async () => { + const { projectDir } = await project(); + const scope = imageScope(projectDir); + await recordImage({ projectDir, env: { web: {} } }); + const first = await loadNativeEnvFingerprint(scope); + await recordImage({ projectDir, run: OTHER_RUN, env: { web: {} } }); + + await forgetNativeEnvFingerprint(scope, { + salt: first?.fingerprint.salt ?? "", + }); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(OTHER_RUN); + + await forgetNativeEnvFingerprint(scope); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + // Forgetting an absent record is a no-op, not a failure. + await forgetNativeEnvFingerprint(scope); + }); + + test("a session records, drops on restart, and releases only its own record", async () => { + const { projectDir } = await project(); + const scope: NativeEnvFingerprintScope = imageScope(projectDir); + const previous = createNativeEnvFingerprintSession(scope); + await previous.record( + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(RUN); + + // A restart drops the stale record once the previous run is stopping. + const restart = createNativeEnvFingerprintSession(scope); + restart.drop(); + restart.capture( + createNativeEnvFingerprintEntry({ + owner: "image", + run: OTHER_RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + restart.commit(); + // The committed write is queued off the synchronous observer. + let current = await loadNativeEnvFingerprint(scope); + for ( + let attempt = 0; + attempt < 50 && current?.run !== OTHER_RUN; + attempt++ + ) { + await Bun.sleep(5); + current = await loadNativeEnvFingerprint(scope); + } + expect(current?.run).toBe(OTHER_RUN); + // The previous foreground exits late; its release must not remove ours. + await previous.release(); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(OTHER_RUN); + await restart.release(); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + }); + + test("a restart that never becomes ready leaves no record", async () => { + const { projectDir } = await project(); + const scope = imageScope(projectDir); + await recordImage({ projectDir, env: { web: {} } }); + const restart = createNativeEnvFingerprintSession(scope); + restart.drop(); + // Captured at delivery, but never committed: the run never became ready. + restart.capture( + createNativeEnvFingerprintEntry({ + owner: "image", + run: OTHER_RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + await restart.release(); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + }); + + test("an unwritable record never fails the session", async () => { + const { projectDir } = await project(); + await writeFile(join(projectDir, ".internal"), "not a directory"); + const session = createNativeEnvFingerprintSession(imageScope(projectDir)); + await session.record( + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + await session.release(); + expect(await loadNativeEnvFingerprint(imageScope(projectDir))).toBeNull(); + }); +}); + +describe("native stale env warning text", () => { + test("is closed, names a valid service and suggests a whole-project restart", () => { + expect( + buildStaleNativeEnvWarning({ service: "web", exec: "started" }) + ).toBe( + 'Env for service "web" changed since it started; this exec uses the env it started with. Run `hack restart` to apply the current env.' + ); + expect(buildStaleNativeEnvWarning({ service: "web", exec: "fresh" })).toBe( + 'Env for service "web" changed since it started; the running service still uses the env it started with. Run `hack restart` to apply the current env.' + ); + expect( + buildStaleNativeEnvWarning({ service: "$(bad name)", exec: "started" }) + ).toContain("Env for this service changed"); + }); +}); + +function imageMapping( + overrides: Partial = {} +): NativeProjectRun { + return { + run: RUN, + owner: "c".repeat(32), + namespace: "d".repeat(64), + planId: "e".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + ...overrides, + }; +} + +function preparedInput( + managedEnvironment: NativeProjectInput["managedEnvironment"], + effectiveEnvName: string | null = null +): NativeProjectInput { + return { + originalSha256: "f".repeat(64), + normalizedComposeJson: "{}", + managedEnvironment, + lifecycleHostEnvironment: {}, + effectiveEnvName, + environmentFiles: [], + serviceNames: Object.keys(managedEnvironment), + }; +} + +describe("native image exec stale env check", () => { + async function resolve(opts: { + readonly projectDir: string; + readonly root: string; + readonly mapping?: NativeProjectRun | null; + readonly current?: NativeProjectInput["managedEnvironment"]; + readonly currentEnvName?: string | null; + readonly service?: string; + readonly prepare?: () => Promise; + }) { + const prepared: (string | null | undefined)[] = []; + const warning = await resolveNativeImageExecStaleEnvWarning({ + scope: { + projectRoot: opts.root, + projectDir: opts.projectDir, + nativeHome: opts.root, + branch: null, + }, + service: opts.service ?? "web", + composeFile: join(opts.projectDir, "docker-compose.yml"), + loadRun: async () => + opts.mapping === undefined ? imageMapping() : opts.mapping, + prepare: + opts.prepare ?? + (async (request) => { + prepared.push(request.envName); + return preparedInput( + opts.current ?? { web: { [KEY]: SECRET_BEFORE } }, + opts.currentEnvName ?? null + ); + }), + }); + return { warning, prepared }; + } + + test("warns value-free when the selection changed since the run started", async () => { + const fixture = await project(); + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + + const { warning, prepared } = await resolve({ + ...fixture, + current: { web: { [KEY]: SECRET_AFTER } }, + }); + + expect(warning).toBe( + buildStaleNativeEnvWarning({ service: "web", exec: "fresh" }) + ); + expect(prepared).toEqual([null]); + expectValueFree(warning ?? ""); + }); + + test("negative control: unchanged env does not warn", async () => { + const fixture = await project(); + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + + expect((await resolve(fixture)).warning).toBeNull(); + }); + + test("an added key on a service that started with no env warns", async () => { + const fixture = await project(); + await recordImage({ projectDir: fixture.projectDir, env: { web: {} } }); + + expect( + (await resolve({ ...fixture, current: { web: { [KEY]: SECRET_AFTER } } })) + .warning + ).not.toBeNull(); + }); + + test("no record, another run, a legacy mapping or a changed env name stay silent", async () => { + const fixture = await project(); + const changed = { web: { [KEY]: SECRET_AFTER } }; + const none = await resolve({ ...fixture, current: changed }); + expect(none).toEqual({ warning: null, prepared: [] }); + + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + expect( + await resolve({ + ...fixture, + current: changed, + mapping: imageMapping({ run: OTHER_RUN }), + }) + ).toEqual({ warning: null, prepared: [] }); + expect( + await resolve({ ...fixture, current: changed, mapping: null }) + ).toEqual({ warning: null, prepared: [] }); + const { effectiveEnvName: _legacy, ...legacy } = imageMapping(); + expect( + await resolve({ ...fixture, current: changed, mapping: legacy }) + ).toEqual({ warning: null, prepared: [] }); + expect( + (await resolve({ ...fixture, current: changed, currentEnvName: "qa" })) + .warning + ).toBeNull(); + expect( + (await resolve({ ...fixture, current: changed, service: "worker" })) + .warning + ).toBeNull(); + }); + + test("a resolution failure stays silent", async () => { + const fixture = await project(); + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + + expect( + ( + await resolve({ + ...fixture, + prepare: () => Promise.reject(new Error(SECRET_AFTER)), + }) + ).warning + ).toBeNull(); + }); +}); + +describe("native image exec output", () => { + function captured() { + const stdout: string[] = []; + const stderr: string[] = []; + const out = spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + return { + stdout, + stderr, + restore: () => { + out.mockRestore(); + err.mockRestore(); + }, + }; + } + + const scope = { + projectRoot: "/synthetic/project", + projectDir: "/synthetic/project/.hack", + nativeHome: "/synthetic/native-home", + branch: null, + }; + + test("writes the warning to stderr ahead of the captured output; stdout stays the command's", async () => { + const io = captured(); + let code: number; + try { + code = await runNativeProjectExec({ + runtime: { binary: "/synthetic/hack-native", home: scope.nativeHome }, + scope, + service: "web", + argv: ["printenv"], + workdir: undefined, + composeFile: "/synthetic/project/.hack/docker-compose.yml", + exec: async () => ({ + exitCode: 3, + stdout: Buffer.from("command-out\n"), + stderr: Buffer.from("command-err\n"), + truncated: false, + }), + staleEnv: async (request) => + buildStaleNativeEnvWarning({ + service: request.service, + exec: "fresh", + }), + }); + } finally { + io.restore(); + } + expect(code).toBe(3); + expect(io.stdout).toEqual(["command-out\n"]); + expect(io.stderr).toEqual([ + `WARNING: ${buildStaleNativeEnvWarning({ service: "web", exec: "fresh" })}\n`, + "command-err\n", + ]); + }); + + test("negative control: no warning when unchanged, and none for a refused exec", async () => { + const io = captured(); + let checks = 0; + try { + await runNativeProjectExec({ + runtime: { binary: "/synthetic/hack-native", home: scope.nativeHome }, + scope, + service: "web", + argv: ["true"], + workdir: undefined, + composeFile: "/synthetic/project/.hack/docker-compose.yml", + exec: async () => ({ + exitCode: 0, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + truncated: false, + }), + staleEnv: async () => { + checks++; + return null; + }, + }); + await expect( + runNativeProjectExec({ + runtime: { + binary: "/synthetic/hack-native", + home: scope.nativeHome, + }, + scope, + service: "web", + argv: ["true"], + workdir: undefined, + composeFile: "/synthetic/project/.hack/docker-compose.yml", + exec: () => Promise.reject(new Error("refused")), + staleEnv: async () => { + checks++; + return "unexpected"; + }, + }) + ).rejects.toThrow("refused"); + } finally { + io.restore(); + } + expect(checks).toBe(1); + expect(io.stderr.join("")).not.toContain("WARNING"); + }); +}); + +describe("authored native exec stale env check", () => { + function authoredScope(root: string, projectDir: string) { + return { + projectRoot: root, + projectDir, + nativeHome: join(root, "candidate"), + branch: "feature", + }; + } + + async function recordAuthored(opts: { + readonly projectDir: string; + readonly env: Readonly>>>; + }): Promise { + await saveNativeEnvFingerprint( + { projectDir: opts.projectDir, owner: "authored", branch: "feature" }, + createNativeEnvFingerprintEntry({ + owner: "authored", + run: RUN, + fingerprint: fingerprint(opts.env), + selection: { overlay: "qa", profiles: ["extra"] }, + }) + ); + } + + test("warns when the recorded selection resolves differently now", async () => { + const { root, projectDir } = await project(); + await recordAuthored({ + projectDir, + env: { web: { [KEY]: SECRET_BEFORE }, worker: {} }, + }); + const selections: unknown[] = []; + + const warning = await resolveNativeAuthoredExecStaleEnvWarning({ + scope: authoredScope(root, projectDir), + service: "web", + currentRun: async () => RUN, + resolveEnvironment: async (request) => { + selections.push(request.selection); + return { web: { [KEY]: SECRET_AFTER }, worker: {} }; + }, + }); + + expect(warning).toBe( + buildStaleNativeEnvWarning({ service: "web", exec: "started" }) + ); + // The same public selectors startup used are resolved again. + expect(selections).toEqual([{ overlay: "qa", profiles: ["extra"] }]); + expectValueFree(warning ?? ""); + }); + + test("negative control: unchanged env, another run, or no record stay silent", async () => { + const { root, projectDir } = await project(); + const scope = authoredScope(root, projectDir); + let resolutions = 0; + const resolveEnvironment = async () => { + resolutions++; + return { web: { [KEY]: SECRET_BEFORE } }; + }; + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => RUN, + resolveEnvironment, + }) + ).toBeNull(); + expect(resolutions).toBe(0); + + await recordAuthored({ + projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => RUN, + resolveEnvironment, + }) + ).toBeNull(); + expect(resolutions).toBe(1); + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => OTHER_RUN, + resolveEnvironment: async () => ({ web: { [KEY]: SECRET_AFTER } }), + }) + ).toBeNull(); + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => RUN, + resolveEnvironment: () => Promise.reject(new Error(SECRET_AFTER)), + }) + ).toBeNull(); + }); +}); + +const macTest = process.platform === "darwin" ? test : test.skip; + +describe("authored native command wiring", () => { + async function fixture() { + const { root, projectDir } = await project(); + const nativeHome = join(root, "candidate"); + await mkdir(nativeHome, { mode: 0o700 }); + return { + root, + projectDir, + selected: { kind: "native" as const, projectRoot: root }, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: join(root, "explicit-native"), + HACK_NATIVE_HOME: nativeHome, + HACK_COMPOSE_STARTUP_TIMEOUT_MS: "1500", + }, + }; + } + + /** + * Capture process.stderr writes and Bun.write output in one ordered log. + * Bun.write entries name their destination so stdout and stderr stay + * distinguishable. + */ + function orderedOutput() { + const events: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + events.push(`process.stderr:${String(chunk)}`); + return true; + }); + const writes = spyOn(Bun, "write").mockImplementation( + async (destination, data) => { + const target = + destination === Bun.stdout + ? "Bun.stdout" + : destination === Bun.stderr + ? "Bun.stderr" + : "other"; + events.push( + `${target}:${new TextDecoder().decode(data as Uint8Array)}` + ); + return 0; + } + ); + return { + events, + restore: () => { + err.mockRestore(); + writes.mockRestore(); + }, + }; + } + + macTest( + "exec writes the warning to stderr before the command's output", + async () => { + const selected = await fixture(); + const io = orderedOutput(); + let code: number | null; + try { + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: async () => ({ + backend: "native", + run: RUN, + service: "web", + container: "f".repeat(64), + exitCode: 0, + stdout: new TextEncoder().encode("command-out\n"), + stderr: new TextEncoder().encode("command-err\n"), + truncated: false, + }), + staleEnv: async (request) => + buildStaleNativeEnvWarning({ + service: request.service, + exec: "started", + }), + }); + } finally { + io.restore(); + } + expect(code).toBe(0); + expect(io.events).toEqual([ + `process.stderr:WARNING: ${buildStaleNativeEnvWarning({ service: "web", exec: "started" })}\n`, + "Bun.stdout:command-out\n", + "Bun.stderr:command-err\n", + ]); + } + ); + + function execCompletion(stdout: string) { + return async () => ({ + backend: "native" as const, + run: RUN, + service: "web", + container: "f".repeat(64), + exitCode: 4, + stdout: new TextEncoder().encode(stdout), + stderr: new Uint8Array(), + truncated: false, + }); + } + + macTest( + "Ctrl-C during the advisory check returns 130 without waiting for it or writing output", + async () => { + const selected = await fixture(); + // mockRestore() clears a spy's recorded calls, so the output is + // captured into this log rather than read back from the spies. + const io = orderedOutput(); + const received: { signal?: AbortSignal } = {}; + let code: number | null; + try { + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion("command-out\n"), + staleEnv: (request) => { + received.signal = request.signal; + process.emit("SIGINT"); + // A resolution that ignores cancellation must not hold the exec. + return new Promise(() => undefined); + }, + }); + } finally { + io.restore(); + } + expect(code).toBe(130); + expect(received.signal?.aborted).toBe(true); + expect(io.events).toEqual([]); + }, + 2000 + ); + + macTest( + "a slow advisory check is time-bounded: the exec output follows without a warning", + async () => { + const selected = await fixture(); + const stderr: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + const written: string[] = []; + const writes = spyOn(Bun, "write").mockImplementation( + async (_destination, data) => { + written.push(new TextDecoder().decode(data as Uint8Array)); + return 0; + } + ); + const received: { signal?: AbortSignal } = {}; + let code: number | null; + try { + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion("command-out\n"), + staleEnvTimeoutMs: 25, + staleEnv: (request) => { + received.signal = request.signal; + return new Promise(() => undefined); + }, + }); + } finally { + err.mockRestore(); + writes.mockRestore(); + } + expect(code).toBe(4); + expect(received.signal?.aborted).toBe(true); + expect(written).toEqual(["command-out\n", ""]); + expect(stderr).toEqual([]); + }, + 2000 + ); + + macTest( + "negative control: a warning resolved after the bound, or a failed check, is dropped", + async () => { + const selected = await fixture(); + const stderr: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + const writes = spyOn(Bun, "write").mockImplementation(async () => 0); + let code: number | null; + let failedCode: number | null; + try { + failedCode = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion(""), + staleEnv: () => Promise.reject(new Error(SECRET_AFTER)), + }); + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion(""), + staleEnvTimeoutMs: 25, + staleEnv: (request) => + new Promise((resolve) => { + request.signal?.addEventListener("abort", () => + resolve( + buildStaleNativeEnvWarning({ + service: request.service, + exec: "started", + }) + ) + ); + }), + }); + } finally { + err.mockRestore(); + writes.mockRestore(); + } + expect(failedCode).toBe(4); + expect(code).toBe(4); + expect(stderr).toEqual([]); + }, + 2000 + ); + + macTest( + "up records the delivered selection while ready and removes it on exit", + async () => { + const selected = await fixture(); + const scope = { + projectDir: selected.projectDir, + owner: "authored" as const, + branch: null, + }; + const observed: { + entry: Awaited>; + } = { entry: null }; + const code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { cwd: selected.root, operation: "up", overlay: "qa" }, + serve: async (request) => { + request.onEnvironmentSelected?.( + fingerprint({ web: { [KEY]: SECRET_BEFORE } }) + ); + request.onReady?.( + {} as Parameters>[0] + ); + // The ready record is written off the synchronous observer. + for (let attempt = 0; attempt < 50 && !observed.entry; attempt++) { + await Bun.sleep(5); + observed.entry = await loadNativeEnvFingerprint(scope); + } + expect(observed.entry?.run).toBe(request.run); + return 0; + }, + }); + expect(code).toBe(0); + expect(observed.entry?.selection).toEqual({ overlay: "qa" }); + expectValueFree(JSON.stringify(observed.entry)); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + } + ); + + macTest( + "negative control: a run that never becomes ready records nothing", + async () => { + const selected = await fixture(); + const code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { cwd: selected.root, operation: "up" }, + serve: async (request) => { + request.onEnvironmentSelected?.(fingerprint({ web: {} })); + return 0; + }, + }); + expect(code).toBe(0); + expect(await readdir(selected.projectDir)).toEqual([]); + } + ); + + macTest( + "a failed restart start drops the previous run's record", + async () => { + const selected = await fixture(); + const scope = { + projectDir: selected.projectDir, + owner: "authored" as const, + branch: null, + }; + await saveNativeEnvFingerprint( + scope, + createNativeEnvFingerprintEntry({ + owner: "authored", + run: RUN, + fingerprint: fingerprint({ web: { [KEY]: SECRET_BEFORE } }), + }) + ); + await expect( + tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { cwd: selected.root, operation: "restart" }, + stop: async () => undefined, + serve: async (request) => { + request.onEnvironmentSelected?.(fingerprint({ web: {} })); + throw new Error("synthetic start failure"); + }, + }) + ).rejects.toThrow(); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + } + ); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4e67a7ff..4a8721f2 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -31,6 +31,10 @@ import { verifyHttpsRoutes, } from "../src/backends/native-project-start.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { + type ComposeEnvFingerprintRecord, + evaluateComposeServiceEnvFreshness, +} from "../src/lib/compose-env-fingerprint.ts"; const roots: string[] = []; afterEach(async () => { @@ -289,6 +293,50 @@ test("foreground saves after readiness and retains mapping after confirmed stop" "cleanup", ]); }); +test("foreground reports value-free digests of the selected env after publishing the mapping", async () => { + const { opts, events } = await fixture(); + const seen: { run: string; fingerprint: ComposeEnvFingerprintRecord }[] = []; + expect( + await startNativeProject({ + ...opts, + onEnvironmentReady: async (selection) => { + events.push("environment"); + seen.push(selection); + }, + }) + ).toBe(0); + expect(events.indexOf("environment")).toBe(events.indexOf("save") + 1); + expect(events.indexOf("environment")).toBeLessThan(events.indexOf("ready")); + expect(seen).toHaveLength(1); + const [selection] = seen; + expect(selection?.run).toMatch(/^[a-f0-9]{32}$/); + expect(JSON.stringify(selection)).not.toContain("synthetic-secret"); + expect(JSON.stringify(selection)).not.toContain("TOKEN"); + expect( + evaluateComposeServiceEnvFreshness({ + record: selection?.fingerprint, + service: "web", + currentEnv: { TOKEN: "synthetic-secret" }, + }) + ).toBe("unchanged"); + expect( + evaluateComposeServiceEnvFreshness({ + record: selection?.fingerprint, + service: "web", + currentEnv: { TOKEN: "synthetic-edited" }, + }) + ).toBe("changed"); +}); +test("a failing env digest observer never fails foreground startup", async () => { + const { opts, events } = await fixture(false); + expect( + await startNativeProject({ + ...opts, + onEnvironmentReady: () => Promise.reject(new Error("unwritable")), + }) + ).toBe(0); + expect(events).toContain("ready"); +}); test("uncertain foreground failure retains published mapping and cleans lifecycle listeners", async () => { const { opts, events } = await fixture(); const original = opts.dependencies.serve; diff --git a/tests/native-up-env-fingerprint-wiring.test.ts b/tests/native-up-env-fingerprint-wiring.test.ts new file mode 100644 index 00000000..2a767e97 --- /dev/null +++ b/tests/native-up-env-fingerprint-wiring.test.ts @@ -0,0 +1,363 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, +} from "../src/constants.ts"; +import type { ComposeEnvFingerprintRecord } from "../src/lib/compose-env-fingerprint.ts"; +import { + createNativeEnvFingerprintEntry, + loadNativeEnvFingerprint, + type NativeEnvFingerprintEntry, + type NativeEnvFingerprintScope, + saveNativeEnvFingerprint, +} from "../src/lib/native-env-fingerprint-state.ts"; +import { restoreEnv } from "./helpers/env.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +// HACK-1140 command wiring: `hack up` / `hack restart` on the native image +// backend record the env-selection fingerprint the run reports, bound to the +// image owner and the effective branch; release it when the foreground ends; +// and a restart drops the previous run's record once that run is stopping. +// The runtime boundaries (start, restart orchestration, down) are mocked; the +// fingerprint store under test is the real one on a temporary project. + +const RUN_OLD = "0".repeat(32); +const RUN_NEW = "1".repeat(32); +const BRANCH = "feature-a"; + +type StartOptions = { + readonly onEnvironmentReady?: (selection: { + readonly run: string; + readonly fingerprint: ComposeEnvFingerprintRecord; + }) => Promise; +}; +type RestartOptions = { + readonly down: () => Promise; + readonly start: (options: { + run: unknown; + onReady: () => Promise; + }) => Promise; +}; + +let startBehavior: (opts: StartOptions) => Promise = async () => 0; +let restartBehavior: (opts: RestartOptions) => Promise = async () => 0; +let downBehavior: () => Promise<{ status: string }> = async () => ({ + status: "stopped", +}); +const calls: string[] = []; + +const startMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-start.ts", + overrides: { + startNativeProject: async (opts: StartOptions) => { + calls.push("start"); + return await startBehavior(opts); + }, + }, +}); +const restartMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-restart.ts", + overrides: { + restartNativeProject: async (opts: RestartOptions) => { + calls.push("restart"); + return await restartBehavior(opts); + }, + }, +}); +const downMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-down.ts", + overrides: { + nativeProjectDown: async () => { + calls.push("down"); + return await downBehavior(); + }, + }, +}); +// Keep this a legacy Compose project test: no authored or adopted dispatch. +const authoredMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/native-compose-command.ts", + overrides: { tryNativeComposeCommand: async () => null }, +}); +const branchesMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/branches.ts", + overrides: { + resolveEffectiveBranch: async () => ({ + branch: null, + source: "none", + gitBranch: null, + }), + }, +}); +const { restartCommand, upCommand } = await import( + "../src/commands/project.ts" +); + +const ENV_KEYS = [ + "HACK_HOME", + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_NATIVE_HTTPS_PORT", + "HACK_NATIVE_CADDY_BINARY", + "HACK_NATIVE_CADDY_SHA256", + "HACK_NATIVE_SHARED_SOURCE", +] as const; +const savedEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); +const roots: string[] = []; +const mocks = [startMock, restartMock, downMock, authoredMock, branchesMock]; + +beforeAll(() => { + for (const mocked of mocks) { + mocked.activate(); + } +}); +afterEach(async () => { + startBehavior = async () => 0; + restartBehavior = async () => 0; + downBehavior = async () => ({ status: "stopped" }); + calls.length = 0; + for (const key of ENV_KEYS) { + restoreEnv(key, savedEnv.get(key)); + } + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +afterAll(() => { + for (const mocked of mocks) { + mocked.deactivate(); + } +}); + +function fingerprint(seed: string): ComposeEnvFingerprintRecord { + return { + version: 1, + salt: seed.repeat(32), + services: { web: seed.repeat(64) }, + }; +} + +async function nativeProject(): Promise<{ + readonly root: string; + readonly scope: NativeEnvFingerprintScope; +}> { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-up-env-fingerprint-")) + ); + roots.push(root); + await mkdir(join(root, ".hack")); + await writeFile( + join(root, ".hack", PROJECT_COMPOSE_FILENAME), + "name: fingerprint-compose\nservices:\n web:\n image: alpine:3.20\n" + ); + await writeFile( + join(root, ".hack", PROJECT_CONFIG_FILENAME), + JSON.stringify({ dev_host: "fingerprint.hack" }) + ); + for (const key of ENV_KEYS) { + Reflect.deleteProperty(process.env, key); + } + process.env.HACK_HOME = join(root, ".global-hack"); + process.env.HACK_RUNTIME_BACKEND = "native"; + process.env.HACK_NATIVE_BINARY = join(root, "hack-local"); + process.env.HACK_NATIVE_HOME = join(root, "native-home"); + process.env.HACK_NATIVE_SHARED_SOURCE = "1"; + return { + root, + scope: { projectDir: join(root, ".hack"), owner: "image", branch: BRANCH }, + }; +} + +async function up(root: string): Promise { + const input = { + ctx: { cwd: root, cli: CLI_SPEC }, + args: { + options: { + path: root, + project: undefined, + env: undefined, + branch: BRANCH, + detach: false, + profile: undefined, + target: undefined, + json: false, + }, + positionals: { services: [] }, + raw: { argv: ["--path", root, "--branch", BRANCH], positionals: [] }, + }, + } as unknown as Parameters[0]; + return await upCommand.handler(input); +} + +async function restart(root: string): Promise { + const input = { + ctx: { cwd: root, cli: CLI_SPEC }, + args: { + options: { + path: root, + project: undefined, + env: undefined, + branch: BRANCH, + profile: undefined, + target: undefined, + json: false, + recoverFrontend: false, + expectFinalizationAttempt: undefined, + expectFrontendPid: undefined, + }, + positionals: { services: [] }, + raw: { argv: ["--path", root, "--branch", BRANCH], positionals: [] }, + }, + } as unknown as Parameters[0]; + return await restartCommand.handler(input); +} + +/** Start behavior that reports `run`'s env and snapshots the record while running. */ +function reportingStart( + scope: NativeEnvFingerprintScope, + run: string, + seen: { entry?: NativeEnvFingerprintEntry | null }, + outcome: () => Promise = async () => 0 +) { + return async (opts: StartOptions) => { + await opts.onEnvironmentReady?.({ run, fingerprint: fingerprint("e") }); + seen.entry = await loadNativeEnvFingerprint(scope); + return await outcome(); + }; +} + +async function seedPreviousRun(scope: NativeEnvFingerprintScope) { + await saveNativeEnvFingerprint( + scope, + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN_OLD, + fingerprint: fingerprint("d"), + }) + ); +} + +const restartedRun = { + run: RUN_NEW, + effectiveEnvName: null, + profiles: [], + aws: null, +}; + +test("native up records the reported env under the image owner and branch, then releases it", async () => { + const { root, scope } = await nativeProject(); + const seen: { entry?: NativeEnvFingerprintEntry | null } = {}; + startBehavior = reportingStart(scope, RUN_NEW, seen); + expect(await up(root)).toBe(0); + expect(seen.entry).toEqual( + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN_NEW, + fingerprint: fingerprint("e"), + }) + ); + // Released when the foreground ended; nothing else is left behind. + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + expect( + await readdir( + join(scope.projectDir, ".internal", "native-env-fingerprints") + ) + ).toEqual([]); +}); + +test("native up releases its record when startup fails after reporting", async () => { + const { root, scope } = await nativeProject(); + const seen: { entry?: NativeEnvFingerprintEntry | null } = {}; + startBehavior = reportingStart(scope, RUN_NEW, seen, () => + Promise.reject(new Error("synthetic startup failure")) + ); + await expect(up(root)).rejects.toThrow("synthetic startup failure"); + expect(seen.entry?.run).toBe(RUN_NEW); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("native up release keeps a record another owner wrote meanwhile", async () => { + const { root, scope } = await nativeProject(); + startBehavior = async (opts) => { + await opts.onEnvironmentReady?.({ + run: RUN_NEW, + fingerprint: fingerprint("e"), + }); + // A newer foreground replaced the record before this one ended. + await seedPreviousRun(scope); + return 0; + }; + expect(await up(root)).toBe(0); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(RUN_OLD); +}); + +test("native restart drops the previous run's record at down, records the new run, then releases", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + const seen: { entry?: NativeEnvFingerprintEntry | null } = {}; + const beforeDown: { entry?: NativeEnvFingerprintEntry | null } = {}; + startBehavior = reportingStart(scope, RUN_NEW, seen); + restartBehavior = async (opts) => { + beforeDown.entry = await loadNativeEnvFingerprint(scope); + await opts.down(); + return await opts.start({ run: restartedRun, onReady: async () => {} }); + }; + expect(await restart(root)).toBe(0); + expect(calls).toEqual(["restart", "down", "start"]); + expect(beforeDown.entry?.run).toBe(RUN_OLD); + expect(seen.entry?.run).toBe(RUN_NEW); + expect(seen.entry?.fingerprint).toEqual(fingerprint("e")); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("a native restart that fails after down leaves no record", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + restartBehavior = async (opts) => { + await opts.down(); + throw new Error("synthetic restart start failure"); + }; + await expect(restart(root)).rejects.toThrow( + "synthetic restart start failure" + ); + expect(calls).toEqual(["restart", "down"]); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("a native restart whose down fails still drops the previous run's record", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + downBehavior = () => Promise.reject(new Error("synthetic down failure")); + restartBehavior = async (opts) => { + await opts.down(); + return 0; + }; + await expect(restart(root)).rejects.toThrow("synthetic down failure"); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("negative control: a native restart refused before down keeps the running record", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + restartBehavior = () => + Promise.reject(new Error("synthetic preflight refusal")); + await expect(restart(root)).rejects.toThrow("synthetic preflight refusal"); + expect(calls).toEqual(["restart"]); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(RUN_OLD); +});