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 @@
-
\ 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" } },