diff --git a/docs/native/environment-files.md b/docs/native/environment-files.md index 2a3c7cae0..1d7068620 100644 --- a/docs/native/environment-files.md +++ b/docs/native/environment-files.md @@ -19,7 +19,27 @@ The reviewed runtime active set must agree before private delivery or effects; an active service with unprepared file inputs refuses. Paths are literal and relative to the Compose file, within the current canonical -project root. Absolute paths inside that root are accepted. Escaping the root or +project root. + +Hack's own key and secret-store files are never environment sources. Before any +inspection or read, a service `env_file` or the automatic `.env` is refused with +"Hack key and secret-store files cannot be environment sources" when it is: + +- named `.hack.secret.key` at any depth (the checkout, shared and + primary-checkout project key copies); +- named `.hack-secrets.enc.json` or `.hack-secrets-file.key` (legacy + project-relative encrypted store and key); +- under the Hack home (`HACK_HOME`, else `~/.hack`) when the Hack home lies inside + the project root. A Hack home equal to the project root refuses every source. + +Paths compare after Unicode NFC normalization and Unicode case folding, so a +case or normalization alias (`Ä` and `ä`, for example) is refused too. An absent optional entry with such a name is refused rather +than skipped. The check uses paths only and opens nothing. It does not recognize +a Hack home reached only through a symlink, or an encrypted store configured at +another path; symlinked and multi-link sources are already refused below. A +project that lies inside the Hack home keeps the name rules. Start, preflight, +restart preflight and fresh exec share this check. + Absolute paths inside that root are accepted. Escaping the root or following a symlink inside it is refused. Files must be regular, single-link, owned by the current user, owner-readable and not writable by group or others; ordinary 0644 checkouts work. Directories inside the root must be owned by the @@ -83,7 +103,8 @@ File-local cloud names can be used by later assignments without reading those names from the shell. Support includes export prefixes, `=` and `:` separators in default format, -comments, single-quoted literals, double-quoted escapes, multiline quoted values, +comments (an unquoted inline comment starts at a space followed by `#`; a tab +before `#` is part of the value, as in Compose), single-quoted literals, double-quoted escapes, multiline quoted values, `$NAME`, `${NAME}`, defaults (`-`, `:-`), alternatives (`+`, `:+`), required references (`?`, `:?`), nesting to four levels, and `$$`. An unset `${NAME}` yields empty with a notice naming the destination key, file and assignment line; neither notices nor @@ -193,8 +214,28 @@ compose-go v2.16.1 results, alongside the documented [environment-file rules](ht and [interpolation rules](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/). Actual guest delivery is qualified separately on a Mac. +### Dotenv grammar parity + +`tests/native-compose-env-parity.test.ts` checks 86 dotenv cases against results +recorded with Docker Compose v5.6.1: quoting and escapes, `export` prefixes, +inline comments, multi-line quoted values, empty versus unset, bare names and the +`${VAR:-x}`, `${VAR-x}`, `${VAR:?err}`, `${VAR?err}`, `${VAR:+x}` and `${VAR+x}` +operators. Native matches Compose in every case except these deliberate +refusals, where Compose accepts the input: + +| Input | Compose | Native | +| --- | --- | --- | +| Text after a closing quote, such as `A="v"x` or `A='v'x` | Drops the text and keeps `v` | Refuses the file and names the line, rather than silently lose part of the value | +| A key that is not an identifier, such as `A.B`, `A-B` or `1A` | Accepts it | Refuses; see the identifier rule above | + ## Changes for earlier v5 prereleases +- A tab before `#` in an unquoted dotenv value no longer starts a comment: + `A=val\t# note` now delivers `val\t# note`, as Compose does. Earlier native + builds delivered `val`. Use a space before `#` for a comment. +- An `env_file` or `.env` that is a Hack key or secret-store file is refused, as + described above. + - `.hack/.env` is read on every native start and preflight, including when managed env already exists. A symlink, other-owner file, group/other-writable file or ancestor inside the project root refuses. Replace the symlink with an owned diff --git a/docs/native/environment-files.mmd b/docs/native/environment-files.mmd index b172724b8..b32f4c340 100644 --- a/docs/native/environment-files.mmd +++ b/docs/native/environment-files.mmd @@ -2,7 +2,9 @@ title: Native environment input boundaries --- flowchart TD - Sources["Files and allowed sources"] --> Guard["Guarded source reads"] + Sources["Files and allowed sources"] --> KeyCheck{"Hack key or secret-store file?"} + KeyCheck -->|"no"| Guard["Guarded source reads"] + KeyCheck -->|"yes, before any read"| Refuse Guard --> Normalize["Shared environment preparation"] Normalize -->|"names and public declarations"| Review["Read-only runtime admission"] Normalize -->|"values in memory"| Private["Bounded private delivery"] diff --git a/docs/native/environment-files.svg b/docs/native/environment-files.svg index d145d7610..5daf3ea4a 100644 --- a/docs/native/environment-files.svg +++ b/docs/native/environment-files.svg @@ -1,4 +1,4 @@ -Native environment input boundariesnames and public declarationsvalues in memoryprivate reference in public fieldadmitted active servicesFiles and allowed sourcesGuarded source readsShared environmentpreparationRead-only runtimeadmissionBounded private deliveryClosed refusalPrivate stdin envelopeExpiring guest allocationService launcher \ No newline at end of file +]]>Native environment input boundariesnoyes, before any readnames and public declarationsvalues in memoryprivate reference in public fieldadmitted active servicesFiles and allowed sourcesHack key or secret-storefile?Guarded source readsClosed refusalShared environmentpreparationRead-only runtimeadmissionBounded private deliveryPrivate stdin envelopeExpiring guest allocationService launcher \ No newline at end of file diff --git a/docs/versions/v5/native/preflight.md b/docs/versions/v5/native/preflight.md index d614465c3..9a087a76a 100644 --- a/docs/versions/v5/native/preflight.md +++ b/docs/versions/v5/native/preflight.md @@ -25,6 +25,9 @@ structured blocker with recovery guidance; the command still produces a report. A project-root `.env` outside the Compose directory is ignored with one notice. Detached starts omit per-key "unresolved input" notices because owner stdio is ignored; use this preflight or a foreground start to inspect missing inputs. +Later source changes: an env file or `.env` that is a Hack key or secret-store +file (such as `.hack.secret.key`) is refused before it is read, and a tab before +`#` no longer starts a dotenv comment, matching Compose. See the [environment-file rules](https://github.com/hack-dance/hack/blob/9cd2053affe4f3307703e97d5bb4cd7fc3049830/docs/native/environment-files.md) and [migration notes](https://github.com/hack-dance/hack/blob/9cd2053affe4f3307703e97d5bb4cd7fc3049830/docs/guides/v5-release-notes.md#next17-what-changed-and-what-to-do) before upgrading an earlier prerelease. diff --git a/src/backends/native-compose-env-syntax.ts b/src/backends/native-compose-env-syntax.ts index 50fe3a8ba..adab43501 100644 --- a/src/backends/native-compose-env-syntax.ts +++ b/src/backends/native-compose-env-syntax.ts @@ -2,7 +2,12 @@ const NAME = /^[A-Za-z_][A-Za-z0-9_]*/; const ENTRY = /^(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*(?:([=:])[ \t]*(.*))?$/; -const COMMENT = /[ \t]+#.*$/; +/** + * An unquoted inline comment starts at the first space immediately followed by + * `#`, as in compose-go. A tab before `#` does not start one: `A=val\t# c` + * keeps `val\t# c` (recorded with Docker Compose v5.6.1). + */ +const COMMENT = / #.*$/; const OPERATOR = /^(?::)?[-+?]/; const BOM = /^\uFEFF/; const ESCAPE_SEQUENCE = /\\(?:[abcfnrtv$"\\]|0\d{0,3})/g; diff --git a/src/backends/native-compose-environment.ts b/src/backends/native-compose-environment.ts index 338fea1c7..faa943dcb 100644 --- a/src/backends/native-compose-environment.ts +++ b/src/backends/native-compose-environment.ts @@ -17,6 +17,12 @@ import { nativeEnvFileSourceLocation, readNativeEnvFileSource, } from "./native-env-file-source.ts"; +import { + NATIVE_ENV_SECRET_SOURCE_REASON, + NATIVE_ENV_SECRET_SOURCE_UNKNOWN_REASON, + type NativeEnvSecretDenylist, + nativeEnvSecretDenylist, +} from "./native-env-source-denylist.ts"; import { nativeGcloudCredentialMount } from "./native-gcloud-environment.ts"; import { nativeServiceActive } from "./native-project-builds.ts"; import { shellVariableReadable } from "./native-shell-environment.ts"; @@ -36,6 +42,8 @@ type FileSpec = { type Source = { readonly path: string; readonly text: string } | null; type Options = { readonly projectRoot: string; + /** The project root as the caller supplied it, before canonicalization. */ + readonly callerProjectRoot?: string; readonly composeFile: string; readonly compose: Record; readonly services: Readonly>; @@ -272,7 +280,12 @@ async function canonicalSourceOptions(opts: Options): Promise { ); } const root = await realpath(opts.projectRoot); - return { ...opts, projectRoot: root, composeFile: resolve(root, member) }; + return { + ...opts, + projectRoot: root, + callerProjectRoot: opts.callerProjectRoot ?? opts.projectRoot, + composeFile: resolve(root, member), + }; } async function readAutomaticDotenv(opts: { @@ -343,6 +356,17 @@ async function environmentSources( const cached = new Map(); const automatic = join(dirname(opts.composeFile), ".env"); const env = opts.env ?? {}; + let denylist: NativeEnvSecretDenylist | undefined; + // Built on first use from paths only; it touches no file. + const secretSource = (path: string): boolean => { + denylist ??= nativeEnvSecretDenylist({ + projectRoots: [ + opts.projectRoot, + opts.callerProjectRoot ?? opts.projectRoot, + ], + }); + return denylist.denies(path); + }; let bytes = 0; let references = 0; const shell = (name: string, reportName: boolean): string | undefined => { @@ -372,6 +396,20 @@ async function environmentSources( path: spec.path, service, }); + // Before any other inspection, cache reuse or read of the source. + let denied: boolean; + try { + denied = secretSource(path); + } catch { + throw refused( + spec.path, + NATIVE_ENV_SECRET_SOURCE_UNKNOWN_REASON, + service + ); + } + if (denied) { + throw refused(spec.path, NATIVE_ENV_SECRET_SOURCE_REASON, service); + } if ( (await present(`${automatic}.state.json`)) && (await sameSource(path, automatic)) diff --git a/src/backends/native-env-source-denylist.ts b/src/backends/native-env-source-denylist.ts new file mode 100644 index 000000000..b04912d39 --- /dev/null +++ b/src/backends/native-env-source-denylist.ts @@ -0,0 +1,99 @@ +import { basename, isAbsolute, relative, resolve } from "node:path"; +import { PROJECT_ENV_KEY_FILENAME } from "../constants.ts"; +import { resolveGlobalHackDir } from "../lib/config-paths.ts"; + +/** + * Hack's own key and secret-store files can never be a native environment + * source: neither a service `env_file` nor the automatic `.env`. + * + * Ownership: the native environment reader calls this after literal path + * admission and before it inspects, caches or reads a source, so start, + * preflight, restart preflight and fresh exec refuse alike. The decision is + * lexical: it performs no filesystem access, so it never opens or stats a + * denied file or the Hack home. Callers report the authored source path and a + * closed reason, never a resolved private path or a value. + * + * Denied, after Unicode NFC normalization and Unicode case folding (so a case + * or normalization alias on an insensitive filesystem is denied too): + * - any file named like the project env key, `.hack.secret.key`, at any depth. + * This covers the checkout key, the shared key under the git common + * directory and the primary checkout's key; + * - the legacy project-relative encrypted-file store and key names, + * `.hack-secrets.enc.json` and `.hack-secrets-file.key`; + * - every path under the Hack home (`HACK_HOME`, else `~/.hack`) when the Hack + * home lies inside the project root, including the Hack home itself being + * the project's `.hack` directory; a Hack home equal to the project root + * denies every source. A project that lies strictly inside the Hack home + * keeps the name rules; denying the whole Hack home would refuse every + * source of that project. + * + * Limits: the Hack home is compared by path against both the canonical and + * the caller's project root, not by file identity; a Hack home reachable only + * through a symlink is not recognized. Encrypted stores configured at other + * paths are not covered. Native source admission separately refuses symlinked + * sources and multi-link files. + * + * Failure: when the Hack home cannot be determined (no usable `HOME` or + * `HACK_HOME`), building the denylist throws and every source is refused. + */ + +/** Closed refusal reason; names no path and no value. */ +export const NATIVE_ENV_SECRET_SOURCE_REASON = + "Hack key and secret-store files cannot be environment sources"; +/** Closed refusal reason when the denylist itself cannot be built. */ +export const NATIVE_ENV_SECRET_SOURCE_UNKNOWN_REASON = + "the Hack home could not be determined, so key files cannot be excluded"; + +/** Legacy project-relative encrypted-file store and key names. */ +const LEGACY_STORE_NAMES = [ + ".hack-secrets-file.key", + ".hack-secrets.enc.json", +] as const; + +const DENIED_NAMES: ReadonlySet = new Set( + [PROJECT_ENV_KEY_FILENAME, ...LEGACY_STORE_NAMES].map(fold) +); + +/** + * Unicode NFC plus full Unicode case folding (upper then lower case), so + * aliases on a case- or normalization-insensitive filesystem compare equal. + * It may over-match (for example `ß` and `ss`); that only refuses more. + */ +function fold(path: string): string { + return path.normalize("NFC").toUpperCase().toLowerCase().normalize("NFC"); +} + +function inside(parent: string, child: string): boolean { + const member = relative(parent, child); + return !(member === ".." || member.startsWith("../") || isAbsolute(member)); +} + +export type NativeEnvSecretDenylist = { + /** True when `path` (absolute) must be refused as an environment source. */ + readonly denies: (path: string) => boolean; +}; + +/** + * Build the denylist for one preparation. `projectRoots` are the canonical + * root and, when different, the root the caller supplied. + */ +export function nativeEnvSecretDenylist(opts: { + readonly projectRoots: readonly string[]; + /** Defaults to the Hack home of this process. */ + readonly hackHome?: string; +}): NativeEnvSecretDenylist { + const home = fold(resolve(opts.hackHome ?? resolveGlobalHackDir())); + const roots = opts.projectRoots.map((root) => fold(resolve(root))); + // Only a Hack home inside (or equal to) the project root can hold an + // admissible source; one equal to the root denies every source. + const guardsHome = roots.some((root) => inside(root, home)); + return { + denies: (path) => { + const folded = fold(resolve(path)); + if (DENIED_NAMES.has(basename(folded))) { + return true; + } + return guardsHome && inside(home, folded); + }, + }; +} diff --git a/tests/native-compose-env-parity.test.ts b/tests/native-compose-env-parity.test.ts new file mode 100644 index 000000000..19e88132a --- /dev/null +++ b/tests/native-compose-env-parity.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test"; +import { parseComposeEnvironmentFile } from "../src/backends/native-compose-env-syntax.ts"; + +/** + * Dotenv grammar parity with Docker Compose (HACK-2062 follow-up). + * + * Each `env` expectation was recorded with Docker Compose v5.6.1: + * `docker compose config --format json` for a service whose `env_file` holds + * the row's text, with an interpolation `.env` of `SETV=val` and `EMPTYV=` + * (`UNSETV` is never set). `config` prints a literal `$` as `$$`; the recorded + * values are unescaped. `error: true` means Compose refused the file too. + * `refused` rows are deliberate, documented differences where Hack refuses + * input Compose accepts (see docs/native/environment-files.md). Values here + * are synthetic. + */ +type Expected = + | { readonly env: Readonly> } + | { readonly error: true } + | { readonly refused: string }; + +const context: Readonly> = { SETV: "val", EMPTYV: "" }; +const lookup = (name: string) => + Object.hasOwn(context, name) ? context[name] : undefined; + +const CASES: readonly (readonly [string, string, Expected])[] = [ + ["plain", "A=plain", { env: { A: "plain" } }], + ["spaces around value", "A= spaced value ", { env: { A: "spaced value" } }], + ["spaces around equals", "A = b", { env: { A: "b" } }], + ["leading whitespace", " A=b", { env: { A: "b" } }], + ["colon separator", "A: b", { env: { A: "b" } }], + ["colon no space", "A:b", { env: { A: "b" } }], + [ + "single quoted literal", + "A='lit $SETV ${SETV} \\n'", + { env: { A: "lit $SETV ${SETV} \\n" } }, + ], + [ + "double quoted interpolates", + 'A="x $SETV ${SETV}"', + { env: { A: "x val val" } }, + ], + [ + "double escapes", + 'A="n\\nt\\tq\\"b\\\\d\\$SETV"', + { env: { A: 'n\nt\tq"b\\d$SETV' } }, + ], + ["double unknown escape", 'A="\\q\\x41"', { env: { A: "\\q\\x41" } }], + ["single escaped quote", "A='it\\'s'", { env: { A: "it's" } }], + ["quoted then comment", 'A="v" # c', { env: { A: "v" } }], + [ + "quoted then garbage", + 'A="v"x', + { refused: "Compose drops text after a closing quote" }, + ], + ["unterminated double", 'A="v', { error: true }], + ["unterminated single", "A='v", { error: true }], + ["export prefix", "export A=1", { env: { A: "1" } }], + ["export many spaces", "export A=1", { env: { A: "1" } }], + ["export tab", "export\tA=1", { env: { A: "1" } }], + ["export bare known", "export SETV", { env: { SETV: "val" } }], + ["key named export", "export=1", { env: { export: "1" } }], + ["inline comment space", "A=val # c", { env: { A: "val" } }], + ["inline comment tab", "A=val\t# c", { env: { A: "val\t# c" } }], + ["hash without space", "A=val#c", { env: { A: "val#c" } }], + ["value starts with hash", "A=#c", { env: { A: "#c" } }], + ["space then hash only", "A= # c", { env: { A: "# c" } }], + ["full line comment", "# A=1\nB=2", { env: { B: "2" } }], + ["indented comment", " # A=1\nB=2", { env: { B: "2" } }], + ["multiline double", 'A="l1\nl2"', { env: { A: "l1\nl2" } }], + ["multiline single", "A='l1\nl2'", { env: { A: "l1\nl2" } }], + ["backslash newline in double", 'A="l1\\\nl2"', { env: { A: "l1\\\nl2" } }], + ["empty", "A=", { env: { A: "" } }], + ["empty double quotes", 'A=""', { env: { A: "" } }], + ["empty single quotes", "A=''", { env: { A: "" } }], + ["bare unset", "UNSETV", { env: {} }], + ["bare from context", "SETV", { env: { SETV: "val" } }], + ["bare empty context", "EMPTYV", { env: { EMPTYV: "" } }], + ["unbraced", "A=$SETV/$UNSETV/end", { env: { A: "val//end" } }], + ["dollar escape", "A=$$SETV", { env: { A: "$SETV" } }], + ["lone dollar", "A=a$", { env: { A: "a$" } }], + ["dollar digit", "A=$5", { env: { A: "$5" } }], + [":- set", "A=${SETV:-x}", { env: { A: "val" } }], + [":- empty", "A=${EMPTYV:-x}", { env: { A: "x" } }], + [":- unset", "A=${UNSETV:-x}", { env: { A: "x" } }], + ["- set", "A=${SETV-x}", { env: { A: "val" } }], + ["- empty", "A=${EMPTYV-x}", { env: { A: "" } }], + ["- unset", "A=${UNSETV-x}", { env: { A: "x" } }], + [":? set", "A=${SETV:?err}", { env: { A: "val" } }], + [":? empty", "A=${EMPTYV:?err}", { error: true }], + [":? unset", "A=${UNSETV:?err}", { error: true }], + ["? set", "A=${SETV?err}", { env: { A: "val" } }], + ["? empty", "A=${EMPTYV?err}", { env: { A: "" } }], + ["? unset", "A=${UNSETV?err}", { error: true }], + [":+ set", "A=${SETV:+y}", { env: { A: "y" } }], + [":+ empty", "A=${EMPTYV:+y}", { env: { A: "" } }], + ["+ empty", "A=${EMPTYV+y}", { env: { A: "y" } }], + ["+ unset", "A=${UNSETV+y}", { env: { A: "" } }], + ["default with spaces", "A=${UNSETV:-a b}", { env: { A: "a b" } }], + ["nested default", "A=${UNSETV:-${SETV}}", { env: { A: "val" } }], + ["default in double quotes", 'A="${UNSETV:-x y}"', { env: { A: "x y" } }], + [ + "default in single quotes", + "A='${UNSETV:-x}'", + { env: { A: "${UNSETV:-x}" } }, + ], + ["earlier line reference", "B=one\nA=${B}", { env: { A: "one", B: "one" } }], + ["later line not visible", "A=${C}\nC=late", { env: { A: "", C: "late" } }], + ["duplicate last wins", "A=1\nA=2", { env: { A: "2" } }], + ["crlf", "A=1\r\nB=2\r\n", { env: { A: "1", B: "2" } }], + ["bom", "A=1", { env: { A: "1" } }], + ["dotted key", "A.B=1", { refused: "identifiers only" }], + ["dashed key", "A-B=1", { refused: "identifiers only" }], + ["leading digit key", "1A=1", { refused: "identifiers only" }], + ["unquoted trailing quote", "A=a'b", { env: { A: "a'b" } }], + ["unquoted inner double quote", 'A=a"b"', { env: { A: 'a"b"' } }], + ["tab then hash", "A=val\t#c", { env: { A: "val\t#c" } }], + ["space tab hash", "A=val \t# c", { env: { A: "val \t# c" } }], + ["trailing tab", "A=val\t", { env: { A: "val" } }], + ["trailing spaces", "A=val ", { env: { A: "val" } }], + ["leading tab value", "A=\tval", { env: { A: "val" } }], + ["single quoted then tab comment", "A='v'\t# c", { env: { A: "v" } }], + ["double quoted then tab comment", 'A="v"\t# c', { env: { A: "v" } }], + [ + "single quoted then garbage", + "A='v'x", + { refused: "Compose drops text after a closing quote" }, + ], + ["export with comment", "export A=1 # c", { env: { A: "1" } }], + ["colon with comment", "A: b # c", { env: { A: "b" } }], + ["unbraced then comment", "A=$SETV # c", { env: { A: "val" } }], + ["default containing hash", "A=${UNSETV:-a#b}", { env: { A: "a#b" } }], + ["default containing space hash", "A=${UNSETV:-a #b}", { error: true }], + ["double quoted hash", 'A="a # b"', { env: { A: "a # b" } }], + ["escaped dollar unquoted", "A=\\$SETV", { env: { A: "\\val" } }], + ["octal in single", "A='\\0101'", { env: { A: "\\0101" } }], +]; + +test.each(CASES)("Compose dotenv parity: %s", (_name, text, expected) => { + const parse = () => + parseComposeEnvironmentFile({ + text: text.endsWith("\n") ? text : `${text}\n`, + lookup, + }); + if ("env" in expected) { + expect(Object.fromEntries(parse())).toEqual(expected.env); + return; + } + // Refusals are closed: they never echo the input. + expect(parse).toThrow("values omitted"); +}); diff --git a/tests/native-env-source-denylist.test.ts b/tests/native-env-source-denylist.test.ts new file mode 100644 index 000000000..c316f8d6b --- /dev/null +++ b/tests/native-env-source-denylist.test.ts @@ -0,0 +1,197 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + NATIVE_ENV_SECRET_SOURCE_REASON, + nativeEnvSecretDenylist, +} from "../src/backends/native-env-source-denylist.ts"; +import { prepareNativeProjectInput } from "../src/backends/native-project-input.ts"; + +/** + * Hack key and secret-store files can never be native environment sources + * (HACK-2062 follow-up). The refusal is closed and happens before any read. + */ + +const KEY = "synthetic-key-material-canary"; +const FILE = "synthetic-file-value-canary"; +const roots: string[] = []; +const savedHackHome = process.env.HACK_HOME; + +afterEach(async () => { + if (savedHackHome === undefined) { + Reflect.deleteProperty(process.env, "HACK_HOME"); + } else { + process.env.HACK_HOME = savedHackHome; + } + for (const root of roots.splice(0)) { + await chmod(root, 0o700).catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture(service: Record) { + const projectRoot = await realpath( + await mkdtemp(join(tmpdir(), "native-env-denylist-")) + ); + roots.push(projectRoot); + const projectDir = join(projectRoot, ".hack"); + await mkdir(projectDir, { mode: 0o700 }); + const composeFile = join(projectDir, "docker-compose.yml"); + await writeFile( + composeFile, + JSON.stringify({ services: { app: { image: "alpine:3.21", ...service } } }) + ); + // Each fixture's Hack home is private and outside the project by default. + process.env.HACK_HOME = join( + projectRoot, + "..", + `${projectRoot.split("/").at(-1)}-home` + ); + return { projectRoot, projectDir, composeFile }; +} + +async function refusal(promise: Promise): Promise { + try { + await promise; + } catch (error: unknown) { + return String(error); + } + throw new Error("expected a refusal"); +} + +test.each([ + ["../.hack.secret.key"], + ["nested/.HACK.SECRET.KEY"], + ["../.hack-secrets-file.key"], + ["../.hack-secrets.enc.json"], +])("env_file %s is refused before it is read", async (path) => { + const f = await fixture({ env_file: path }); + const target = join(f.projectDir, path); + await mkdir(join(target, ".."), { recursive: true, mode: 0o700 }); + await writeFile(target, `TOKEN=${KEY}\n`, { mode: 0o600 }); + // Unreadable: any read attempt would refuse with a different reason. + await chmod(target, 0o000); + const message = await refusal(prepareNativeProjectInput(f)); + expect(message).toContain(NATIVE_ENV_SECRET_SOURCE_REASON); + expect(message).not.toContain(KEY); + expect(message).not.toContain(f.projectRoot); +}); + +test("an absent optional key-named source is refused, not skipped", async () => { + const f = await fixture({ + env_file: { path: "../.hack.secret.key", required: false }, + }); + const message = await refusal(prepareNativeProjectInput(f)); + expect(message).toContain(NATIVE_ENV_SECRET_SOURCE_REASON); +}); + +test("fresh exec of one selected service refuses the same source", async () => { + const f = await fixture({ env_file: "../.hack.secret.key" }); + await writeFile(join(f.projectRoot, ".hack.secret.key"), `${KEY}\n`, { + mode: 0o600, + }); + const message = await refusal( + prepareNativeProjectInput({ ...f, selectedServices: ["app"] }) + ); + expect(message).toContain(NATIVE_ENV_SECRET_SOURCE_REASON); + expect(message).not.toContain(KEY); +}); + +test("the automatic .env is refused when the Hack home is the project's .hack directory", async () => { + const f = await fixture({ environment: { COPY: "${SOURCE}" } }); + process.env.HACK_HOME = f.projectDir; + await writeFile(join(f.projectDir, ".env"), `SOURCE=${KEY}\n`, { + mode: 0o600, + }); + const message = await refusal(prepareNativeProjectInput(f)); + expect(message).toContain(NATIVE_ENV_SECRET_SOURCE_REASON); + expect(message).not.toContain(KEY); +}); + +test("files under a Hack home inside the project root are refused; other sources still load", async () => { + const f = await fixture({ + env_file: ["service.env", "../state/hack-state/any.env"], + }); + process.env.HACK_HOME = join(f.projectRoot, "state", "hack-state"); + await writeFile(join(f.projectDir, "service.env"), `FILE=${FILE}\n`, { + mode: 0o600, + }); + const message = await refusal(prepareNativeProjectInput(f)); + expect(message).toContain(NATIVE_ENV_SECRET_SOURCE_REASON); + expect(message).toContain("../state/hack-state/any.env"); + + const control = await fixture({ env_file: "service.env" }); + process.env.HACK_HOME = join(control.projectRoot, "state", "hack-state"); + await writeFile(join(control.projectDir, "service.env"), `FILE=${FILE}\n`, { + mode: 0o600, + }); + const input = await prepareNativeProjectInput(control); + expect(input.shellEnvironment).toEqual({ app: { FILE } }); +}); + +test("a project inside the Hack home keeps ordinary sources and the name rules", async () => { + const f = await fixture({ env_file: "service.env" }); + process.env.HACK_HOME = join(f.projectRoot, ".."); + await writeFile(join(f.projectDir, "service.env"), `FILE=${FILE}\n`, { + mode: 0o600, + }); + expect((await prepareNativeProjectInput(f)).shellEnvironment).toEqual({ + app: { FILE }, + }); + const named = await fixture({ env_file: "../.hack.secret.key" }); + process.env.HACK_HOME = join(named.projectRoot, ".."); + expect(await refusal(prepareNativeProjectInput(named))).toContain( + NATIVE_ENV_SECRET_SOURCE_REASON + ); +}); + +test("the decision is lexical, case-folded and Unicode-normalized", () => { + const denylist = nativeEnvSecretDenylist({ + projectRoots: ["/fixture/project"], + hackHome: "/fixture/project/.Hack-Home", + }); + expect(denylist.denies("/fixture/project/.hack.secret.key")).toBe(true); + expect(denylist.denies("/fixture/project/deep/.Hack.Secret.Key")).toBe(true); + expect(denylist.denies("/fixture/project/.hack-home/x.env")).toBe(true); + expect(denylist.denies("/fixture/project/.hack-home")).toBe(true); + expect(denylist.denies("/fixture/project/.hack/.env")).toBe(false); + expect(denylist.denies("/fixture/project/.hack.secret.key.env")).toBe(false); + // NFD and NFC spellings of the same Hack home name match. + const unicode = nativeEnvSecretDenylist({ + projectRoots: ["/fixture/project"], + hackHome: "/fixture/project/café", + }); + expect(unicode.denies("/fixture/project/café/x.env")).toBe(true); + // Non-ASCII case aliases name the same directory on case-insensitive APFS. + const accented = nativeEnvSecretDenylist({ + projectRoots: ["/fixture/project"], + hackHome: "/fixture/project/\u00c4", + }); + expect(accented.denies("/fixture/project/\u00e4/secrets-file.key")).toBe( + true + ); + expect(accented.denies("/fixture/project/a\u0308/secrets-file.key")).toBe( + true + ); + expect(accented.denies("/fixture/project/a/secrets-file.key")).toBe(false); + expect( + nativeEnvSecretDenylist({ + projectRoots: ["/p"], + hackHome: "/fixture/other/.hack", + }).denies("/p/nested/.H\u00c4CK.secret.key") + ).toBe(false); + // A Hack home outside the project root denies nothing by location. + const outside = nativeEnvSecretDenylist({ + projectRoots: ["/fixture/project"], + hackHome: "/fixture/outside/.hack", + }); + expect(outside.denies("/fixture/project/.hack/.env")).toBe(false); +}); diff --git a/tests/native-preflight-cli.test.ts b/tests/native-preflight-cli.test.ts index c10751415..a9cbad454 100644 --- a/tests/native-preflight-cli.test.ts +++ b/tests/native-preflight-cli.test.ts @@ -279,6 +279,56 @@ test("preflight and start share env-file admission and never publish file values } }, 30_000); +test("preflight and start refuse the project key as an env file before reading it", async () => { + const f = await fixture({ + services: { web: { image: IMAGE, env_file: "../.hack.secret.key" } }, + }); + await chmod(f.project, 0o700); + const keyFile = join(f.project, ".hack.secret.key"); + await writeFile(keyFile, `${PRIVATE}\n`, { mode: 0o600 }); + await f.review([], { services: { web: { active: true } } }); + const env = { ...f.native, ...GRANT }; + const report = await preflight(f, env); + expect(codes(report.report, "cli")).toContain("environment_file_input:web"); + expect(report.output).toContain( + "Hack key and secret-store files cannot be environment sources" + ); + expect(report.output).not.toContain(PRIVATE); + const before = await f.argv(); + // Foreground start: a detached owner discards the refusal text. + const up = await cli(f, ["up"], env); + expect(up.code).toBe(1); + expect(up.output).toContain( + "Hack key and secret-store files cannot be environment sources" + ); + expect(up.output).not.toContain(PRIVATE); + const after = await f.argv(); + expect(after.join("\n")).not.toContain(PRIVATE); + // Start may review the plan and, with first-use setup guidance, inspect the + // runtime setup read-only. Nothing else may run: no pool, file delivery or + // start call reaches the runtime. + const readOnly = (line: string) => { + const words = line.split(" ").slice(2); + if (words[0] === "project" && words[1] === "plan") { + return true; + } + const [runtime, setup, inspect, ...flags] = words; + return ( + runtime === "runtime" && + setup === "setup" && + inspect === "inspect" && + flags.includes("--json") && + flags.every((flag) => flag === "--json" || flag === "--quiescence") + ); + }; + const started = after.slice(before.length); + expect(started.filter((line) => !readOnly(line))).toEqual([]); + expect(readOnly("--home h runtime setup --no-interactive --json")).toBe( + false + ); + expect(readOnly("--home h project start --json")).toBe(false); +}); + test("preflight and static start refuse oversized file delivery before pool effects", async () => { const f = await fixture({ services: { web: { image: IMAGE, env_file: "service.env" } },