diff --git a/tests/app.test.ts b/tests/app.test.ts index 044191a..7f9a72c 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1,3 +1,4 @@ +import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp, errorIdentity } from "../src/app.js"; @@ -1699,7 +1700,8 @@ describe("a provider answer Heimdall cannot use", () => { expect(error).toBeInstanceOf(Error); return { error: error as Error & { code?: unknown; status?: unknown }, - printed: `${String(error)} ${(error as Error).stack ?? ""} ${JSON.stringify(error)}`, + // inspect shows what the others miss, such as a non-enumerable cause. + printed: `${String(error)} ${(error as Error).stack ?? ""} ${JSON.stringify(error)} ${inspect(error, { depth: 5, showHidden: true })}`, }; } diff --git a/tests/recipe-secrets.test.ts b/tests/recipe-secrets.test.ts index 31717c3..d7f69ec 100644 --- a/tests/recipe-secrets.test.ts +++ b/tests/recipe-secrets.test.ts @@ -1,8 +1,7 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { appProfiles } from "../src/app-profiles.js"; import { appSlugs, providers } from "../src/contracts.js"; -import { loadConfig } from "../src/config.js"; import { providerCatalog } from "../src/providers.js"; // The Idunn recipe decides which environment names a binding may set, and @@ -31,80 +30,107 @@ const everyDeclaredName = [...recipe.replace(/\r\n/g, "\n").matchAll(/^\w+_envir // loadConfig runs against an environment that records every name it looks // up. readSecretInput looks up `NAME_FILE` for each secret, so every `_FILE` // lookup names one, however the call is spelled and whatever list computes -// the name. The same recorder stands in for process.env during the call, so a -// read that bypasses the env argument is seen too. +// the name. The recorder also stands in for process.env, both while config.ts +// is imported (freshly, so a copy of process.env taken at module load is the +// recorder too) and during every call. // // A read made from a copy of the environment ({ ...env }, Object.entries) // cannot be seen, so copying or enumerating it is recorded and fails below. // -// A secret read only on a branch that some value opens is reached by running -// loadConfig over many environments: every secret found so far set in -// plaintext, then each name read so far set to each probe value in turn, then -// every such name set to one probe value at once, until no run reads a new -// name. A run that throws still counts the reads it made first. -const probeValues = ["1", "true", "/probe/path", "127.0.0.1:4100", "https://probe.test"]; +// A secret read only on a branch some value opens is reached by running +// loadConfig over many environments, until no run reads a new name: every +// secret found so far set in plaintext; then each name read so far set to +// each probe value (a few shapes, plus every string literal in config.ts); +// then every name at once, each set to a value loadConfig accepted for it +// alone. A run that throws still counts the reads it made first. +// +// Known limit: a read gated on a value that is none of the probe values and +// not accepted alone in combination with the others stays unseen. +const configSource = readFileSync(new URL("../src/config.ts", import.meta.url), "utf8"); +const probeValues = [ + ...new Set(["1", "true", "/probe/path", "127.0.0.1:4100", "https://probe.test", ...[...configSource.matchAll(/"([^"\\\r\n]+)"/g)].map((m) => m[1]!)]), +]; -function readsOf(values: Readonly>): { names: Set; enumerated: boolean } { - const names = new Set(); - let enumerated = false; - const record = (name: string | symbol) => { - if (typeof name === "string") names.add(name); - }; - const env = new Proxy({} as NodeJS.ProcessEnv, { - get(_target, name) { - record(name); - return typeof name === "string" ? values[name] : undefined; - }, - has(_target, name) { - record(name); - return typeof name === "string" && name in values; - }, - ownKeys() { - enumerated = true; - return []; - }, - getOwnPropertyDescriptor() { - enumerated = true; - return undefined; - }, - }); +const recorder = { values: {} as Record, names: new Set(), enumerated: false }; +const recordingEnv = new Proxy({} as NodeJS.ProcessEnv, { + get(_target, name) { + if (typeof name !== "string") return undefined; + recorder.names.add(name); + return recorder.values[name]; + }, + has(_target, name) { + if (typeof name !== "string") return false; + recorder.names.add(name); + return name in recorder.values; + }, + ownKeys() { + recorder.enumerated = true; + return []; + }, + getOwnPropertyDescriptor() { + recorder.enumerated = true; + return undefined; + }, +}); + +async function withRecordingProcessEnv(run: () => T | Promise): Promise { const processEnv = process.env; - process.env = env; + process.env = recordingEnv; try { - loadConfig(env, []); - } catch { - // The reads before the refusal still count. + return await run(); } finally { process.env = processEnv; } - return { names, enumerated }; } -function exploreLoadConfig(): { names: Set; enumerated: boolean } { +vi.resetModules(); +const { loadConfig } = await withRecordingProcessEnv(() => import("../src/config.js")); + +/** The names one loadConfig run reads under `values`, and whether it accepted them. */ +async function readsOf(values: Readonly>): Promise<{ names: Set; accepted: boolean }> { + recorder.values = { ...values }; + recorder.names = new Set(); + let accepted = true; + await withRecordingProcessEnv(() => { + try { + loadConfig(recordingEnv, []); + } catch { + accepted = false; // the reads before the refusal still count + } + }); + return { names: recorder.names, accepted }; +} + +async function exploreLoadConfig(): Promise<{ names: Set; enumerated: boolean }> { const names = new Set(); - let enumerated = false; + recorder.enumerated = false; const secretsOf = () => [...names].filter((name) => name.endsWith("_FILE")).map((name) => name.slice(0, -"_FILE".length)); for (let grew = true; grew; ) { const before = names.size; const secrets = Object.fromEntries(secretsOf().map((name) => [name, "probe-secret"])); const settable = [...names].filter((name) => !name.endsWith("_FILE") && !(name in secrets)); - const runs: Array> = [ - {}, - secrets, - ...settable.flatMap((name) => probeValues.map((value) => ({ ...secrets, [name]: value }))), - ...probeValues.map((value) => ({ ...secrets, ...Object.fromEntries(settable.map((name) => [name, value])) })), - ]; - for (const values of runs) { - const run = readsOf(values); - run.names.forEach((name) => names.add(name)); - enumerated ||= run.enumerated; + const accepted = new Map(); + const keep = (run: { names: Set }) => run.names.forEach((name) => names.add(name)); + keep(await readsOf({})); + keep(await readsOf(secrets)); + for (const name of settable) { + for (const value of probeValues) { + const run = await readsOf({ ...secrets, [name]: value }); + keep(run); + if (run.accepted) accepted.set(name, [...(accepted.get(name) ?? []), value]); + } + } + const widest = Math.max(0, ...[...accepted.values()].map((values) => values.length)); + for (let round = 0; round < widest; round += 1) { + const together = Object.fromEntries([...accepted].map(([name, values]) => [name, values[round % values.length]!])); + keep(await readsOf({ ...secrets, ...together })); } grew = names.size > before; } - return { names, enumerated }; + return { names, enumerated: recorder.enumerated }; } -const explored = exploreLoadConfig(); +const explored = await exploreLoadConfig(); /** Secrets Heimdall reads through readSecretInput, by their plaintext name. */ const secretNames = [...explored.names]