From 3cbd39c678481937ebce4ff183ec8ce2c8e540ec Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 21:04:30 +0300 Subject: [PATCH 01/16] docs: correct 1.3.2 changelog boundary --- CHANGELOG.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25dd9101..da910555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,17 +6,6 @@ All notable changes to `@hasna/emails` are documented here. - scope AWS module cross-account SES credentials to `EMAILS_SES_*` only; generic `AWS_*` credentials are no longer injected, so unrelated SDK clients retain the task-role default chain. -## 1.3.2 (2026-07-26) - -- fail closed on malformed JSON, wrong response envelopes, and missing required - fields from successful self-hosted API responses before repositories, mailbox - status/context/sync projections, or the generated SDK can synthesize empty - rows, lists, or counts. -- share one config-driven wire validator across the synchronous resource store, - asynchronous inbox data source, and generated `@hasna/emails/selfhost` client; - validation errors identify the endpoint and invalid field without including - credentials or response-body contents. - - **fix(status): the refusal registry is checked against the CLI, not against itself — `emails status` was still proposing a command that throws.** `src/lib/status-commands.ts` documented its source of truth as `grep -n 'serverOnly(' src/cli/commands/*.remote.ts`. That glob is wrong: `serverOnly()` is also defined and called in the SHARED modules `src/cli/commands/domain.ts` and `src/cli/commands/address.ts`, which `src/cli/index.tsx` loads in BOTH modes and whose helper throws unconditionally. Fifteen commands were missing from `NEVER_AVAILABLE_COMMANDS`, so `status-facts.remote.ts domainFixCommands` returned `emails domain status --json` for any failed/errored domain, `agent-context.ts buildNextActions` promoted `fix_commands[0]` into `next_actions`, and `isCommandAvailableInMode` waved it through — the exact "remedy that refuses" defect the registry exists to remove, reintroduced one command over. Local mode was hit the same way through `domain-readiness.ts` fix_commands (`emails domain check|dns|verify|setup-cloudflare`, all four unconditional refusals). Also fixed: `cli_equivalents.provision_address` and the `create_receive_address` workflow proposed `emails address provision` (refuses everywhere) — now `emails address add` plus an explicit `emails address set-owner` step, because `address add` takes only `--provider`/`--name` and the workflow was registering an owner it never attached; only one of the three workflow lists was mode-filtered, now all three are; and the `Usable domains:` footer and the MCP domains resource `cli_equivalent` both advertised `emails domain status`, now `emails domain list`. - **test(status): the two "never proposes a refused command" guards were self-referential and could not fail.** `agent-context.local.test.ts` asserted `isCommandAvailableInMode(action.command, "local") === true` — validating the payload against the same registry that filtered it, so a command missing from the registry passed the test and threw at the terminal. `agent-context.self-hosted.test.ts` only checked two hardcoded command names. Both now use `src/test-support/cli-refusals.ts`, which parses every `serverOnly(...)`/`notImplementedAnywhere(...)` call site out of `src/cli/commands/*.ts` — the CLI is the oracle, not the registry under test. New `src/lib/status-commands-coverage.test.ts` fails if the registry does not cover a scanned refusal, carries a positive control so it cannot pass over an empty scan, and carries a counter-control that the real remedies (`emails domain list --json`, `emails address add`, …) are still reported available, so "never propose a refusal" cannot be satisfied by proposing nothing. Against the pre-fix registry the new guards fail and name all fifteen missing commands plus `emails domain status --json (refused by emails domain status)`. - **fix(status): a lower bound is no longer reported as a read failure.** `statusGapClass` answers with three classes, and `agent-context.ts` tested only for `"structural"`, so the third — `"bound"` (`enumeration_cap_exceeded`/`enumeration_unstable`) — fell through into `failures[]`. A count the server DID answer was therefore published under `Read failures (N) — these numbers could not be measured` and in a field documented as "caused by a live read failure"; `gaps["domains.usable[].ready_addresses"]` hit this on every shifted address window. Bound gaps now join `incomplete[]` beside the block-level bounds, render under `Lower bounds`, and still set `degraded: true` — the caller asked for a total and got a floor. @@ -53,6 +42,17 @@ All notable changes to `@hasna/emails` are documented here. - perf(cli): `emails domain warm-list` reads the sent-mail ledger **once per page** instead of once per row, via a new `getTodaySentCountsByDomain`. In self-hosted mode each read is a synchronous `curl` spawn over today's messages, so a default 20-row page cost 20 identical requests. - refactor(warming): ramp position (`current_day`, `total_days`, `progress_percent`, `today_limit`, `today_sent`) is computed once in `describeWarmingProgress` and shared by the CLI, the MCP tools, the local `GET /api/warming/:domain` route, and `formatWarmingStatus` — replacing four copies of the same date math, one of which had already drifted from the server. `formatWarmingStatus` and `describeWarmingProgress` accept precomputed inputs so a single command does not read the sent-mail ledger more than once. +## 1.3.2 (2026-07-26) + +- fail closed on malformed JSON, wrong response envelopes, and missing required + fields from successful self-hosted API responses before repositories, mailbox + status/context/sync projections, or the generated SDK can synthesize empty + rows, lists, or counts. +- share one config-driven wire validator across the synchronous resource store, + asynchronous inbox data source, and generated `@hasna/emails/selfhost` client; + validation errors identify the endpoint and invalid field without including + credentials or response-body contents. + ## 1.3.1 (2026-07-26) ### Security From 9dcac06d5bd50953924853b5e72955be5537e771 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 21:14:08 +0300 Subject: [PATCH 02/16] ci: attest npm package release evidence --- .github/workflows/package-provenance.yml | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 .github/workflows/package-provenance.yml diff --git a/.github/workflows/package-provenance.yml b/.github/workflows/package-provenance.yml new file mode 100644 index 00000000..0fca1791 --- /dev/null +++ b/.github/workflows/package-provenance.yml @@ -0,0 +1,133 @@ +name: Attest @hasna/emails 1.3.2 release evidence + +"on": + workflow_dispatch: + +jobs: + attest-published-package: + if: github.repository == 'hasna/emails' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + + steps: + - name: Download and verify published tarball + shell: bash + run: | + set -euo pipefail + umask 077 + + readonly artifact_dir='attestation-input' + readonly artifact="${artifact_dir}/emails-1.3.2.tgz" + readonly tarball_url='https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz' + readonly expected_sha256='8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431' + readonly expected_shasum='87c933255f5e95e7db8bf30bb606e07c1132f01e' + readonly expected_integrity='sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg==' + + mkdir --mode=700 "$artifact_dir" + curl --disable \ + --fail \ + --silent \ + --show-error \ + --proto '=https' \ + --proto-redir '=https' \ + --tlsv1.2 \ + --connect-timeout 30 \ + --max-time 300 \ + --output "$artifact" \ + "$tarball_url" + + printf '%s %s\n' "$expected_sha256" "$artifact" | + sha256sum --check --strict + printf '%s %s\n' "$expected_shasum" "$artifact" | + sha1sum --check --strict + + actual_integrity="sha512-$(openssl dgst -sha512 -binary "$artifact" | openssl base64 -A)" + readonly actual_integrity + if [[ "$actual_integrity" != "$expected_integrity" ]]; then + echo 'npm integrity verification failed' >&2 + exit 1 + fi + + - name: Create release evidence predicate + shell: bash + run: | + set -euo pipefail + + readonly predicate='attestation-input/npm-release-evidence.json' + + cat >"$predicate" <<'JSON' + { + "schemaVersion": 1, + "kind": "npm-release-evidence", + "package": { + "ecosystem": "npm", + "name": "@hasna/emails", + "version": "1.3.2", + "tarballUrl": "https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz", + "sha256": "8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431", + "npmShasum": "87c933255f5e95e7db8bf30bb606e07c1132f01e", + "npmIntegrity": "sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg==" + }, + "sourceEvidence": { + "repository": "https://github.com/hasna/emails", + "sourceMergeCommit": "fe61a466a28115f33efda1ecc7632dbc7c6525c7", + "reviewedHeadCommit": "4330ff214f53a41de681d595d188861bb3d36e13" + }, + "mainCiEvidence": { + "runId": "30212897836", + "url": "https://github.com/hasna/emails/actions/runs/30212897836", + "workflow": ".github/workflows/ci.yml", + "event": "push", + "branch": "main", + "headSha": "fe61a466a28115f33efda1ecc7632dbc7c6525c7", + "conclusion": "success" + }, + "attestationScope": { + "evidenceType": "post-publication-association", + "subjectOrigin": "downloaded-from-npm-registry", + "subjectBuiltByThisWorkflow": false, + "subjectPublishedByThisWorkflow": false, + "statement": "This workflow downloaded and digest-verified the npm tarball bytes, then attested the recorded release evidence. It did not build or publish the package." + } + } + JSON + + jq --exit-status ' + .schemaVersion == 1 and + .kind == "npm-release-evidence" and + .package.ecosystem == "npm" and + .package.name == "@hasna/emails" and + .package.version == "1.3.2" and + .package.tarballUrl == "https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz" and + .package.sha256 == "8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431" and + .package.npmShasum == "87c933255f5e95e7db8bf30bb606e07c1132f01e" and + .package.npmIntegrity == "sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg==" and + .sourceEvidence.repository == "https://github.com/hasna/emails" and + .sourceEvidence.sourceMergeCommit == "fe61a466a28115f33efda1ecc7632dbc7c6525c7" and + .sourceEvidence.reviewedHeadCommit == "4330ff214f53a41de681d595d188861bb3d36e13" and + .mainCiEvidence.runId == "30212897836" and + .mainCiEvidence.url == "https://github.com/hasna/emails/actions/runs/30212897836" and + .mainCiEvidence.workflow == ".github/workflows/ci.yml" and + .mainCiEvidence.event == "push" and + .mainCiEvidence.branch == "main" and + .mainCiEvidence.headSha == "fe61a466a28115f33efda1ecc7632dbc7c6525c7" and + .mainCiEvidence.conclusion == "success" and + .attestationScope.evidenceType == "post-publication-association" and + .attestationScope.subjectOrigin == "downloaded-from-npm-registry" and + .attestationScope.subjectBuiltByThisWorkflow == false and + .attestationScope.subjectPublishedByThisWorkflow == false and + .attestationScope.statement == "This workflow downloaded and digest-verified the npm tarball bytes, then attested the recorded release evidence. It did not build or publish the package." + ' "$predicate" >/dev/null + + - name: Attest downloaded npm tarball + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: attestation-input/emails-1.3.2.tgz + predicate-type: https://github.com/hasna/emails/attestations/npm-release-evidence/v1 + predicate-path: attestation-input/npm-release-evidence.json + push-to-registry: false + create-storage-record: false From 4b75b8e8701d90c289ec0595ac5cc406d461d5e5 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 21:16:04 +0300 Subject: [PATCH 03/16] test: restore shared-process suite gate --- .github/workflows/ci.yml | 45 ++------------- package.json | 4 +- scripts/run-hermetic-tests.sh | 102 ++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 42 deletions(-) create mode 100755 scripts/run-hermetic-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fef5c3a..56590671 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,47 +114,10 @@ jobs: bun-version: 1.3.14 - name: Install locked dependencies run: bun install --frozen-lockfile - - name: Test in isolated local mode - run: | - tmp_home="$(mktemp -d)" - trap 'rm -rf "$tmp_home"' EXIT - env -u MAILERY_MODE -u HASNA_MAILERY_MODE \ - -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \ - -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \ - -u MAILERY_API_URL -u MAILERY_API_KEY \ - -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \ - -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \ - -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \ - -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \ - -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \ - -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \ - -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \ - -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \ - -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \ - -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ - -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \ - -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \ - -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \ - -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \ - -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \ - -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \ - -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \ - AWS_EC2_METADATA_DISABLED=true \ - HOME="$tmp_home" EMAILS_MODE=local EMAILS_DB_PATH=:memory: \ - bash -euo pipefail <<'BASH' - mapfile -d '' -t test_files < <( - find . \ - \( -path './.git' -o -path './node_modules' -o -path './dist' \) -prune -o \ - -type f \( -name '*.test.ts' -o -name '*.test.tsx' \) \ - -print0 | sort -z - ) - test "${#test_files[@]}" -gt 0 - printf 'Running %d test files in isolated Bun processes\n' "${#test_files[@]}" - for test_file in "${test_files[@]}"; do - printf '\n=== %s ===\n' "$test_file" - bun test --max-concurrency 1 "$test_file" - done - BASH + - name: Test the whole local suite in one process + run: bun run test:shared + - name: Diagnose every local test file in isolation + run: bun run test:hermetic - name: Build run: bun run build - name: Verify generated self-hosted SDK signatures diff --git a/package.json b/package.json index 1fb2ffb7..8b7a7533 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ "build:lib": "bun build src/index.ts src/storage.ts src/selfhost.ts --outdir dist --target bun --packages external --splitting", "build:types": "tsc --emitDeclarationOnly --outDir dist", "test:selfhost-sdk-types": "tsc -p tsconfig.selfhost-sdk-type-tests.json", - "test": "bun test", + "test": "bash scripts/run-hermetic-tests.sh shared", + "test:shared": "bash scripts/run-hermetic-tests.sh shared", + "test:hermetic": "bash scripts/run-hermetic-tests.sh isolated", "no-cloud:source": "bun test src/no-cloud-boundary.test.ts src/no-cloud-artifact-scan.test.ts", "no-cloud:pack": "bun scripts/no-cloud-artifact-scan.mjs", "pack:identity": "bun test src/self-hosted-container.test.ts --test-name-pattern \"packed manifest identities\"", diff --git a/scripts/run-hermetic-tests.sh b/scripts/run-hermetic-tests.sh new file mode 100755 index 00000000..b859a054 --- /dev/null +++ b/scripts/run-hermetic-tests.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +mode="${1:-shared}" +case "$mode" in + shared|isolated) ;; + *) + echo "usage: $0 [shared|isolated]" >&2 + exit 2 + ;; +esac + +mapfile -d '' -t test_files < <( + find . \ + \( -path './.git' -o -path './node_modules' -o -path './dist' \) -prune -o \ + -type f \( \ + -name '*.test.js' -o -name '*.test.jsx' -o -name '*.test.ts' -o -name '*.test.tsx' -o \ + -name '*_test.js' -o -name '*_test.jsx' -o -name '*_test.ts' -o -name '*_test.tsx' -o \ + -name '*.spec.js' -o -name '*.spec.jsx' -o -name '*.spec.ts' -o -name '*.spec.tsx' -o \ + -name '*_spec.js' -o -name '*_spec.jsx' -o -name '*_spec.ts' -o -name '*_spec.tsx' \ + \) -print0 | + sort -z +) + +if (("${#test_files[@]}" == 0)); then + echo "No test files discovered" >&2 + exit 1 +fi + +tmp_root="$(mktemp -d)" +trap 'rm -rf "$tmp_root"' EXIT + +run_scrubbed() { + local test_home="$1" + shift + env \ + -u MAILERY_MODE -u HASNA_MAILERY_MODE \ + -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \ + -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \ + -u MAILERY_API_URL -u MAILERY_API_KEY \ + -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \ + -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \ + -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \ + -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \ + -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \ + -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \ + -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \ + -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \ + -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \ + -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \ + -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \ + -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \ + -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \ + -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \ + -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \ + AWS_EC2_METADATA_DISABLED=true \ + NO_COLOR=1 \ + HOME="$test_home" \ + EMAILS_MODE=local \ + EMAILS_DB_PATH=:memory: \ + "$@" +} + +printf 'Discovered %d test files\n' "${#test_files[@]}" + +if [[ "$mode" == "isolated" ]]; then + for test_file in "${test_files[@]}"; do + test_home="$(mktemp -d "$tmp_root/file.XXXXXX")" + printf '\n=== %s ===\n' "$test_file" + run_scrubbed "$test_home" bun test --max-concurrency 1 "$test_file" + done + exit 0 +fi + +shared_home="$(mktemp -d "$tmp_root/shared.XXXXXX")" +shared_output="$tmp_root/shared-output.log" +set +e +run_scrubbed "$shared_home" bun test --max-concurrency 1 2>&1 | tee "$shared_output" +test_status="${PIPESTATUS[0]}" +set -e + +if [[ "$test_status" -ne 0 ]]; then + exit "$test_status" +fi + +reported_files="$( + sed -nE 's/^Ran [0-9]+ tests across ([0-9]+) files\..*$/\1/p' "$shared_output" | + tail -n 1 +)" +if [[ -z "$reported_files" ]]; then + echo "Bun did not report the number of executed test files" >&2 + exit 1 +fi +if [[ "$reported_files" -ne "${#test_files[@]}" ]]; then + echo "Test discovery mismatch: repository=${#test_files[@]} bun=${reported_files}" >&2 + exit 1 +fi From 4dc106cf39290b188fdbebd5d534195c19d1850f Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 21:16:51 +0300 Subject: [PATCH 04/16] fix(container): make smoke platform explicit --- scripts/container-runtime-smoke.sh | 53 ++++- scripts/container-runtime-smoke.test.sh | 296 ++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 7 deletions(-) create mode 100755 scripts/container-runtime-smoke.test.sh diff --git a/scripts/container-runtime-smoke.sh b/scripts/container-runtime-smoke.sh index f8ba38ed..0b9906a7 100755 --- a/scripts/container-runtime-smoke.sh +++ b/scripts/container-runtime-smoke.sh @@ -11,6 +11,33 @@ patched_base_image="${CONTAINER_RUNTIME_PATCHED_BASE_IMAGE:-hasna-emails-patched image="${CONTAINER_RUNTIME_IMAGE:-hasna-emails-runtime-contract:${revision:0:12}}" container="hasna-emails-runtime-contract-${revision:0:12}-$$" +if test "${CONTAINER_RUNTIME_PLATFORM+x}" = "x"; then + case "$CONTAINER_RUNTIME_PLATFORM" in + linux/arm64 | linux/amd64) + platform="$CONTAINER_RUNTIME_PLATFORM" + ;; + *) + printf 'unsupported CONTAINER_RUNTIME_PLATFORM: %s (expected linux/arm64 or linux/amd64)\n' \ + "$CONTAINER_RUNTIME_PLATFORM" >&2 + exit 1 + ;; + esac +else + docker_server_arch="$(docker info --format '{{.Architecture}}')" + case "$docker_server_arch" in + aarch64 | arm64) + platform="linux/arm64" + ;; + x86_64 | amd64) + platform="linux/amd64" + ;; + *) + printf 'unsupported Docker server architecture: %s\n' "$docker_server_arch" >&2 + exit 1 + ;; + esac +fi + cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true if test "${CONTAINER_RUNTIME_KEEP_IMAGE:-0}" != "1"; then @@ -19,18 +46,30 @@ cleanup() { } trap cleanup EXIT -docker build --platform linux/amd64 \ +assert_image_platform() { + local candidate_image="$1" + local actual_platform + actual_platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$candidate_image")" + if test "$actual_platform" != "$platform"; then + printf 'image platform mismatch for %s: requested %s, got %s\n' \ + "$candidate_image" "$platform" "$actual_platform" >&2 + return 1 + fi +} + +docker build --platform "$platform" \ --target base \ --tag "$patched_base_image" \ --build-arg "BUN_IMAGE=$upstream_image" . +assert_image_platform "$patched_base_image" -docker build --platform linux/amd64 \ +docker build --platform "$platform" \ --build-arg "BUN_IMAGE=$upstream_image" \ --build-arg "VERSION=$version" \ --build-arg "REVISION=$revision" \ --tag "$image" . +assert_image_platform "$image" -test "$(docker image inspect --format '{{.Architecture}}' "$image")" = "amd64" test "$(docker image inspect --format '{{.Config.User}}' "$image")" = "1000:1000" test "$(docker image inspect --format '{{.Config.WorkingDir}}' "$image")" = "/app" test "$(docker image inspect --format '{{json .Config.Entrypoint}}' "$image")" = '["/usr/local/bin/bun"]' @@ -39,7 +78,7 @@ test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainer test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "$image")" = "$version" test "$(docker image inspect --format '{{json (index .Config.Volumes "/tmp")}}' "$image")" = '{}' -docker run --rm --platform linux/amd64 --read-only \ +docker run --rm --platform "$platform" --read-only \ --entrypoint /usr/local/bin/bun "$image" -e ' import { access, stat, writeFile } from "node:fs/promises"; import { rootCertificates } from "node:tls"; @@ -59,11 +98,11 @@ docker run --rm --platform linux/amd64 --read-only \ if (rootCertificates.length < 100) throw new Error("public TLS root store is unavailable"); ' -test "$(docker run --rm --platform linux/amd64 --read-only "$image" src/cli/index.tsx --version)" = "$version" -docker run --rm --platform linux/amd64 --read-only "$image" src/server/index.ts --help \ +test "$(docker run --rm --platform "$platform" --read-only "$image" src/cli/index.tsx --version)" = "$version" +docker run --rm --platform "$platform" --read-only "$image" src/server/index.ts --help \ | grep -F 'ingest-worker' >/dev/null -docker run --detach --platform linux/amd64 --read-only --name "$container" \ +docker run --detach --platform "$platform" --read-only --name "$container" \ --tmpfs /app/data:rw,noexec,nosuid,nodev,mode=0700,uid=1000,gid=1000 \ --env EMAILS_MODE=local \ --env EMAILS_DB_PATH=/app/data/emails.db \ diff --git a/scripts/container-runtime-smoke.test.sh b/scripts/container-runtime-smoke.test.sh new file mode 100755 index 00000000..2393180d --- /dev/null +++ b/scripts/container-runtime-smoke.test.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +set -euo pipefail +export BASH_ENV=/dev/null +unset CONTAINER_RUNTIME_PLATFORM +unset FAKE_DOCKER_FORCED_PATCHED_ARCH +unset FAKE_DOCKER_FORCED_FINAL_ARCH + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +script="$repo_root/scripts/container-runtime-smoke.sh" +test_root="$(mktemp -d)" + +cleanup() { + rm -rf "$test_root" +} +trap cleanup EXIT + +fake_bin="$test_root/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "$*" >>"$FAKE_DOCKER_LOG" + +case "${1:-}" in + info) + if test "$#" -ne 3 || test "${2:-}" != "--format" || test "${3:-}" != '{{.Architecture}}'; then + printf 'unexpected docker info command: %s\n' "$*" >&2 + exit 2 + fi + printf '%s\n' "$FAKE_DOCKER_SERVER_ARCH" + ;; + build) + platform="" + image="" + shift + while test "$#" -gt 0; do + case "$1" in + --platform) + platform="${2:-}" + shift 2 + ;; + --tag) + image="${2:-}" + shift 2 + ;; + *) + shift + ;; + esac + done + image_arch="${platform#linux/}" + if test "$image" = "$FAKE_DOCKER_PATCHED_IMAGE" && test -n "${FAKE_DOCKER_FORCED_PATCHED_ARCH:-}"; then + image_arch="$FAKE_DOCKER_FORCED_PATCHED_ARCH" + fi + if test "$image" = "$FAKE_DOCKER_FINAL_IMAGE" && test -n "${FAKE_DOCKER_FORCED_FINAL_ARCH:-}"; then + image_arch="$FAKE_DOCKER_FORCED_FINAL_ARCH" + fi + printf '%s %s\n' "$image" "$image_arch" >>"$FAKE_DOCKER_STATE" + ;; + image) + case "${2:-}" in + inspect) + format="${4:-}" + image="${5:-}" + image_arch="$(awk -v image="$image" '$1 == image { arch = $2 } END { print arch }' "$FAKE_DOCKER_STATE")" + test -n "$image_arch" + case "$format" in + '{{.Os}}/{{.Architecture}}') + printf 'linux/%s\n' "$image_arch" + ;; + '{{.Architecture}}') + printf '%s\n' "$image_arch" + ;; + '{{.Config.User}}') + printf '1000:1000\n' + ;; + '{{.Config.WorkingDir}}') + printf '/app\n' + ;; + '{{json .Config.Entrypoint}}') + printf '["/usr/local/bin/bun"]\n' + ;; + '{{json .Config.Cmd}}') + printf '["src/server/index.ts"]\n' + ;; + '{{index .Config.Labels "org.opencontainers.image.revision"}}') + printf '%s\n' "$FAKE_DOCKER_REVISION" + ;; + '{{index .Config.Labels "org.opencontainers.image.version"}}') + printf '%s\n' "$FAKE_DOCKER_VERSION" + ;; + '{{json (index .Config.Volumes "/tmp")}}') + printf '{}\n' + ;; + *) + printf 'unexpected image inspect format: %s\n' "$format" >&2 + exit 2 + ;; + esac + ;; + rm) + ;; + *) + printf 'unexpected docker image command: %s\n' "$*" >&2 + exit 2 + ;; + esac + ;; + run) + case " $* " in + *' src/cli/index.tsx --version '*) + printf '%s\n' "$FAKE_DOCKER_VERSION" + ;; + *' src/server/index.ts --help '*) + printf 'ingest-worker\n' + ;; + *' --detach '*) + printf 'fake-container-id\n' + ;; + esac + ;; + exec) + ;; + inspect) + printf 'healthy\n' + ;; + logs | rm) + ;; + *) + printf 'unexpected docker command: %s\n' "$*" >&2 + exit 2 + ;; +esac +EOF +chmod +x "$fake_bin/docker" + +revision="$(git -C "$repo_root" rev-parse HEAD)" +version="$(jq -er '.version' "$repo_root/package.json")" + +run_case() { + local case_name="$1" + local server_arch="$2" + local expected_platform="$3" + local override="${4:-}" + local log="$test_root/$case_name.log" + local state="$test_root/$case_name.state" + local stdout="$test_root/$case_name.stdout" + local stderr="$test_root/$case_name.stderr" + local patched_image="test-patched:$case_name" + local final_image="test-final:$case_name" + local -a environment=( + "PATH=$fake_bin:$PATH" + "FAKE_DOCKER_LOG=$log" + "FAKE_DOCKER_STATE=$state" + "FAKE_DOCKER_SERVER_ARCH=$server_arch" + "FAKE_DOCKER_PATCHED_IMAGE=$patched_image" + "FAKE_DOCKER_FINAL_IMAGE=$final_image" + "FAKE_DOCKER_REVISION=$revision" + "FAKE_DOCKER_VERSION=$version" + "CONTAINER_RUNTIME_PATCHED_BASE_IMAGE=$patched_image" + "CONTAINER_RUNTIME_IMAGE=$final_image" + ) + + : >"$log" + : >"$state" + if test -n "$override"; then + environment+=("CONTAINER_RUNTIME_PLATFORM=$override") + fi + + if ! env "${environment[@]}" "$script" >"$stdout" 2>"$stderr"; then + printf '%s script execution failed:\n' "$case_name" >&2 + cat "$stderr" >&2 + return 1 + fi + + test "$(grep -c '^build ' "$log")" = "2" + test "$(grep -c '^run ' "$log")" = "4" + if grep -E '^(build|run) ' "$log" | grep -Fv -- "--platform $expected_platform" >/dev/null; then + printf '%s did not propagate %s to every build and run:\n' "$case_name" "$expected_platform" >&2 + cat "$log" >&2 + return 1 + fi + test "$(grep -c "^image inspect --format {{.Os}}/{{.Architecture}} test-patched:$case_name$" "$log")" = "1" + test "$(grep -c "^image inspect --format {{.Os}}/{{.Architecture}} test-final:$case_name$" "$log")" = "1" +} + +run_case default-aarch64 aarch64 linux/arm64 +run_case default-arm64 arm64 linux/arm64 +run_case default-x86_64 x86_64 linux/amd64 +run_case default-amd64 amd64 linux/amd64 +run_case override-arm64 amd64 linux/arm64 linux/arm64 +run_case override-amd64 aarch64 linux/amd64 linux/amd64 + +invalid_log="$test_root/invalid.log" +invalid_state="$test_root/invalid.state" +invalid_stderr="$test_root/invalid.stderr" +: >"$invalid_log" +: >"$invalid_state" +if env \ + "PATH=$fake_bin:$PATH" \ + "FAKE_DOCKER_LOG=$invalid_log" \ + "FAKE_DOCKER_STATE=$invalid_state" \ + "FAKE_DOCKER_SERVER_ARCH=aarch64" \ + "CONTAINER_RUNTIME_PLATFORM=linux/s390x" \ + "$script" >"$test_root/invalid.stdout" 2>"$invalid_stderr"; then + printf 'unsupported platform override unexpectedly succeeded\n' >&2 + exit 1 +fi +grep -F 'unsupported CONTAINER_RUNTIME_PLATFORM: linux/s390x' "$invalid_stderr" >/dev/null +test ! -s "$invalid_log" + +empty_override_log="$test_root/empty-override.log" +empty_override_state="$test_root/empty-override.state" +empty_override_stderr="$test_root/empty-override.stderr" +: >"$empty_override_log" +: >"$empty_override_state" +if env \ + "PATH=$fake_bin:$PATH" \ + "FAKE_DOCKER_LOG=$empty_override_log" \ + "FAKE_DOCKER_STATE=$empty_override_state" \ + "FAKE_DOCKER_SERVER_ARCH=aarch64" \ + "CONTAINER_RUNTIME_PLATFORM=" \ + "$script" >"$test_root/empty-override.stdout" 2>"$empty_override_stderr"; then + printf 'empty platform override unexpectedly succeeded\n' >&2 + exit 1 +fi +grep -F 'unsupported CONTAINER_RUNTIME_PLATFORM: ' "$empty_override_stderr" >/dev/null +test ! -s "$empty_override_log" + +unsupported_arch_log="$test_root/unsupported-arch.log" +unsupported_arch_state="$test_root/unsupported-arch.state" +unsupported_arch_stderr="$test_root/unsupported-arch.stderr" +: >"$unsupported_arch_log" +: >"$unsupported_arch_state" +if env \ + "PATH=$fake_bin:$PATH" \ + "FAKE_DOCKER_LOG=$unsupported_arch_log" \ + "FAKE_DOCKER_STATE=$unsupported_arch_state" \ + "FAKE_DOCKER_SERVER_ARCH=ppc64le" \ + "$script" >"$test_root/unsupported-arch.stdout" 2>"$unsupported_arch_stderr"; then + printf 'unsupported Docker server architecture unexpectedly succeeded\n' >&2 + exit 1 +fi +grep -F 'unsupported Docker server architecture: ppc64le' "$unsupported_arch_stderr" >/dev/null +test "$(cat "$unsupported_arch_log")" = "info --format {{.Architecture}}" + +mismatch_log="$test_root/mismatch.log" +mismatch_state="$test_root/mismatch.state" +mismatch_stderr="$test_root/mismatch.stderr" +: >"$mismatch_log" +: >"$mismatch_state" +if env \ + "PATH=$fake_bin:$PATH" \ + "FAKE_DOCKER_LOG=$mismatch_log" \ + "FAKE_DOCKER_STATE=$mismatch_state" \ + "FAKE_DOCKER_SERVER_ARCH=aarch64" \ + "FAKE_DOCKER_PATCHED_IMAGE=test-patched:mismatch" \ + "FAKE_DOCKER_FINAL_IMAGE=test-final:mismatch" \ + "FAKE_DOCKER_FORCED_PATCHED_ARCH=amd64" \ + "FAKE_DOCKER_REVISION=$revision" \ + "FAKE_DOCKER_VERSION=$version" \ + "CONTAINER_RUNTIME_PATCHED_BASE_IMAGE=test-patched:mismatch" \ + "CONTAINER_RUNTIME_IMAGE=test-final:mismatch" \ + "$script" >"$test_root/mismatch.stdout" 2>"$mismatch_stderr"; then + printf 'patched-base architecture mismatch unexpectedly succeeded\n' >&2 + exit 1 +fi +grep -F 'image platform mismatch for test-patched:mismatch: requested linux/arm64, got linux/amd64' "$mismatch_stderr" >/dev/null + +final_mismatch_log="$test_root/final-mismatch.log" +final_mismatch_state="$test_root/final-mismatch.state" +final_mismatch_stderr="$test_root/final-mismatch.stderr" +: >"$final_mismatch_log" +: >"$final_mismatch_state" +if env \ + "PATH=$fake_bin:$PATH" \ + "FAKE_DOCKER_LOG=$final_mismatch_log" \ + "FAKE_DOCKER_STATE=$final_mismatch_state" \ + "FAKE_DOCKER_SERVER_ARCH=aarch64" \ + "FAKE_DOCKER_PATCHED_IMAGE=test-patched:final-mismatch" \ + "FAKE_DOCKER_FINAL_IMAGE=test-final:final-mismatch" \ + "FAKE_DOCKER_FORCED_FINAL_ARCH=amd64" \ + "FAKE_DOCKER_REVISION=$revision" \ + "FAKE_DOCKER_VERSION=$version" \ + "CONTAINER_RUNTIME_PATCHED_BASE_IMAGE=test-patched:final-mismatch" \ + "CONTAINER_RUNTIME_IMAGE=test-final:final-mismatch" \ + "$script" >"$test_root/final-mismatch.stdout" 2>"$final_mismatch_stderr"; then + printf 'final-image architecture mismatch unexpectedly succeeded\n' >&2 + exit 1 +fi +grep -F 'image platform mismatch for test-final:final-mismatch: requested linux/arm64, got linux/amd64' \ + "$final_mismatch_stderr" >/dev/null + +printf 'container runtime smoke platform tests passed\n' From ac157b5d76c838f01a6435f75acaf11dcc796622 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 21:35:17 +0300 Subject: [PATCH 05/16] test(container): wire platform regression into Bun suite --- src/self-hosted-container.test.ts | 74 +++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/src/self-hosted-container.test.ts b/src/self-hosted-container.test.ts index a1797564..d0da506d 100644 --- a/src/self-hosted-container.test.ts +++ b/src/self-hosted-container.test.ts @@ -105,13 +105,66 @@ describe("self-hosted container TLS contract", () => { expect(runtimeFilesStage).not.toMatch(/expected\["(?:libcrypto3|libssl3)"\]/); }); - test("builds, retains, and cleans a separately tagged patched base target", () => { + test("selects, propagates, and verifies an explicit supported container platform", () => { expect(runtimeSmoke).toContain( 'patched_base_image="${CONTAINER_RUNTIME_PATCHED_BASE_IMAGE:-hasna-emails-patched-bun-base:${revision:0:12}}"', ); - expect(runtimeSmoke).toMatch( - /docker build --platform linux\/amd64 \\\n+\s+--target base \\\n+\s+--tag "\$patched_base_image" \\\n+\s+--build-arg "BUN_IMAGE=\$upstream_image" \./, + + const platformSelection = runtimeSmoke.slice( + runtimeSmoke.indexOf( + 'if test "${CONTAINER_RUNTIME_PLATFORM+x}" = "x"; then', + ), + runtimeSmoke.indexOf("cleanup() {"), + ); + expect(platformSelection).toContain( + 'if test "${CONTAINER_RUNTIME_PLATFORM+x}" = "x"; then', + ); + expect(platformSelection).toMatch( + /case "\$CONTAINER_RUNTIME_PLATFORM" in\s+linux\/arm64 \| linux\/amd64\)\s+platform="\$CONTAINER_RUNTIME_PLATFORM"\s+;;\s+\*\)\s+printf 'unsupported CONTAINER_RUNTIME_PLATFORM: %s \(expected linux\/arm64 or linux\/amd64\)\\n' \\\s+"\$CONTAINER_RUNTIME_PLATFORM" >&2\s+exit 1/, + ); + expect(platformSelection).toMatch( + /aarch64 \| arm64\)\s+platform="linux\/arm64"\s+;;/, + ); + expect(platformSelection).toMatch( + /x86_64 \| amd64\)\s+platform="linux\/amd64"\s+;;/, + ); + expect(platformSelection).toMatch( + /\*\)\s+printf 'unsupported Docker server architecture: %s\\n' "\$docker_server_arch" >&2\s+exit 1/, + ); + expect(platformSelection).not.toContain( + 'test -n "$CONTAINER_RUNTIME_PLATFORM"', + ); + + const buildIndexes = [ + ...runtimeSmoke.matchAll(/docker build --platform "\$platform" \\/g), + ].map((match) => match.index); + expect([...runtimeSmoke.matchAll(/\bdocker build\b/g)]).toHaveLength(2); + expect(buildIndexes).toHaveLength(2); + + const runCommands = [ + ...runtimeSmoke.matchAll( + /\bdocker run --(?:rm|detach) --platform "\$platform"/g, + ), + ]; + expect([...runtimeSmoke.matchAll(/\bdocker run\b/g)]).toHaveLength(4); + expect(runCommands).toHaveLength(4); + + expect(runtimeSmoke).toContain( + "actual_platform=\"$(docker image inspect --format '{{.Os}}/{{.Architecture}}' \"$candidate_image\")\"", + ); + expect(runtimeSmoke).toContain( + 'if test "$actual_platform" != "$platform"; then', ); + const patchedPlatformCheck = runtimeSmoke.indexOf( + 'assert_image_platform "$patched_base_image"', + ); + const finalPlatformCheck = runtimeSmoke.indexOf( + 'assert_image_platform "$image"', + ); + expect(buildIndexes[0]).toBeLessThan(patchedPlatformCheck); + expect(patchedPlatformCheck).toBeLessThan(buildIndexes[1]); + expect(buildIndexes[1]).toBeLessThan(finalPlatformCheck); + expect(runtimeSmoke).toContain( 'docker image rm -f "$image" "$patched_base_image" >/dev/null 2>&1 || true', ); @@ -120,6 +173,21 @@ describe("self-hosted container TLS contract", () => { ); }); + test("runs the fake-Docker platform regression in the Bun suite", () => { + const output = execFileSync( + "bash", + ["scripts/container-runtime-smoke.test.sh"], + { + cwd: resolve(import.meta.dir, ".."), + encoding: "utf8", + env: { ...process.env, BASH_ENV: "/dev/null" }, + }, + ); + expect(output.trim()).toBe( + "container runtime smoke platform tests passed", + ); + }); + test("pins the official RDS trust bundle by content digest", () => { expect(dockerfile).toContain( `ADD --checksum=sha256:${bundleSha256}`, From b2fc6a06b9fb177b86ebf26365d6afbe6ad39011 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 21:53:21 +0300 Subject: [PATCH 06/16] test: integrate terminal gate contracts --- scripts/no-cloud-scan-lib.mjs | 60 +++++++- src/mode-axis-ratchet.test.ts | 18 ++- src/no-cloud-boundary.test.ts | 26 ++++ src/self-hosted-wire-regression.test.ts | 4 +- src/workflow-contract.test.ts | 181 ++++++++++++++++++++++-- 5 files changed, 273 insertions(+), 16 deletions(-) diff --git a/scripts/no-cloud-scan-lib.mjs b/scripts/no-cloud-scan-lib.mjs index 5235a1a1..775b9a59 100644 --- a/scripts/no-cloud-scan-lib.mjs +++ b/scripts/no-cloud-scan-lib.mjs @@ -239,10 +239,62 @@ function stripExactCompatibilityBridges(content, path) { // bundles that import it for `version`. scanned = scanned.replace(/(? { + const withoutNewline = line.replace(/\r?\n$/, ""); + const trimmed = withoutNewline.trimStart(); + if (!insideEnvCommand && !/^env(?:[ \t]|\\|$)/.test(trimmed)) return line; + insideEnvCommand = true; + + let rewritten = ""; + let quote = null; + for (let index = 0; index < line.length;) { + const char = line[index]; + if (quote !== null) { + rewritten += char; + if (char === quote) quote = null; + else if (char === "\\" && quote === '"' && index + 1 < line.length) { + index += 1; + rewritten += line[index]; + } + index += 1; + continue; + } + if (char === "'" || char === '"') { + quote = char; + rewritten += char; + index += 1; + continue; + } + if ((index === 0 || /\s/.test(line[index - 1])) && line.startsWith("-u", index) && /[ \t]/.test(line[index + 2] ?? "")) { + const keyStart = index + 2 + (line.slice(index + 2).match(/^[ \t]+/)?.[0].length ?? 0); + const key = legacyHostedEnvKeys.find( + (candidate) => + line.startsWith(candidate, keyStart) && + (keyStart + candidate.length === line.length || /[\s\\]/.test(line[keyStart + candidate.length] ?? "")), + ); + if (key !== undefined) { + rewritten += line.slice(index, keyStart) + "LEGACY_HOSTED_SENTINEL"; + index = keyStart + key.length; + continue; + } + } + rewritten += char; + index += 1; + } + + if (!/\\[ \t]*$/.test(withoutNewline)) insideEnvCommand = false; + return rewritten; + }) + .join(""); } return scanned; } diff --git a/src/mode-axis-ratchet.test.ts b/src/mode-axis-ratchet.test.ts index 077474ea..b01d8eaa 100644 --- a/src/mode-axis-ratchet.test.ts +++ b/src/mode-axis-ratchet.test.ts @@ -125,6 +125,13 @@ interface ScannedFile { content: string; } +function stripExactModeAxisCompatibilityBridge(content: string, path: string): string { + if (path !== "scripts/run-hermetic-tests.sh") return content; + const retiredModeKey = "HASNA_EMAILS_" + "MODE"; + const exactBridge = ` -u HASNA_MAILERY_ENV_FILE -u ${retiredModeKey} \\`; + return content.replace(exactBridge, " -u HASNA_MAILERY_ENV_FILE -u RETIRED_MODE_SENTINEL \\"); +} + // `git ls-files` IS the set of committed surfaces, which is the only set that can // carry the axis forward. Deriving the corpus from the index rather than from a // hand-written roots list is what keeps a rename, a new directory or a new file type @@ -162,7 +169,7 @@ function scannedFiles(): ScannedFile[] { if (!statSync(join(root, path)).isFile()) continue; const buffer = readFileSync(join(root, path)); if (isSkippableBinary(path, buffer)) continue; - files.push({ path, content: buffer.toString("utf8") }); + files.push({ path, content: stripExactModeAxisCompatibilityBridge(buffer.toString("utf8"), path) }); } // THE FLOOR LIVES HERE, not in one test. Every assertion that reads the corpus goes // through this function, so none of them can be run — by `-t`, by `it.skip`, or by a @@ -198,6 +205,15 @@ describe("deployment-mode axis ratchet", () => { // presence, not proportion — one file of a kind satisfies it. const kinds = new Set(scanned.map((file) => extname(file.path).toLowerCase() || "(extensionless)")); for (const kind of [".ts", ".tsx", ".mjs", ".md", ".tf", ".yml", ".json"]) expect(kinds).toContain(kind); + + const retiredModeKey = "HASNA_EMAILS_" + "MODE"; + const exactBridge = ` -u HASNA_MAILERY_ENV_FILE -u ${retiredModeKey} \\`; + const hermeticSource = readFileSync(join(root, "scripts/run-hermetic-tests.sh"), "utf8"); + expect(hermeticSource).toContain(exactBridge); + expect(stripExactModeAxisCompatibilityBridge(hermeticSource, "scripts/run-hermetic-tests.sh")).not.toContain(exactBridge); + expect(stripExactModeAxisCompatibilityBridge(hermeticSource, "scripts/arbitrary.sh")).toBe(hermeticSource); + expect(stripExactModeAxisCompatibilityBridge(`echo ${retiredModeKey}`, "scripts/run-hermetic-tests.sh")) + .toBe(`echo ${retiredModeKey}`); }); it("keeps the corpus exemption list minimal, live, and free of self-exemption", () => { diff --git a/src/no-cloud-boundary.test.ts b/src/no-cloud-boundary.test.ts index 9d3d1dff..ced76cc3 100644 --- a/src/no-cloud-boundary.test.ts +++ b/src/no-cloud-boundary.test.ts @@ -148,6 +148,32 @@ describe("no hosted control plane", () => { // Allowances cover a small minority of the tree; everything else is enforced. const allowedCount = scanned.filter((path) => allowed.some((entry) => isSourceAllowed(entry, path))).length; expect(allowedCount * 2).toBeLessThan(scanned.length); + + const exactBridge = [ + "env \\", + " -u MAILERY_CLOUD_API_URL \\", + " -u HASNA_MAILERY_API_URL \\", + " true", + ].join("\n"); + for (const path of [".github/workflows/ci.yml", "scripts/run-hermetic-tests.sh"]) { + expect(sourceBoundaryFindings(exactBridge, path)).toEqual([]); + expect(sourceBoundaryFindings("echo MAILERY_CLOUD_API_URL", path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings("env MAILERY_CLOUD_API_URL=value true", path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings("printf '%s' 'env -u MAILERY_CLOUD_API_URL'", path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + } + expect(sourceBoundaryFindings(exactBridge, "scripts/arbitrary.sh")).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); }); it("contains no banned hosted-control-plane marker in any scanned file", () => { diff --git a/src/self-hosted-wire-regression.test.ts b/src/self-hosted-wire-regression.test.ts index 189ba160..4825f207 100644 --- a/src/self-hosted-wire-regression.test.ts +++ b/src/self-hosted-wire-regression.test.ts @@ -222,9 +222,9 @@ function applySelfHostedEnv(): void { } // `bun test` shares one process across every test file, and the harness sets -// EMAILS_MODE=local exactly once for that process. Deleting these keys rather +// local mode exactly once for that process. Deleting these keys rather // than restoring them leaves every file that runs after this one falling -// through resolveEmailsModeSelection() to EMAILS_CLIENT_ENV_SECRET or the +// through the mode resolver to EMAILS_CLIENT_ENV_SECRET or the // on-disk config, which resolves to self_hosted and fails unrelated suites. // Restore the values this process started with instead. const SELF_HOSTED_ENV_KEYS = [ diff --git a/src/workflow-contract.test.ts b/src/workflow-contract.test.ts index cb6e5ea5..4c723986 100644 --- a/src/workflow-contract.test.ts +++ b/src/workflow-contract.test.ts @@ -5,29 +5,40 @@ import { join } from "node:path"; const workflowDir = join(import.meta.dir, "..", ".github", "workflows"); const repositoryRoot = join(import.meta.dir, ".."); +function readWorkflow(name: string): string { + return readFileSync(join(workflowDir, name), "utf8"); +} + +function singleQuotedReadonly(workflow: string, name: string): string { + const matches = [...workflow.matchAll(new RegExp(`readonly ${name}='([^']*)'`, "g"))]; + expect(matches, `${name} must be declared exactly once`).toHaveLength(1); + return matches[0]?.[1] ?? ""; +} + describe("repository workflow safety", () => { - it("allows only product CI and credential-free Terraform validation", () => { + it("allows only product CI, credential-free Terraform validation, and package provenance", () => { const files = existsSync(workflowDir) ? readdirSync(workflowDir).filter((name) => /\.ya?ml$/.test(name)).sort() : []; - const text = files.map((name) => readFileSync(join(workflowDir, name), "utf8")).join("\n"); - expect(files).toEqual(["ci.yml", "terraform-aws-validate.yml"]); - expect(text).not.toMatch( + expect(files).toEqual(["ci.yml", "package-provenance.yml", "terraform-aws-validate.yml"]); + + const validationText = ["ci.yml", "terraform-aws-validate.yml"].map(readWorkflow).join("\n"); + expect(validationText).not.toMatch( /configure-aws-credentials|aws-actions\/amazon-ecr|amazon-ecr-login|ecs update-service|aws configure|role-to-assume|id-token:\s*write/i, ); - expect(text).not.toMatch(/^\s*(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)\s*:/m); - expect(text).not.toMatch(/\b(?:terraform|tofu)\s+(?:apply|destroy)\b/i); - expect(text).not.toMatch(/\b(?:npm|bun|pnpm|yarn)\s+publish\b/i); + expect(validationText).not.toMatch(/^\s*(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)\s*:/m); + expect(validationText).not.toMatch(/\b(?:terraform|tofu)\s+(?:apply|destroy)\b/i); + expect(validationText).not.toMatch(/\b(?:npm|bun|pnpm|yarn)\s+publish\b/i); }); it("keeps both product CI jobs on the reviewed Bun toolchain", () => { - const ci = readFileSync(join(workflowDir, "ci.yml"), "utf8"); + const ci = readWorkflow("ci.yml"); expect(ci.match(/bun-version:\s*1\.3\.14/g)).toHaveLength(2); expect(ci).not.toContain("bun-version: 1.3.13"); }); it("scans the locally patched Bun base without weakening either vulnerability gate", () => { - const ci = readFileSync(join(workflowDir, "ci.yml"), "utf8"); + const ci = readWorkflow("ci.yml"); expect(ci).toContain( "BUN_UPSTREAM_IMAGE: oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0", ); @@ -52,6 +63,158 @@ describe("repository workflow safety", () => { expect(ci).toContain( "docker image rm -f hasna-emails-runtime-contract:ci hasna-emails-patched-bun-base:ci || true", ); + + const workflow = readWorkflow("package-provenance.yml"); + const tarballUrl = "https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz"; + const sourceRepository = "https://github.com/hasna/emails"; + const sourceMergeCommit = "fe61a466a28115f33efda1ecc7632dbc7c6525c7"; + const reviewedHeadCommit = "4330ff214f53a41de681d595d188861bb3d36e13"; + const ciRunId = "30212897836"; + const ciRunUrl = `https://github.com/hasna/emails/actions/runs/${ciRunId}`; + const sha256 = "8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431"; + const npmShasum = "87c933255f5e95e7db8bf30bb606e07c1132f01e"; + const npmIntegrity = + "sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg=="; + const predicateType = "https://github.com/hasna/emails/attestations/npm-release-evidence/v1"; + const attestAction = "actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6"; + + const triggerStart = workflow.indexOf('"on":'); + const jobsStart = workflow.indexOf("jobs:"); + expect(triggerStart).toBeGreaterThanOrEqual(0); + expect(jobsStart).toBeGreaterThan(triggerStart); + expect(workflow.slice(triggerStart, jobsStart)).toBe('"on":\n workflow_dispatch:\n\n'); + expect(workflow).not.toMatch(/^\s+inputs\s*:/m); + + const jobIds = [...workflow.slice(jobsStart + "jobs:\n".length).matchAll(/^ ([A-Za-z0-9_-]+):$/gm)].map( + (match) => match[1], + ); + expect(jobIds).toEqual(["attest-published-package"]); + expect(workflow.match(/if: github\.repository == 'hasna\/emails' && github\.ref == 'refs\/heads\/main'/g)) + .toHaveLength(1); + expect(workflow).toContain(" runs-on: ubuntu-24.04"); + expect(workflow).toContain(" timeout-minutes: 10"); + + const permissions = workflow.match(/^ permissions:\n((?:^ [a-z-]+: [a-z]+\n)+)/m)?.[1] ?? ""; + expect(workflow.match(/^ permissions:/gm)).toHaveLength(1); + expect(permissions.trim().split("\n").map((line) => line.trim())).toEqual([ + "contents: read", + "id-token: write", + "attestations: write", + ]); + + expect([...workflow.matchAll(/^ - name: (.+)$/gm)].map((match) => match[1])).toEqual([ + "Download and verify published tarball", + "Create release evidence predicate", + "Attest downloaded npm tarball", + ]); + const uses = [...workflow.matchAll(/^\s+uses:\s*(\S+)(?:\s+#.*)?$/gm)].map((match) => match[1]); + expect(uses).toEqual([attestAction]); + expect(workflow).not.toMatch(/actions\/checkout|secrets(?:\.|\[)|\$\{\{/i); + + expect(singleQuotedReadonly(workflow, "artifact_dir")).toBe("attestation-input"); + expect(singleQuotedReadonly(workflow, "tarball_url")).toBe(tarballUrl); + expect(singleQuotedReadonly(workflow, "expected_sha256")).toBe(sha256); + expect(singleQuotedReadonly(workflow, "expected_shasum")).toBe(npmShasum); + expect(singleQuotedReadonly(workflow, "expected_integrity")).toBe(npmIntegrity); + expect(singleQuotedReadonly(workflow, "predicate")).toBe("attestation-input/npm-release-evidence.json"); + + const predicateMatch = workflow.match(/cat >"\$predicate" <<'JSON'\n([\s\S]*?)\n JSON/); + expect(predicateMatch, "the custom predicate heredoc must exist").not.toBeNull(); + const predicateJson = (predicateMatch?.[1] ?? "") + .split("\n") + .map((line) => line.replace(/^ /, "")) + .join("\n"); + expect(JSON.parse(predicateJson)).toEqual({ + schemaVersion: 1, + kind: "npm-release-evidence", + package: { + ecosystem: "npm", + name: "@hasna/emails", + version: "1.3.2", + tarballUrl, + sha256, + npmShasum, + npmIntegrity, + }, + sourceEvidence: { + repository: sourceRepository, + sourceMergeCommit, + reviewedHeadCommit, + }, + mainCiEvidence: { + runId: ciRunId, + url: ciRunUrl, + workflow: ".github/workflows/ci.yml", + event: "push", + branch: "main", + headSha: sourceMergeCommit, + conclusion: "success", + }, + attestationScope: { + evidenceType: "post-publication-association", + subjectOrigin: "downloaded-from-npm-registry", + subjectBuiltByThisWorkflow: false, + subjectPublishedByThisWorkflow: false, + statement: + "This workflow downloaded and digest-verified the npm tarball bytes, then attested the recorded release evidence. It did not build or publish the package.", + }, + }); + + const requiredPredicateChecks = [ + '.schemaVersion == 1', + '.kind == "npm-release-evidence"', + '.package.name == "@hasna/emails"', + '.package.version == "1.3.2"', + `.package.tarballUrl == "${tarballUrl}"`, + `.package.sha256 == "${sha256}"`, + `.package.npmShasum == "${npmShasum}"`, + `.package.npmIntegrity == "${npmIntegrity}"`, + `.sourceEvidence.repository == "${sourceRepository}"`, + `.sourceEvidence.sourceMergeCommit == "${sourceMergeCommit}"`, + `.sourceEvidence.reviewedHeadCommit == "${reviewedHeadCommit}"`, + `.mainCiEvidence.runId == "${ciRunId}"`, + `.mainCiEvidence.url == "${ciRunUrl}"`, + '.mainCiEvidence.workflow == ".github/workflows/ci.yml"', + '.mainCiEvidence.event == "push"', + '.mainCiEvidence.branch == "main"', + `.mainCiEvidence.headSha == "${sourceMergeCommit}"`, + '.mainCiEvidence.conclusion == "success"', + '.attestationScope.evidenceType == "post-publication-association"', + '.attestationScope.subjectOrigin == "downloaded-from-npm-registry"', + '.attestationScope.subjectBuiltByThisWorkflow == false', + '.attestationScope.subjectPublishedByThisWorkflow == false', + ]; + for (const check of requiredPredicateChecks) expect(workflow).toContain(check); + + expect(workflow).toContain(` subject-path: attestation-input/emails-1.3.2.tgz`); + expect(workflow).toContain(` predicate-type: ${predicateType}`); + expect(workflow).toContain(" predicate-path: attestation-input/npm-release-evidence.json"); + expect(workflow).toContain(" push-to-registry: false"); + expect(workflow).toContain(" create-storage-record: false"); + expect(workflow).not.toMatch(/push-to-registry:\s*true|create-storage-record:\s*true/i); + + expect(workflow.match(/\bcurl\b/g)).toHaveLength(1); + for (const marker of [ + "curl --disable", + "--fail", + "--silent", + "--show-error", + "--proto '=https'", + "--proto-redir '=https'", + "--tlsv1.2", + "--connect-timeout 30", + "--max-time 300", + '--output "$artifact"', + '"$tarball_url"', + ]) { + expect(workflow).toContain(marker); + } + expect(workflow).not.toMatch( + /\b(?:wget|npm|bun|pnpm|yarn)\s+(?:publish|pack|view)|\b(?:gh\s+release|git\s+tag|docker\s+push|podman\s+push)|\b(?:aws|terraform|tofu|kubectl|helm)\b/i, + ); + expect([...new Set([...workflow.matchAll(/https:\/\/[^"'\s]+/g)].map((match) => match[0]))].sort()).toEqual( + [ciRunUrl, predicateType, sourceRepository, tarballUrl].sort(), + ); }); it("requires the exact merged and published commit before any AWS deployment", () => { From 599297cd6e84c22c198eae66ec016f152ec48374 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 22:06:51 +0300 Subject: [PATCH 07/16] test(aws): guard provenance workflow --- deploy/aws/tests/static_contract.sh | 147 ++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/deploy/aws/tests/static_contract.sh b/deploy/aws/tests/static_contract.sh index 9d15d37b..13cc2fbb 100755 --- a/deploy/aws/tests/static_contract.sh +++ b/deploy/aws/tests/static_contract.sh @@ -362,12 +362,21 @@ fi workflow_dir="$repo/.github/workflows" workflow="$workflow_dir/terraform-aws-validate.yml" product_workflow="$workflow_dir/ci.yml" +provenance_workflow="$workflow_dir/package-provenance.yml" test -f "$workflow" || { echo "CI-safe Terraform workflow missing" >&2; exit 1; } test -f "$product_workflow" || { echo "product CI workflow missing" >&2; exit 1; } - -workflow_count="$(find "$workflow_dir" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | wc -l | tr -d '[:space:]')" -if [ "$workflow_count" != "2" ]; then - echo "only ci.yml and terraform-aws-validate.yml are allowed" >&2 +test -f "$provenance_workflow" || { echo "package provenance workflow missing" >&2; exit 1; } + +expected_workflows='ci.yml +package-provenance.yml +terraform-aws-validate.yml' +actual_workflows="$( + find "$workflow_dir" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) \ + | sed 's#^.*/##' \ + | sort +)" +if [ "$actual_workflows" != "$expected_workflows" ]; then + echo "only ci.yml, package-provenance.yml, and terraform-aws-validate.yml are allowed" >&2 exit 1 fi @@ -383,7 +392,7 @@ grep -Fq 'terraform providers lock -platform=darwin_arm64 -platform=linux_amd64' if grep -En 'id-token:[[:space:]]*write|configure-aws-credentials|amazon-ecr-login|role-to-assume|aws configure' \ "$workflow" "$product_workflow" >/dev/null; then - echo "workflows must not request AWS credentials or OIDC" >&2 + echo "validation workflows must not request AWS credentials or OIDC" >&2 exit 1 fi @@ -395,11 +404,11 @@ fi if grep -En '(^|[^[:alnum:]_])(terraform|tofu)[[:space:]]+(apply|destroy)([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])(npm|bun|pnpm|yarn)[[:space:]]+publish([^[:alnum:]_-]|$)|ecs[[:space:]]+update-service' \ "$workflow" "$product_workflow" >/dev/null; then - echo "workflows must not apply, destroy, publish, or deploy" >&2 + echo "validation workflows must not apply, destroy, publish, or deploy" >&2 exit 1 fi -for allowed_workflow in "$workflow" "$product_workflow"; do +for allowed_workflow in "$workflow" "$product_workflow" "$provenance_workflow"; do uses_count="$(grep -Ec 'uses:' "$allowed_workflow" || true)" pinned_uses_count="$(grep -Ec 'uses:[[:space:]]+[^@[:space:]]+@[0-9a-f]{40}([[:space:]]+#.*)?$' "$allowed_workflow" || true)" if [ "$uses_count" != "$pinned_uses_count" ]; then @@ -408,6 +417,130 @@ for allowed_workflow in "$workflow" "$product_workflow"; do fi done +provenance_trigger="$( + awk ' + /^"on":[[:space:]]*$/ { capture = 1 } + capture { print } + capture && /^jobs:[[:space:]]*$/ { exit } + ' "$provenance_workflow" +)" +expected_provenance_trigger='"on": + workflow_dispatch: + +jobs:' +if [ "$(grep -Fxc '"on":' "$provenance_workflow" || true)" != "1" ] \ + || [ "$(grep -Fxc 'jobs:' "$provenance_workflow" || true)" != "1" ] \ + || [ "$provenance_trigger" != "$expected_provenance_trigger" ]; then + echo "package provenance must be manual workflow_dispatch only" >&2 + exit 1 +fi + +provenance_job_ids="$( + awk ' + /^jobs:[[:space:]]*$/ { in_jobs = 1; next } + in_jobs && /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + job_id = $0 + sub(/^ /, "", job_id) + sub(/:[[:space:]]*$/, "", job_id) + print job_id + } + ' "$provenance_workflow" +)" +if [ "$provenance_job_ids" != "attest-published-package" ]; then + echo "package provenance must contain only the guarded attestation job" >&2 + exit 1 +fi + +provenance_guard=" if: github.repository == 'hasna/emails' && github.ref == 'refs/heads/main'" +if [ "$(grep -Fxc "$provenance_guard" "$provenance_workflow" || true)" != "1" ]; then + echo "package provenance must have the exact hasna/emails main-branch guard" >&2 + exit 1 +fi + +if [ "$(grep -Ec '^[[:space:]]*permissions:[[:space:]]*$' "$provenance_workflow" || true)" != "1" ]; then + echo "package provenance must declare exactly one permissions block" >&2 + exit 1 +fi +provenance_permissions="$( + awk ' + /^ permissions:[[:space:]]*$/ { capture = 1; next } + capture && /^ [a-z-]+:[[:space:]]*[a-z]+[[:space:]]*$/ { print; next } + capture { exit } + ' "$provenance_workflow" +)" +expected_provenance_permissions=' contents: read + id-token: write + attestations: write' +if [ "$provenance_permissions" != "$expected_provenance_permissions" ]; then + echo "package provenance permissions must be limited to contents read and attestation identity writes" >&2 + exit 1 +fi + +if [ "$(grep -Ec '^[[:space:]]+uses:' "$provenance_workflow" || true)" != "1" ] \ + || [ "$(grep -Fxc ' uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0' "$provenance_workflow" || true)" != "1" ]; then + echo "package provenance must use only the exact actions/attest v4.2.0 commit" >&2 + exit 1 +fi + +for provenance_download_contract in \ + "readonly tarball_url='https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz'" \ + 'curl --disable' \ + '--fail' \ + "--proto '=https'" \ + "--proto-redir '=https'" \ + '--tlsv1.2' \ + '--output "$artifact"' \ + '"$tarball_url"'; do + grep -Fq -- "$provenance_download_contract" "$provenance_workflow" || { + echo "package provenance TLS registry download contract missing: $provenance_download_contract" >&2 + exit 1 + } +done +if [ "$(grep -Ec '^[[:space:]]*curl([[:space:]]|$)' "$provenance_workflow" || true)" != "1" ] \ + || grep -En -- 'http://|--request|--data|--form|--upload-file|--user|--header|(^|[[:space:]])-[[:alpha:]]*k[[:alpha:]]*([[:space:]]|\\|$)|(^|[[:space:]])--insecure([[:space:]]|\\|$)' \ + "$provenance_workflow" >/dev/null; then + echo "package provenance must perform one read-only TLS registry download" >&2 + exit 1 +fi + +for provenance_integrity_contract in \ + "readonly expected_sha256='8f5e166e73ae7aebeb49a5eeae6dd199d0be63a9931a35981373e67b9ccfe431'" \ + "readonly expected_shasum='87c933255f5e95e7db8bf30bb606e07c1132f01e'" \ + "readonly expected_integrity='sha512-nGwS4AoZH2NwTV8Xoop2XupAubyq4bHuawYZX5itCjVwa/3U4hE6t+tBdnKZ9p72/BuUxmAL4iLEZ79eWlkCHg=='" \ + 'sha256sum --check --strict' \ + 'sha1sum --check --strict' \ + 'actual_integrity="sha512-$(openssl dgst -sha512 -binary "$artifact" | openssl base64 -A)"' \ + 'if [[ "$actual_integrity" != "$expected_integrity" ]]; then'; do + grep -Fq -- "$provenance_integrity_contract" "$provenance_workflow" || { + echo "package provenance npm digest verification missing: $provenance_integrity_contract" >&2 + exit 1 + } +done + +for provenance_predicate_contract in \ + "readonly predicate='attestation-input/npm-release-evidence.json'" \ + 'cat >"$predicate" <<'\''JSON'\''' \ + '"kind": "npm-release-evidence"' \ + '"subjectBuiltByThisWorkflow": false' \ + '"subjectPublishedByThisWorkflow": false' \ + 'jq --exit-status' \ + 'subject-path: attestation-input/emails-1.3.2.tgz' \ + 'predicate-type: https://github.com/hasna/emails/attestations/npm-release-evidence/v1' \ + 'predicate-path: attestation-input/npm-release-evidence.json' \ + 'push-to-registry: false' \ + 'create-storage-record: false'; do + grep -Fq -- "$provenance_predicate_contract" "$provenance_workflow" || { + echo "package provenance custom predicate contract missing: $provenance_predicate_contract" >&2 + exit 1 + } +done + +if grep -Ein 'actions/checkout|secrets([.]|\[)|configure-aws-credentials|amazon-ecr-login|role-to-assume|(^|[^[:alnum:]_])(terraform|tofu)[[:space:]]+(apply|destroy)([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])(npm|bun|pnpm|yarn)[[:space:]]+publish([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])(docker|podman)[[:space:]]+push([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])gh[[:space:]]+release([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])git[[:space:]]+tag([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])(aws|kubectl|helm)[[:space:]]+|ecs[[:space:]]+update-service|push-to-registry:[[:space:]]*true|create-storage-record:[[:space:]]*true' \ + "$provenance_workflow" >/dev/null; then + echo "package provenance must not publish, deploy, assume cloud credentials, or read secrets" >&2 + exit 1 +fi + for runbook in "$repo/docs/DEPLOYMENT_CUTOVER.md" "$root/README.md"; do for phrase in \ "migration 0016" \ From dadca973c53e62bf28880fbf6cd76ab1174768a7 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 22:32:06 +0300 Subject: [PATCH 08/16] test: fail closed on provenance workflow drift --- deploy/aws/tests/static_contract.sh | 51 +++++++++- src/workflow-contract.test.ts | 150 +++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 6 deletions(-) diff --git a/deploy/aws/tests/static_contract.sh b/deploy/aws/tests/static_contract.sh index 13cc2fbb..1e616f59 100755 --- a/deploy/aws/tests/static_contract.sh +++ b/deploy/aws/tests/static_contract.sh @@ -363,9 +363,11 @@ workflow_dir="$repo/.github/workflows" workflow="$workflow_dir/terraform-aws-validate.yml" product_workflow="$workflow_dir/ci.yml" provenance_workflow="$workflow_dir/package-provenance.yml" +changelog="$repo/CHANGELOG.md" test -f "$workflow" || { echo "CI-safe Terraform workflow missing" >&2; exit 1; } test -f "$product_workflow" || { echo "product CI workflow missing" >&2; exit 1; } test -f "$provenance_workflow" || { echo "package provenance workflow missing" >&2; exit 1; } +test -f "$changelog" || { echo "changelog missing" >&2; exit 1; } expected_workflows='ci.yml package-provenance.yml @@ -380,6 +382,13 @@ if [ "$actual_workflows" != "$expected_workflows" ]; then exit 1 fi +expected_provenance_sha256='0849b75cbe8fb252c9c2b95ecce59b1f228144c9bcda62c64ba9aa62fbb13c0f' +actual_provenance_sha256="$(sha256sum "$provenance_workflow" | awk '{ print $1 }')" +if [ "$actual_provenance_sha256" != "$expected_provenance_sha256" ]; then + echo "package provenance workflow must match the exact reviewed manual attestation artifact" >&2 + exit 1 +fi + grep -Fq '".github/workflows/**"' "$workflow" || { echo "workflow changes must trigger the static legacy-workflow guard" >&2 exit 1 @@ -535,9 +544,45 @@ for provenance_predicate_contract in \ } done -if grep -Ein 'actions/checkout|secrets([.]|\[)|configure-aws-credentials|amazon-ecr-login|role-to-assume|(^|[^[:alnum:]_])(terraform|tofu)[[:space:]]+(apply|destroy)([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])(npm|bun|pnpm|yarn)[[:space:]]+publish([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])(docker|podman)[[:space:]]+push([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])gh[[:space:]]+release([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])git[[:space:]]+tag([^[:alnum:]_-]|$)|(^|[^[:alnum:]_])(aws|kubectl|helm)[[:space:]]+|ecs[[:space:]]+update-service|push-to-registry:[[:space:]]*true|create-storage-record:[[:space:]]*true' \ - "$provenance_workflow" >/dev/null; then - echo "package provenance must not publish, deploy, assume cloud credentials, or read secrets" >&2 +release_132_section="$( + awk ' + /^## 1[.]3[.]2 [(]2026-07-26[)]$/ { capture = 1 } + capture && /^## / && $0 != "## 1.3.2 (2026-07-26)" { exit } + capture { print } + ' "$changelog" +)" +unreleased_section="$( + awk ' + /^## \[Unreleased\]$/ { capture = 1 } + capture && /^## / && $0 != "## [Unreleased]" { exit } + capture { print } + ' "$changelog" +)" +expected_release_132_section='## 1.3.2 (2026-07-26) + +- fail closed on malformed JSON, wrong response envelopes, and missing required + fields from successful self-hosted API responses before repositories, mailbox + status/context/sync projections, or the generated SDK can synthesize empty + rows, lists, or counts. +- share one config-driven wire validator across the synchronous resource store, + asynchronous inbox data source, and generated `@hasna/emails/selfhost` client; + validation errors identify the endpoint and invalid field without including + credentials or response-body contents.' +expected_unreleased_sha256='40e9d4fc08e67cd4f7d38b053c5c9031dd3e8e403d68bc7e40f83a87bc00ba20' +actual_unreleased_sha256="$(printf '%s' "$unreleased_section" | sha256sum | awk '{ print $1 }')" +unreleased_line="$(grep -Fn '## [Unreleased]' "$changelog" | cut -d: -f1)" +release_132_line="$(grep -Fn '## 1.3.2 (2026-07-26)' "$changelog" | cut -d: -f1)" +release_131_line="$(grep -Fn '## 1.3.1 (2026-07-26)' "$changelog" | cut -d: -f1)" +if [ "$(grep -Fxc '## [Unreleased]' "$changelog" || true)" != "1" ] \ + || [ "$(grep -Fxc '## 1.3.2 (2026-07-26)' "$changelog" || true)" != "1" ] \ + || [ -z "$unreleased_line" ] \ + || [ -z "$release_132_line" ] \ + || [ -z "$release_131_line" ] \ + || [ "$unreleased_line" -ge "$release_132_line" ] \ + || [ "$release_132_line" -ge "$release_131_line" ] \ + || [ "$actual_unreleased_sha256" != "$expected_unreleased_sha256" ] \ + || [ "$release_132_section" != "$expected_release_132_section" ]; then + echo "1.3.2 changelog must contain exactly its two release bullets below the full Unreleased section" >&2 exit 1 fi diff --git a/src/workflow-contract.test.ts b/src/workflow-contract.test.ts index 4c723986..541d8618 100644 --- a/src/workflow-contract.test.ts +++ b/src/workflow-contract.test.ts @@ -1,14 +1,61 @@ import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; const workflowDir = join(import.meta.dir, "..", ".github", "workflows"); const repositoryRoot = join(import.meta.dir, ".."); +const packageProvenanceWorkflowSha256 = "0849b75cbe8fb252c9c2b95ecce59b1f228144c9bcda62c64ba9aa62fbb13c0f"; +const unreleasedSectionSha256 = "40e9d4fc08e67cd4f7d38b053c5c9031dd3e8e403d68bc7e40f83a87bc00ba20"; +const release132Section = `## 1.3.2 (2026-07-26) + +- fail closed on malformed JSON, wrong response envelopes, and missing required + fields from successful self-hosted API responses before repositories, mailbox + status/context/sync projections, or the generated SDK can synthesize empty + rows, lists, or counts. +- share one config-driven wire validator across the synchronous resource store, + asynchronous inbox data source, and generated \`@hasna/emails/selfhost\` client; + validation errors identify the endpoint and invalid field without including + credentials or response-body contents.`; function readWorkflow(name: string): string { return readFileSync(join(workflowDir, name), "utf8"); } +function textSha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function isCanonicalPackageProvenanceWorkflow(workflow: string): boolean { + return textSha256(workflow) === packageProvenanceWorkflowSha256; +} + +function markdownSection(markdown: string, heading: string): string { + const start = markdown.indexOf(`${heading}\n`); + if (start < 0) return ""; + const nextHeading = markdown.indexOf("\n## ", start + heading.length); + return markdown.slice(start, nextHeading < 0 ? markdown.length : nextHeading).trimEnd(); +} + +function hasCanonicalRelease132Boundary(changelog: string): boolean { + const unreleasedHeading = "## [Unreleased]"; + const release132Heading = "## 1.3.2 (2026-07-26)"; + const release131Heading = "## 1.3.1 (2026-07-26)"; + const unreleasedIndex = changelog.indexOf(`${unreleasedHeading}\n`); + const release132Index = changelog.indexOf(`${release132Heading}\n`); + const release131Index = changelog.indexOf(`${release131Heading}\n`); + + return ( + unreleasedIndex >= 0 && + release132Index > unreleasedIndex && + release131Index > release132Index && + changelog.match(/^## \[Unreleased\]$/gm)?.length === 1 && + changelog.match(/^## 1\.3\.2 \(2026-07-26\)$/gm)?.length === 1 && + textSha256(markdownSection(changelog, unreleasedHeading)) === unreleasedSectionSha256 && + markdownSection(changelog, release132Heading) === release132Section + ); +} + function singleQuotedReadonly(workflow: string, name: string): string { const matches = [...workflow.matchAll(new RegExp(`readonly ${name}='([^']*)'`, "g"))]; expect(matches, `${name} must be declared exactly once`).toHaveLength(1); @@ -65,6 +112,7 @@ describe("repository workflow safety", () => { ); const workflow = readWorkflow("package-provenance.yml"); + expect(textSha256(workflow)).toBe(packageProvenanceWorkflowSha256); const tarballUrl = "https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz"; const sourceRepository = "https://github.com/hasna/emails"; const sourceMergeCommit = "fe61a466a28115f33efda1ecc7632dbc7c6525c7"; @@ -209,14 +257,110 @@ describe("repository workflow safety", () => { ]) { expect(workflow).toContain(marker); } - expect(workflow).not.toMatch( - /\b(?:wget|npm|bun|pnpm|yarn)\s+(?:publish|pack|view)|\b(?:gh\s+release|git\s+tag|docker\s+push|podman\s+push)|\b(?:aws|terraform|tofu|kubectl|helm)\b/i, - ); expect([...new Set([...workflow.matchAll(/https:\/\/[^"'\s]+/g)].map((match) => match[0]))].sort()).toEqual( [ciRunUrl, predicateType, sourceRepository, tarballUrl].sort(), ); }); + it("fails closed on every package provenance workflow change", () => { + const workflow = readWorkflow("package-provenance.yml"); + expect(isCanonicalPackageProvenanceWorkflow(workflow)).toBe(true); + + const adversarialFixtures = [ + { + name: "npx npm publish alias", + workflow: workflow.replace( + " umask 077\n", + ' umask 077\n npx --yes npm@latest publish "$artifact"\n', + ), + }, + { + name: "publish hidden behind a shell alias", + workflow: workflow.replace( + " umask 077\n", + " umask 077\n alias release='npm publish'\n release\n", + ), + }, + { + name: "deploy hidden behind a repository script", + workflow: workflow.replace( + " set -euo pipefail\n\n readonly predicate=", + " set -euo pipefail\n bun run deploy\n\n readonly predicate=", + ), + }, + { + name: "added executable run step", + workflow: workflow.replace( + " - name: Attest downloaded npm tarball\n", + " - name: Unexpected executable action\n shell: bash\n run: echo unexpected\n\n - name: Attest downloaded npm tarball\n", + ), + }, + { + name: "added executable uses step", + workflow: workflow.replace( + " - name: Attest downloaded npm tarball\n", + " - name: Unexpected external action\n uses: example/action@0123456789abcdef0123456789abcdef01234567\n\n - name: Attest downloaded npm tarball\n", + ), + }, + { + name: "trigger drift", + workflow: workflow.replace(" workflow_dispatch:\n", " workflow_dispatch:\n push:\n"), + }, + { + name: "permission drift", + workflow: workflow.replace(" contents: read\n", " contents: write\n"), + }, + { + name: "action pin drift", + workflow: workflow.replace( + "actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6", + "actions/attest@0123456789abcdef0123456789abcdef01234567", + ), + }, + ]; + + for (const fixture of adversarialFixtures) { + expect(fixture.workflow, `${fixture.name} fixture must change the workflow`).not.toBe(workflow); + expect(isCanonicalPackageProvenanceWorkflow(fixture.workflow), fixture.name).toBe(false); + } + }); + + it("keeps 1.3.2 at the exact changelog boundary with only its two release bullets", () => { + const changelog = readFileSync(join(repositoryRoot, "CHANGELOG.md"), "utf8"); + expect(hasCanonicalRelease132Boundary(changelog)).toBe(true); + + const adversarialFixtures = [ + { + name: "unreleased entry drifted under 1.3.2", + changelog: changelog.replace( + "## 1.3.2 (2026-07-26)\n\n", + "## 1.3.2 (2026-07-26)\n\n- unrelated unreleased entry\n", + ), + }, + { + name: "third release bullet added", + changelog: changelog.replace( + " credentials or response-body contents.\n", + " credentials or response-body contents.\n- unrelated third release bullet.\n", + ), + }, + { + name: "release boundary moved into Unreleased", + changelog: changelog + .replace(`${release132Section}\n\n`, "") + .replace( + "## [Unreleased]\n\n", + `## [Unreleased]\n\n${release132Section}\n\n`, + ), + }, + ]; + + for (const fixture of adversarialFixtures) { + expect(fixture.changelog, `${fixture.name} fixture must change the changelog`).not.toBe(changelog); + expect(hasCanonicalRelease132Boundary(fixture.changelog), fixture.name).toBe(false); + } + }); + it("requires the exact merged and published commit before any AWS deployment", () => { const runbook = "docs/DEPLOYMENT_CUTOVER.md"; const text = readFileSync(join(repositoryRoot, runbook), "utf8"); From 97710677f81a4b44ba5afa91c19fc05bb17a802d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 22:38:40 +0300 Subject: [PATCH 09/16] ci: verify provenance evidence live --- .github/workflows/package-provenance.yml | 56 +++++++++++++++++++ deploy/aws/tests/static_contract.sh | 58 ++++++++++++++++++- src/workflow-contract.test.ts | 71 +++++++++++++++++++++++- 3 files changed, 180 insertions(+), 5 deletions(-) diff --git a/.github/workflows/package-provenance.yml b/.github/workflows/package-provenance.yml index 0fca1791..663298fc 100644 --- a/.github/workflows/package-provenance.yml +++ b/.github/workflows/package-provenance.yml @@ -9,11 +9,67 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: + actions: read contents: read id-token: write attestations: write steps: + - name: Verify source and CI evidence + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + umask 077 + + readonly repository='hasna/emails' + readonly source_merge_commit='fe61a466a28115f33efda1ecc7632dbc7c6525c7' + readonly ci_run_id='30212897836' + readonly verification_dir="$(mktemp -d)" + readonly commit_response="${verification_dir}/source-commit.json" + readonly run_response="${verification_dir}/ci-run.json" + + cleanup_verification() { + rm -rf -- "$verification_dir" + } + trap cleanup_verification EXIT + + command -v gh >/dev/null + command -v jq >/dev/null + + gh api \ + --method GET \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/${repository}/commits/${source_merge_commit}" \ + >"$commit_response" + jq --exit-status \ + --arg source_merge_commit "$source_merge_commit" \ + '.sha == $source_merge_commit' \ + "$commit_response" >/dev/null + + gh api \ + --method GET \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/${repository}/actions/runs/${ci_run_id}" \ + >"$run_response" + jq --exit-status \ + --arg repository "$repository" \ + --arg source_merge_commit "$source_merge_commit" \ + --argjson ci_run_id "$ci_run_id" ' + .id == $ci_run_id and + .repository.full_name == $repository and + .head_repository.full_name == $repository and + .head_sha == $source_merge_commit and + .status == "completed" and + .conclusion == "success" and + .event == "push" and + .head_branch == "main" and + .path == ".github/workflows/ci.yml" + ' "$run_response" >/dev/null + - name: Download and verify published tarball shell: bash run: | diff --git a/deploy/aws/tests/static_contract.sh b/deploy/aws/tests/static_contract.sh index 1e616f59..e4808423 100755 --- a/deploy/aws/tests/static_contract.sh +++ b/deploy/aws/tests/static_contract.sh @@ -382,7 +382,7 @@ if [ "$actual_workflows" != "$expected_workflows" ]; then exit 1 fi -expected_provenance_sha256='0849b75cbe8fb252c9c2b95ecce59b1f228144c9bcda62c64ba9aa62fbb13c0f' +expected_provenance_sha256='7e80be3eb71e5e4b2f99c80467f6230430eb79be2dd8d170233828076a378285' actual_provenance_sha256="$(sha256sum "$provenance_workflow" | awk '{ print $1 }')" if [ "$actual_provenance_sha256" != "$expected_provenance_sha256" ]; then echo "package provenance workflow must match the exact reviewed manual attestation artifact" >&2 @@ -477,11 +477,24 @@ provenance_permissions="$( capture { exit } ' "$provenance_workflow" )" -expected_provenance_permissions=' contents: read +expected_provenance_permissions=' actions: read + contents: read id-token: write attestations: write' if [ "$provenance_permissions" != "$expected_provenance_permissions" ]; then - echo "package provenance permissions must be limited to contents read and attestation identity writes" >&2 + echo "package provenance permissions must be limited to actions/contents read and attestation identity writes" >&2 + exit 1 +fi + +expected_provenance_steps='Verify source and CI evidence +Download and verify published tarball +Create release evidence predicate +Attest downloaded npm tarball' +actual_provenance_steps="$( + sed -n 's/^ - name: //p' "$provenance_workflow" +)" +if [ "$actual_provenance_steps" != "$expected_provenance_steps" ]; then + echo "package provenance must contain only the exact reviewed verification and attestation steps" >&2 exit 1 fi @@ -491,6 +504,45 @@ if [ "$(grep -Ec '^[[:space:]]+uses:' "$provenance_workflow" || true)" != "1" ] exit 1 fi +if [ "$(grep -Fxc ' GH_TOKEN: ${{ github.token }}' "$provenance_workflow" || true)" != "1" ] \ + || [ "$(grep -Eoc '\$\{\{[^}]+\}\}' "$provenance_workflow" || true)" != "1" ] \ + || grep -Ein 'secrets([.]|\[)|set[[:space:]]+-x|printenv|(^|[[:space:]])env([[:space:]]|$)' \ + "$provenance_workflow" >/dev/null; then + echo "package provenance must expose only github.token to gh without printing token-bearing environment" >&2 + exit 1 +fi + +for provenance_live_verification_contract in \ + "readonly repository='hasna/emails'" \ + "readonly source_merge_commit='fe61a466a28115f33efda1ecc7632dbc7c6525c7'" \ + "readonly ci_run_id='30212897836'" \ + 'command -v gh >/dev/null' \ + 'command -v jq >/dev/null' \ + 'readonly verification_dir="$(mktemp -d)"' \ + 'trap cleanup_verification EXIT' \ + '"/repos/${repository}/commits/${source_merge_commit}"' \ + '"/repos/${repository}/actions/runs/${ci_run_id}"' \ + '.sha == $source_merge_commit' \ + '.id == $ci_run_id' \ + '.repository.full_name == $repository' \ + '.head_repository.full_name == $repository' \ + '.head_sha == $source_merge_commit' \ + '.status == "completed"' \ + '.conclusion == "success"' \ + '.event == "push"' \ + '.head_branch == "main"' \ + '.path == ".github/workflows/ci.yml"'; do + grep -Fq -- "$provenance_live_verification_contract" "$provenance_workflow" || { + echo "package provenance live source/CI verification missing: $provenance_live_verification_contract" >&2 + exit 1 + } +done +if [ "$(grep -Ec '^[[:space:]]*gh api[[:space:]]*\\$' "$provenance_workflow" || true)" != "2" ] \ + || [ "$(grep -Ec '^[[:space:]]*jq --exit-status' "$provenance_workflow" || true)" != "3" ]; then + echo "package provenance must perform exactly two GitHub API reads and three jq validations" >&2 + exit 1 +fi + for provenance_download_contract in \ "readonly tarball_url='https://registry.npmjs.org/@hasna/emails/-/emails-1.3.2.tgz'" \ 'curl --disable' \ diff --git a/src/workflow-contract.test.ts b/src/workflow-contract.test.ts index 541d8618..d47d481d 100644 --- a/src/workflow-contract.test.ts +++ b/src/workflow-contract.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; const workflowDir = join(import.meta.dir, "..", ".github", "workflows"); const repositoryRoot = join(import.meta.dir, ".."); -const packageProvenanceWorkflowSha256 = "0849b75cbe8fb252c9c2b95ecce59b1f228144c9bcda62c64ba9aa62fbb13c0f"; +const packageProvenanceWorkflowSha256 = "7e80be3eb71e5e4b2f99c80467f6230430eb79be2dd8d170233828076a378285"; const unreleasedSectionSha256 = "40e9d4fc08e67cd4f7d38b053c5c9031dd3e8e403d68bc7e40f83a87bc00ba20"; const release132Section = `## 1.3.2 (2026-07-26) @@ -145,19 +145,53 @@ describe("repository workflow safety", () => { const permissions = workflow.match(/^ permissions:\n((?:^ [a-z-]+: [a-z]+\n)+)/m)?.[1] ?? ""; expect(workflow.match(/^ permissions:/gm)).toHaveLength(1); expect(permissions.trim().split("\n").map((line) => line.trim())).toEqual([ + "actions: read", "contents: read", "id-token: write", "attestations: write", ]); expect([...workflow.matchAll(/^ - name: (.+)$/gm)].map((match) => match[1])).toEqual([ + "Verify source and CI evidence", "Download and verify published tarball", "Create release evidence predicate", "Attest downloaded npm tarball", ]); const uses = [...workflow.matchAll(/^\s+uses:\s*(\S+)(?:\s+#.*)?$/gm)].map((match) => match[1]); expect(uses).toEqual([attestAction]); - expect(workflow).not.toMatch(/actions\/checkout|secrets(?:\.|\[)|\$\{\{/i); + expect(workflow).not.toMatch(/actions\/checkout|secrets(?:\.|\[)/i); + expect([...workflow.matchAll(/\$\{\{\s*([^}]+?)\s*\}\}/g)].map((match) => match[1])).toEqual([ + "github.token", + ]); + expect(workflow.match(/^\s+GH_TOKEN:\s+\$\{\{ github\.token \}\}$/gm)).toHaveLength(1); + + expect(singleQuotedReadonly(workflow, "repository")).toBe("hasna/emails"); + expect(singleQuotedReadonly(workflow, "source_merge_commit")).toBe(sourceMergeCommit); + expect(singleQuotedReadonly(workflow, "ci_run_id")).toBe(ciRunId); + expect(workflow.match(/^\s+gh api\s*\\$/gm)).toHaveLength(2); + expect(workflow.match(/^\s+jq --exit-status/gm)).toHaveLength(3); + for (const liveVerificationContract of [ + "command -v gh >/dev/null", + "command -v jq >/dev/null", + 'readonly verification_dir="$(mktemp -d)"', + "trap cleanup_verification EXIT", + '"/repos/${repository}/commits/${source_merge_commit}"', + '"/repos/${repository}/actions/runs/${ci_run_id}"', + ".sha == $source_merge_commit", + ".id == $ci_run_id", + ".repository.full_name == $repository", + ".head_repository.full_name == $repository", + ".head_sha == $source_merge_commit", + '.status == "completed"', + '.conclusion == "success"', + '.event == "push"', + '.head_branch == "main"', + '.path == ".github/workflows/ci.yml"', + ]) { + expect(workflow, `live source/CI verification is missing ${liveVerificationContract}`).toContain( + liveVerificationContract, + ); + } expect(singleQuotedReadonly(workflow, "artifact_dir")).toBe("attestation-input"); expect(singleQuotedReadonly(workflow, "tarball_url")).toBe(tarballUrl); @@ -317,6 +351,39 @@ describe("repository workflow safety", () => { "actions/attest@0123456789abcdef0123456789abcdef01234567", ), }, + { + name: "source commit existence check bypassed", + workflow: workflow.replace(" '.sha == $source_merge_commit' \\\n", " 'true' \\\n"), + }, + { + name: "CI success check bypassed", + workflow: workflow.replace( + ' .status == "completed" and\n .conclusion == "success" and\n', + " true and\n", + ), + }, + { + name: "CI repository association check bypassed", + workflow: workflow.replace( + " .repository.full_name == $repository and\n .head_repository.full_name == $repository and\n", + " true and\n", + ), + }, + { + name: "Actions read permission removed", + workflow: workflow.replace(" actions: read\n", ""), + }, + { + name: "token source changed to secrets context", + workflow: workflow.replace("${{ github.token }}", "${{ secrets.GITHUB_TOKEN }}"), + }, + { + name: "token-bearing shell tracing enabled", + workflow: workflow.replace( + " set -euo pipefail\n umask 077\n\n readonly repository=", + " set -euxo pipefail\n umask 077\n\n readonly repository=", + ), + }, ]; for (const fixture of adversarialFixtures) { From 83672b98a1d4078d46fe8dddf58fb67a3ce6efd6 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 22:40:40 +0300 Subject: [PATCH 10/16] fix(container): verify Bun runtime architecture --- scripts/container-runtime-smoke.sh | 21 +++++++ scripts/container-runtime-smoke.test.sh | 84 +++++++++++++++++++++++++ src/self-hosted-container.test.ts | 40 +++++++++++- 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/scripts/container-runtime-smoke.sh b/scripts/container-runtime-smoke.sh index 0b9906a7..622c99e9 100755 --- a/scripts/container-runtime-smoke.sh +++ b/scripts/container-runtime-smoke.sh @@ -38,6 +38,19 @@ else esac fi +case "$platform" in + linux/arm64) + expected_bun_arch="arm64" + ;; + linux/amd64) + expected_bun_arch="x64" + ;; + *) + printf 'unsupported resolved container platform: %s\n' "$platform" >&2 + exit 1 + ;; +esac + cleanup() { docker rm -f "$container" >/dev/null 2>&1 || true if test "${CONTAINER_RUNTIME_KEEP_IMAGE:-0}" != "1"; then @@ -79,9 +92,17 @@ test "$(docker image inspect --format '{{index .Config.Labels "org.opencontainer test "$(docker image inspect --format '{{json (index .Config.Volumes "/tmp")}}' "$image")" = '{}' docker run --rm --platform "$platform" --read-only \ + --env "CONTAINER_RUNTIME_EXPECTED_BUN_ARCH=$expected_bun_arch" \ --entrypoint /usr/local/bin/bun "$image" -e ' import { access, stat, writeFile } from "node:fs/promises"; import { rootCertificates } from "node:tls"; + const expectedArch = process.env.CONTAINER_RUNTIME_EXPECTED_BUN_ARCH; + if (expectedArch !== "arm64" && expectedArch !== "x64") { + throw new Error(`unsupported expected runtime architecture: ${expectedArch ?? "unset"}`); + } + if (process.arch !== expectedArch) { + throw new Error(`runtime architecture mismatch: expected ${expectedArch}, got ${process.arch}`); + } if (process.cwd() !== "/app") throw new Error(`unexpected cwd: ${process.cwd()}`); if (process.getuid?.() !== 1000 || process.getgid?.() !== 1000) { throw new Error(`unexpected identity: ${process.getuid?.()}:${process.getgid?.()}`); diff --git a/scripts/container-runtime-smoke.test.sh b/scripts/container-runtime-smoke.test.sh index 2393180d..461dd661 100755 --- a/scripts/container-runtime-smoke.test.sh +++ b/scripts/container-runtime-smoke.test.sh @@ -4,6 +4,7 @@ export BASH_ENV=/dev/null unset CONTAINER_RUNTIME_PLATFORM unset FAKE_DOCKER_FORCED_PATCHED_ARCH unset FAKE_DOCKER_FORCED_FINAL_ARCH +unset FAKE_DOCKER_RUNTIME_ARCH repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" script="$repo_root/scripts/container-runtime-smoke.sh" @@ -109,6 +110,29 @@ case "${1:-}" in esac ;; run) + expected_bun_arch="" + models_runtime_arch_check=0 + for argument in "$@"; do + case "$argument" in + CONTAINER_RUNTIME_EXPECTED_BUN_ARCH=*) + expected_bun_arch="${argument#*=}" + ;; + *'if (process.arch !== expectedArch)'*) + models_runtime_arch_check=1 + ;; + esac + done + if test "$models_runtime_arch_check" = "1"; then + if test -z "$expected_bun_arch"; then + printf 'runtime probe did not receive an expected Bun architecture\n' >&2 + exit 2 + fi + if test "${FAKE_DOCKER_RUNTIME_ARCH:-}" != "$expected_bun_arch"; then + printf 'runtime payload architecture mismatch: expected %s, got %s\n' \ + "$expected_bun_arch" "${FAKE_DOCKER_RUNTIME_ARCH:-unset}" >&2 + exit 1 + fi + fi case " $* " in *' src/cli/index.tsx --version '*) printf '%s\n' "$FAKE_DOCKER_VERSION" @@ -144,6 +168,7 @@ run_case() { local server_arch="$2" local expected_platform="$3" local override="${4:-}" + local expected_bun_arch local log="$test_root/$case_name.log" local state="$test_root/$case_name.state" local stdout="$test_root/$case_name.stdout" @@ -163,6 +188,20 @@ run_case() { "CONTAINER_RUNTIME_IMAGE=$final_image" ) + case "$expected_platform" in + linux/arm64) + expected_bun_arch="arm64" + ;; + linux/amd64) + expected_bun_arch="x64" + ;; + *) + printf 'unsupported test platform: %s\n' "$expected_platform" >&2 + return 1 + ;; + esac + environment+=("FAKE_DOCKER_RUNTIME_ARCH=$expected_bun_arch") + : >"$log" : >"$state" if test -n "$override"; then @@ -293,4 +332,49 @@ fi grep -F 'image platform mismatch for test-final:final-mismatch: requested linux/arm64, got linux/amd64' \ "$final_mismatch_stderr" >/dev/null +run_runtime_arch_spoof_case() { + local case_name="$1" + local server_arch="$2" + local expected_platform="$3" + local expected_image_arch="$4" + local expected_bun_arch="$5" + local runtime_arch="$6" + local log="$test_root/$case_name.log" + local state="$test_root/$case_name.state" + local stderr="$test_root/$case_name.stderr" + local patched_image="test-patched:$case_name" + local final_image="test-final:$case_name" + + : >"$log" + : >"$state" + if env \ + "PATH=$fake_bin:$PATH" \ + "FAKE_DOCKER_LOG=$log" \ + "FAKE_DOCKER_STATE=$state" \ + "FAKE_DOCKER_SERVER_ARCH=$server_arch" \ + "FAKE_DOCKER_PATCHED_IMAGE=$patched_image" \ + "FAKE_DOCKER_FINAL_IMAGE=$final_image" \ + "FAKE_DOCKER_RUNTIME_ARCH=$runtime_arch" \ + "FAKE_DOCKER_REVISION=$revision" \ + "FAKE_DOCKER_VERSION=$version" \ + "CONTAINER_RUNTIME_PATCHED_BASE_IMAGE=$patched_image" \ + "CONTAINER_RUNTIME_IMAGE=$final_image" \ + "$script" >"$test_root/$case_name.stdout" 2>"$stderr"; then + printf '%s metadata-spoofed runtime architecture unexpectedly succeeded\n' "$case_name" >&2 + exit 1 + fi + + grep -F "runtime payload architecture mismatch: expected $expected_bun_arch, got $runtime_arch" \ + "$stderr" >/dev/null + test "$(grep -c "^$patched_image $expected_image_arch$" "$state")" = "1" + test "$(grep -c "^$final_image $expected_image_arch$" "$state")" = "1" + test "$(grep -c "^image inspect --format {{.Os}}/{{.Architecture}} $patched_image$" "$log")" = "1" + test "$(grep -c "^image inspect --format {{.Os}}/{{.Architecture}} $final_image$" "$log")" = "1" + test "$(grep -c -- "--platform $expected_platform" "$log")" -ge "3" + test "$(grep -c -- "--env CONTAINER_RUNTIME_EXPECTED_BUN_ARCH=$expected_bun_arch" "$log")" = "1" +} + +run_runtime_arch_spoof_case spoof-arm64 aarch64 linux/arm64 arm64 arm64 x64 +run_runtime_arch_spoof_case spoof-amd64 amd64 linux/amd64 amd64 x64 arm64 + printf 'container runtime smoke platform tests passed\n' diff --git a/src/self-hosted-container.test.ts b/src/self-hosted-container.test.ts index d0da506d..ecf863d7 100644 --- a/src/self-hosted-container.test.ts +++ b/src/self-hosted-container.test.ts @@ -105,7 +105,7 @@ describe("self-hosted container TLS contract", () => { expect(runtimeFilesStage).not.toMatch(/expected\["(?:libcrypto3|libssl3)"\]/); }); - test("selects, propagates, and verifies an explicit supported container platform", () => { + test("selects, propagates, and verifies an explicit supported container platform and runtime architecture", () => { expect(runtimeSmoke).toContain( 'patched_base_image="${CONTAINER_RUNTIME_PATCHED_BASE_IMAGE:-hasna-emails-patched-bun-base:${revision:0:12}}"', ); @@ -134,6 +134,15 @@ describe("self-hosted container TLS contract", () => { expect(platformSelection).not.toContain( 'test -n "$CONTAINER_RUNTIME_PLATFORM"', ); + expect(platformSelection).toMatch( + /case "\$platform" in\s+linux\/arm64\)\s+expected_bun_arch="arm64"\s+;;/, + ); + expect(platformSelection).toMatch( + /linux\/amd64\)\s+expected_bun_arch="x64"\s+;;/, + ); + expect(platformSelection).toMatch( + /\*\)\s+printf 'unsupported resolved container platform: %s\\n' "\$platform" >&2\s+exit 1/, + ); const buildIndexes = [ ...runtimeSmoke.matchAll(/docker build --platform "\$platform" \\/g), @@ -171,6 +180,35 @@ describe("self-hosted container TLS contract", () => { expect(runtimeSmoke.indexOf('--tag "$patched_base_image"')).toBeLessThan( runtimeSmoke.indexOf('--tag "$image"'), ); + + const runtimeProbeStart = runtimeSmoke.indexOf( + 'docker run --rm --platform "$platform" --read-only', + ); + const runtimeProbe = runtimeSmoke.slice( + runtimeProbeStart, + runtimeSmoke.indexOf( + 'test "$(docker run --rm --platform "$platform"', + runtimeProbeStart, + ), + ); + expect(runtimeProbe).toContain( + '--env "CONTAINER_RUNTIME_EXPECTED_BUN_ARCH=$expected_bun_arch"', + ); + expect(runtimeProbe).toContain( + "const expectedArch = process.env.CONTAINER_RUNTIME_EXPECTED_BUN_ARCH;", + ); + expect(runtimeProbe).toContain( + 'if (expectedArch !== "arm64" && expectedArch !== "x64")', + ); + expect(runtimeProbe).toContain( + "if (process.arch !== expectedArch)", + ); + expect(runtimeProbe).toContain( + "runtime architecture mismatch: expected ${expectedArch}, got ${process.arch}", + ); + expect(runtimeProbe).not.toContain( + 'const expectedArch = "$expected_bun_arch"', + ); }); test("runs the fake-Docker platform regression in the Bun suite", () => { From a80020deb71f0cf30052c56a93aa42a7fffc38c4 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 22:42:42 +0300 Subject: [PATCH 11/16] fix(guards): make no-cloud bridges exact --- scripts/no-cloud-scan-lib.mjs | 262 +++++++++++++++++++++++++--------- src/no-cloud-boundary.test.ts | 235 ++++++++++++++++++++++++++++-- 2 files changed, 417 insertions(+), 80 deletions(-) diff --git a/scripts/no-cloud-scan-lib.mjs b/scripts/no-cloud-scan-lib.mjs index 775b9a59..231a1aa9 100644 --- a/scripts/no-cloud-scan-lib.mjs +++ b/scripts/no-cloud-scan-lib.mjs @@ -130,17 +130,10 @@ const boundaryPatterns = [ label: "hosted implementation vocabulary", scopes: BOTH, pattern: /\b(?:saas|fleet)\b|cloud_/i, - // `src/lib/mode.ts`, `src/server/self-hosted/migrations.ts` and - // `src/server/self-hosted/service-resources.test.ts` are handled by - // stripExactCompatibilityBridges and need no allowance. - sourceAllowance: { - reason: - "Prose describing the pivot AWAY from a hosted SaaS/fleet (the changelog, design docs, the deploy " + - "README, and the Terraform comment asserting no vendor-owned account), plus the suites that assert " + - "`MAILERY_CLOUD_*` inputs are rejected. Non-test, non-prose source is still checked, as is every " + - "packed bundle chunk — and the packed README is checked, because allowances are source-only.", - paths: [/\.test\.ts$/, /^CHANGELOG\.md$/, /^docs\//, /^deploy\/aws\/README\.md$/, /^deploy\/aws\/backend\.tf$/], - }, + // Every legitimate source occurrence is handled content-exactly by + // stripExactCompatibilityBridges. There is deliberately no path allowance: + // new test fixtures, prose, or CHANGELOG entries must prove they are another + // narrow compatibility record instead of inheriting a directory-wide bypass. }, ]; @@ -198,6 +191,161 @@ export const artifactBoundaryPatterns = boundaryPatternsForScope(ARTIFACT_SCOPE) /** Patterns enforced on the committed source tree. */ export const sourceBoundaryPatterns = boundaryPatternsForScope(SOURCE_SCOPE); +// These are the only source locations where retired hosted environment names +// remain operationally necessary: they are unset before local tests start. +// Include the surrounding structure plus the full option, assignment, and +// utility sequence so a lookalike env command elsewhere in either file cannot +// borrow this compatibility bridge. +const exactLegacyHostedEnvUnsetBridges = new Map([ + [ + ".github/workflows/ci.yml", + [ + " - name: Test in isolated local mode", + " run: |", + ' tmp_home="$(mktemp -d)"', + " trap 'rm -rf \"$tmp_home\"' EXIT", + " env -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", + " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", + " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", + " -u MAILERY_API_URL -u MAILERY_API_KEY \\", + " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", + " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", + " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", + " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", + " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", + " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", + " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", + " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", + " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", + " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", + " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", + " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", + " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", + " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", + " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", + " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", + " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", + " AWS_EC2_METADATA_DISABLED=true \\", + ' HOME="$tmp_home" EMAILS_MODE=local EMAILS_DB_PATH=:memory: \\', + " bash -euo pipefail <<'BASH'", + ].join("\n") + "\n", + ], + [ + "scripts/run-hermetic-tests.sh", + [ + "run_scrubbed() {", + ' local test_home="$1"', + " shift", + " env \\", + " -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", + " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", + " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", + " -u MAILERY_API_URL -u MAILERY_API_KEY \\", + " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", + " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", + " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", + " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", + " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", + " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", + " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", + " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", + " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", + " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", + " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", + " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", + " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", + " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", + " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", + " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", + " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", + " AWS_EC2_METADATA_DISABLED=true \\", + " NO_COLOR=1 \\", + ' HOME="$test_home" \\', + " EMAILS_MODE=local \\", + " EMAILS_DB_PATH=:memory: \\", + ' "$@"', + ].join("\n") + "\n", + ], +]); + +for (const [path, bridge] of exactLegacyHostedEnvUnsetBridges) { + for (const key of legacyHostedEnvKeys) { + const token = `-u ${key}`; + if (bridge.indexOf(token) < 0 || bridge.indexOf(token) !== bridge.lastIndexOf(token)) { + throw new Error(`exact compatibility bridge ${path} must contain ${token} exactly once`); + } + } +} + +const exactHistoricalChangelogBridge = [ + "- rebuild the product as local-first and operator-owned AWS self-hosting, with no Hasna SaaS control plane.", + "- add durable idempotent self-hosted sends, authenticated attachment retrieval, mailbox mutations, signed replay-safe webhooks, and explicit compatibility for previously issued API keys.", + "- harden deployment with separate migration/runtime database roles, readiness health checks, immutable container/action pins, and explicit local/self-hosted mode validation.", +].join("\n") + "\n"; + +// Historical and deployment prose may document the completed move away from a +// hosted product. Preserve only the exact current paragraphs, including their +// surrounding context, rather than allowing an entire file or directory. +const exactHistoricalHostedVocabularyBridges = new Map([ + [ + "CHANGELOG.md", + { + content: exactHistoricalChangelogBridge, + tokens: ["SaaS"], + }, + ], + [ + "deploy/aws/README.md", + { + content: [ + "# Emails on operator-owned AWS", + "", + "This Terraform root configuration deploys the Emails self-hosted service into", + "an AWS account controlled by the operator. It contains no maintainer account,", + "hostname, role, control plane, billing integration, fleet resource, or hosted", + "service endpoint.", + ].join("\n") + "\n", + tokens: ["fleet"], + }, + ], + [ + "deploy/aws/backend.tf", + { + content: [ + "terraform {", + " # Values are supplied by the operator with -backend-config. Nothing here", + " # points at a vendor, fleet, or maintainer-owned account.", + ' backend "s3" {}', + "}", + ].join("\n") + "\n", + tokens: ["fleet"], + }, + ], + [ + "docs/design/multi-tenancy-auth.md", + { + content: [ + "## 15. Implementation reconciliation (v3)", + "", + "The implementation now has exactly two deployment modes: local SQLite and", + "operator-owned `self_hosted` PostgreSQL. It has no hosted SaaS control plane and", + "no hybrid synchronization mode. Passing an explicit Bun `Database` handle to", + "the public library always selects that caller-owned SQLite database, even when", + "the process is otherwise configured as a self-hosted client.", + ].join("\n") + "\n", + tokens: ["SaaS"], + }, + ], +]); + +for (const [path, { content: bridge, tokens }] of exactHistoricalHostedVocabularyBridges) { + for (const token of tokens) { + if (bridge.indexOf(token) < 0 || bridge.indexOf(token) !== bridge.lastIndexOf(token)) { + throw new Error(`exact historical hosted-vocabulary bridge ${path} must contain ${token} exactly once`); + } + } +} + function stripExactCompatibilityBridges(content, path) { let scanned = content; // The mode resolver must retain these literal names only to reject old @@ -239,62 +387,35 @@ function stripExactCompatibilityBridges(content, path) { // bundles that import it for `version`. scanned = scanned.replace(/(? { - const withoutNewline = line.replace(/\r?\n$/, ""); - const trimmed = withoutNewline.trimStart(); - if (!insideEnvCommand && !/^env(?:[ \t]|\\|$)/.test(trimmed)) return line; - insideEnvCommand = true; - - let rewritten = ""; - let quote = null; - for (let index = 0; index < line.length;) { - const char = line[index]; - if (quote !== null) { - rewritten += char; - if (char === quote) quote = null; - else if (char === "\\" && quote === '"' && index + 1 < line.length) { - index += 1; - rewritten += line[index]; - } - index += 1; - continue; - } - if (char === "'" || char === '"') { - quote = char; - rewritten += char; - index += 1; - continue; - } - if ((index === 0 || /\s/.test(line[index - 1])) && line.startsWith("-u", index) && /[ \t]/.test(line[index + 2] ?? "")) { - const keyStart = index + 2 + (line.slice(index + 2).match(/^[ \t]+/)?.[0].length ?? 0); - const key = legacyHostedEnvKeys.find( - (candidate) => - line.startsWith(candidate, keyStart) && - (keyStart + candidate.length === line.length || /[\s\\]/.test(line[keyStart + candidate.length] ?? "")), - ); - if (key !== undefined) { - rewritten += line.slice(index, keyStart) + "LEGACY_HOSTED_SENTINEL"; - index = keyStart + key.length; - continue; - } - } - rewritten += char; - index += 1; - } + // Normalize exactly one byte-exact bridge at its structural location. A second + // bridge, a reordered/injected option, a different indentation, or any retired + // key after env reaches its utility does not match and therefore remains visible + // to the boundary patterns. + const exactBridge = exactLegacyHostedEnvUnsetBridges.get(path); + if (exactBridge !== undefined) { + const bridgeStart = scanned.indexOf(exactBridge); + if (bridgeStart >= 0 && scanned.indexOf(exactBridge, bridgeStart + exactBridge.length) < 0) { + let normalizedBridge = exactBridge; + for (const key of legacyHostedEnvKeys) { + normalizedBridge = normalizedBridge.replace(`-u ${key}`, "-u LEGACY_HOSTED_SENTINEL"); + } + scanned = scanned.slice(0, bridgeStart) + normalizedBridge + scanned.slice(bridgeStart + exactBridge.length); + } + } - if (!/\\[ \t]*$/.test(withoutNewline)) insideEnvCommand = false; - return rewritten; - }) - .join(""); + const historicalBridge = exactHistoricalHostedVocabularyBridges.get(path); + const historicalBridgeStart = historicalBridge === undefined ? -1 : scanned.indexOf(historicalBridge.content); + if (historicalBridge !== undefined && historicalBridgeStart >= 0) { + const { content: bridge, tokens } = historicalBridge; + if (scanned.indexOf(bridge, historicalBridgeStart + bridge.length) >= 0) return scanned; + let normalizedHistoricalBridge = bridge; + for (const token of tokens) { + normalizedHistoricalBridge = normalizedHistoricalBridge.replace(token, "HISTORICAL_HOSTED_SENTINEL"); + } + scanned = + scanned.slice(0, historicalBridgeStart) + + normalizedHistoricalBridge + + scanned.slice(historicalBridgeStart + bridge.length); } return scanned; } @@ -305,7 +426,14 @@ function stripExactCompatibilityBridges(content, path) { * (source) or package-relative (artifact) for `sourceAllowlist` to resolve. */ export function boundaryFindings(content, path, scope) { - const scanned = stripExactCompatibilityBridges(content, path); + let scanned = stripExactCompatibilityBridges(content, path); + // Source test suites must spell the retired environment inputs they reject. + // Remove only those exact variable-name tokens before the broader `cloud_` + // vocabulary rule runs. Artifact fixtures remain unmodified and must detect + // the same token, which is why this normalization is source-only. + if (scope === SOURCE_SCOPE && /\.test\.ts$/.test(path)) { + for (const key of legacyHostedEnvKeys) scanned = scanned.replaceAll(key, "LEGACY_HOSTED_SENTINEL"); + } return boundaryPatternsForScope(scope) .filter((entry) => !(scope === SOURCE_SCOPE && isSourceAllowed(entry, path))) .filter(({ pattern }) => pattern.test(scanned)) diff --git a/src/no-cloud-boundary.test.ts b/src/no-cloud-boundary.test.ts index ced76cc3..e0688291 100644 --- a/src/no-cloud-boundary.test.ts +++ b/src/no-cloud-boundary.test.ts @@ -52,6 +52,101 @@ function scannedPaths(): string[] { .filter((path) => !isSkippableBinary(path, readFileSync(join(root, path)))); } +const legacyHostedEnvKeys = [ + "MAILERY_API_URL", + "MAILERY_API_KEY", + "MAILERY_CLOUD_API_URL", + "MAILERY_CLOUD_TOKEN", + "HASNA_MAILERY_API_URL", + "HASNA_MAILERY_API_KEY", +]; + +const exactCompatibilityBridges = new Map([ + [ + ".github/workflows/ci.yml", + [ + " - name: Test in isolated local mode", + " run: |", + ' tmp_home="$(mktemp -d)"', + " trap 'rm -rf \"$tmp_home\"' EXIT", + " env -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", + " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", + " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", + " -u MAILERY_API_URL -u MAILERY_API_KEY \\", + " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", + " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", + " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", + " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", + " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", + " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", + " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", + " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", + " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", + " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", + " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", + " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", + " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", + " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", + " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", + " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", + " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", + " AWS_EC2_METADATA_DISABLED=true \\", + ' HOME="$tmp_home" EMAILS_MODE=local EMAILS_DB_PATH=:memory: \\', + " bash -euo pipefail <<'BASH'", + ].join("\n") + "\n", + ], + [ + "scripts/run-hermetic-tests.sh", + [ + "run_scrubbed() {", + ' local test_home="$1"', + " shift", + " env \\", + " -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", + " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", + " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", + " -u MAILERY_API_URL -u MAILERY_API_KEY \\", + " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", + " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", + " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", + " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", + " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", + " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", + " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", + " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", + " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", + " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", + " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", + " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", + " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", + " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", + " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", + " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", + " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", + " AWS_EC2_METADATA_DISABLED=true \\", + " NO_COLOR=1 \\", + ' HOME="$test_home" \\', + " EMAILS_MODE=local \\", + " EMAILS_DB_PATH=:memory: \\", + ' "$@"', + ].join("\n") + "\n", + ], +]); + +function expectedLegacyFindings(key: string): string[] { + return key.includes("CLOUD") + ? ["legacy hosted environment", "hosted implementation vocabulary"] + : ["legacy hosted environment"]; +} + +const hostedServiceModel = ["S", "aaS"].join(""); +const hostedFleetTerm = ["fl", "eet"].join(""); +const exactHistoricalChangelogBridge = [ + `- rebuild the product as local-first and operator-owned AWS self-hosting, with no Hasna ${hostedServiceModel} control plane.`, + "- add durable idempotent self-hosted sends, authenticated attachment retrieval, mailbox mutations, signed replay-safe webhooks, and explicit compatibility for previously issued API keys.", + "- harden deployment with separate migration/runtime database roles, readiness health checks, immutable container/action pins, and explicit local/self-hosted mode validation.", +].join("\n") + "\n"; + describe("no hosted control plane", () => { it("scans every committed file, and a non-vacuous number of them", () => { const tracked = trackedFiles(); @@ -122,11 +217,9 @@ describe("no hosted control plane", () => { it("keeps every source allowance narrow, justified, and live", () => { const allowed = boundaryPatternTable.filter((entry) => entry.sourceAllowance !== undefined); - // Only these two patterns may exempt any source path at all. - expect(allowed.map((entry) => entry.label)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); + // Only retired environment-name rejection fixtures may use a path allowance. + // Hosted implementation vocabulary is normalized content-exactly instead. + expect(allowed.map((entry) => entry.label)).toEqual(["legacy hosted environment"]); const scanned = scannedPaths(); for (const entry of allowed) { expect((entry.sourceAllowance.reason as string).length).toBeGreaterThan(40); @@ -149,13 +242,7 @@ describe("no hosted control plane", () => { const allowedCount = scanned.filter((path) => allowed.some((entry) => isSourceAllowed(entry, path))).length; expect(allowedCount * 2).toBeLessThan(scanned.length); - const exactBridge = [ - "env \\", - " -u MAILERY_CLOUD_API_URL \\", - " -u HASNA_MAILERY_API_URL \\", - " true", - ].join("\n"); - for (const path of [".github/workflows/ci.yml", "scripts/run-hermetic-tests.sh"]) { + for (const [path, exactBridge] of exactCompatibilityBridges) { expect(sourceBoundaryFindings(exactBridge, path)).toEqual([]); expect(sourceBoundaryFindings("echo MAILERY_CLOUD_API_URL", path)).toEqual([ "legacy hosted environment", @@ -170,12 +257,134 @@ describe("no hosted control plane", () => { "hosted implementation vocabulary", ]); } - expect(sourceBoundaryFindings(exactBridge, "scripts/arbitrary.sh")).toEqual([ + }); + + it("allows only the location- and syntax-exact legacy env-unset bridges", () => { + const arbitraryBridge = [ + "env \\", + " -u MAILERY_API_URL -u MAILERY_API_KEY \\", + " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", + " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", + " true", + ].join("\n"); + + for (const [path, exactBridge] of exactCompatibilityBridges) { + expect(sourceBoundaryFindings(exactBridge, path)).toEqual([]); + + // Reviewer bypass 1: a second unrelated env command on an allowed path. + expect(sourceBoundaryFindings(`${exactBridge}\n${arbitraryBridge}`, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings(`${exactBridge}\n${exactBridge}`, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + + // Reviewer bypass 2: env options appearing after option parsing has ended. + expect(sourceBoundaryFindings("env bash -c true -u MAILERY_CLOUD_API_URL ignored", path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + + // The same command text is not a bridge at an arbitrary location. + expect(sourceBoundaryFindings(arbitraryBridge, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + + // Reordering or injecting even a valid env option invalidates the bridge. + const reordered = exactBridge.replace( + /(\s+-u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\\n)(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/, + "$2$1", + ); + expect(sourceBoundaryFindings(reordered, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + const injected = exactBridge.replace( + /(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/, + " -u UNRELATED_ENVIRONMENT_VARIABLE \\\n$1", + ); + expect(sourceBoundaryFindings(injected, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + const changedUtility = + path === ".github/workflows/ci.yml" + ? exactBridge.replace(" bash -euo pipefail <<'BASH'\n", " bash -c true\n") + : exactBridge.replace(' "$@"\n', " bash -c true\n"); + expect(sourceBoundaryFindings(changedUtility, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + + // Every retired key remains banned anywhere outside the one exact bridge. + for (const key of legacyHostedEnvKeys) { + expect(sourceBoundaryFindings(`${exactBridge}\necho ${key}`, path)).toEqual(expectedLegacyFindings(key)); + } + } + + expect(sourceBoundaryFindings(exactCompatibilityBridges.get(".github/workflows/ci.yml")!, "scripts/arbitrary.sh")).toEqual([ "legacy hosted environment", "hosted implementation vocabulary", ]); }); + it("allows hosted vocabulary only in the exact historical CHANGELOG retirement note", () => { + const hostedVocabulary = boundaryPatternTable.find((entry) => entry.label === "hosted implementation vocabulary")!; + expect(isSourceAllowed(hostedVocabulary, "CHANGELOG.md")).toBe(false); + + for (const path of [ + "CHANGELOG.md", + "deploy/aws/README.md", + "deploy/aws/backend.tf", + "docs/design/multi-tenancy-auth.md", + ]) { + expect(sourceBoundaryFindings(readFileSync(join(root, path), "utf8"), path)).toEqual([]); + expect(sourceBoundaryFindings(`- launch the ${hostedServiceModel} ${hostedFleetTerm} control plane.\n`, path)).toEqual([ + "hosted implementation vocabulary", + ]); + } + expect(sourceBoundaryFindings(`const model = "${hostedServiceModel}";\n`, "src/arbitrary.test.ts")).toEqual([ + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings(exactHistoricalChangelogBridge, "CHANGELOG.md")).toEqual([]); + + // The historical sentence is allowed only with its exact neighboring release + // notes; the same prose at an arbitrary location remains a finding. + const historicalSentence = + `- rebuild the product as local-first and operator-owned AWS self-hosting, with no Hasna ${hostedServiceModel} control plane.\n`; + expect(sourceBoundaryFindings(historicalSentence, "CHANGELOG.md")).toEqual(["hosted implementation vocabulary"]); + expect(sourceBoundaryFindings(exactHistoricalChangelogBridge, "HISTORY.md")).toEqual(["hosted implementation vocabulary"]); + + // A duplicate bridge or any new hosted vocabulary in CHANGELOG is rejected. + expect(sourceBoundaryFindings(`${exactHistoricalChangelogBridge}\n${exactHistoricalChangelogBridge}`, "CHANGELOG.md")).toEqual([ + "hosted implementation vocabulary", + ]); + expect( + sourceBoundaryFindings( + `${exactHistoricalChangelogBridge}\n- launch the ${hostedServiceModel} ${hostedFleetTerm} control plane.\n`, + "CHANGELOG.md", + ), + ).toEqual(["hosted implementation vocabulary"]); + + // Rewording the historical note into a current hosted claim invalidates the + // bridge instead of inheriting an allowance from its neighbors. + const poisonedHistory = exactHistoricalChangelogBridge.replace( + `with no Hasna ${hostedServiceModel} control plane.`, + `with a Hasna ${hostedServiceModel} control plane.`, + ); + expect(sourceBoundaryFindings(poisonedHistory, "CHANGELOG.md")).toEqual(["hosted implementation vocabulary"]); + + // Historical release notes may still describe the separate hosted product in + // ordinary language; only the active hosted-implementation tokens are guarded. + const legitimateVersionedHistory = + "## [0.6.117] - 2026-07-09\n" + + "- chore: free the mailery bins for the separate cloud CLI; its cloud API-key app id is unchanged.\n"; + expect(sourceBoundaryFindings(legitimateVersionedHistory, "CHANGELOG.md")).toEqual([]); + }); + it("contains no banned hosted-control-plane marker in any scanned file", () => { const findings = scannedPaths() .flatMap((path) => { From 920aa7366b57d201832cb6bab25415ff5575a06b Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 22:48:23 +0300 Subject: [PATCH 12/16] ci: preserve provenance setup failures --- .github/workflows/package-provenance.yml | 3 ++- deploy/aws/tests/static_contract.sh | 9 +++++++-- src/workflow-contract.test.ts | 6 ++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/package-provenance.yml b/.github/workflows/package-provenance.yml index 663298fc..cbcb5ed7 100644 --- a/.github/workflows/package-provenance.yml +++ b/.github/workflows/package-provenance.yml @@ -26,7 +26,8 @@ jobs: readonly repository='hasna/emails' readonly source_merge_commit='fe61a466a28115f33efda1ecc7632dbc7c6525c7' readonly ci_run_id='30212897836' - readonly verification_dir="$(mktemp -d)" + verification_dir="$(mktemp -d)" + readonly verification_dir readonly commit_response="${verification_dir}/source-commit.json" readonly run_response="${verification_dir}/ci-run.json" diff --git a/deploy/aws/tests/static_contract.sh b/deploy/aws/tests/static_contract.sh index e4808423..beff7651 100755 --- a/deploy/aws/tests/static_contract.sh +++ b/deploy/aws/tests/static_contract.sh @@ -382,7 +382,7 @@ if [ "$actual_workflows" != "$expected_workflows" ]; then exit 1 fi -expected_provenance_sha256='7e80be3eb71e5e4b2f99c80467f6230430eb79be2dd8d170233828076a378285' +expected_provenance_sha256='706c636d7b60059f6e8ce52229bfb723c0c9a2c61cb4a462b3d6ead24a46232f' actual_provenance_sha256="$(sha256sum "$provenance_workflow" | awk '{ print $1 }')" if [ "$actual_provenance_sha256" != "$expected_provenance_sha256" ]; then echo "package provenance workflow must match the exact reviewed manual attestation artifact" >&2 @@ -518,7 +518,8 @@ for provenance_live_verification_contract in \ "readonly ci_run_id='30212897836'" \ 'command -v gh >/dev/null' \ 'command -v jq >/dev/null' \ - 'readonly verification_dir="$(mktemp -d)"' \ + 'verification_dir="$(mktemp -d)"' \ + 'readonly verification_dir' \ 'trap cleanup_verification EXIT' \ '"/repos/${repository}/commits/${source_merge_commit}"' \ '"/repos/${repository}/actions/runs/${ci_run_id}"' \ @@ -537,6 +538,10 @@ for provenance_live_verification_contract in \ exit 1 } done +if grep -Fq 'readonly verification_dir="$(mktemp -d)"' "$provenance_workflow"; then + echo "package provenance must preserve temporary-directory assignment failures" >&2 + exit 1 +fi if [ "$(grep -Ec '^[[:space:]]*gh api[[:space:]]*\\$' "$provenance_workflow" || true)" != "2" ] \ || [ "$(grep -Ec '^[[:space:]]*jq --exit-status' "$provenance_workflow" || true)" != "3" ]; then echo "package provenance must perform exactly two GitHub API reads and three jq validations" >&2 diff --git a/src/workflow-contract.test.ts b/src/workflow-contract.test.ts index d47d481d..e7668e39 100644 --- a/src/workflow-contract.test.ts +++ b/src/workflow-contract.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; const workflowDir = join(import.meta.dir, "..", ".github", "workflows"); const repositoryRoot = join(import.meta.dir, ".."); -const packageProvenanceWorkflowSha256 = "7e80be3eb71e5e4b2f99c80467f6230430eb79be2dd8d170233828076a378285"; +const packageProvenanceWorkflowSha256 = "706c636d7b60059f6e8ce52229bfb723c0c9a2c61cb4a462b3d6ead24a46232f"; const unreleasedSectionSha256 = "40e9d4fc08e67cd4f7d38b053c5c9031dd3e8e403d68bc7e40f83a87bc00ba20"; const release132Section = `## 1.3.2 (2026-07-26) @@ -173,7 +173,8 @@ describe("repository workflow safety", () => { for (const liveVerificationContract of [ "command -v gh >/dev/null", "command -v jq >/dev/null", - 'readonly verification_dir="$(mktemp -d)"', + 'verification_dir="$(mktemp -d)"', + "readonly verification_dir", "trap cleanup_verification EXIT", '"/repos/${repository}/commits/${source_merge_commit}"', '"/repos/${repository}/actions/runs/${ci_run_id}"', @@ -192,6 +193,7 @@ describe("repository workflow safety", () => { liveVerificationContract, ); } + expect(workflow).not.toContain('readonly verification_dir="$(mktemp -d)"'); expect(singleQuotedReadonly(workflow, "artifact_dir")).toBe("attestation-input"); expect(singleQuotedReadonly(workflow, "tarball_url")).toBe(tarballUrl); From ecc548910d5038be540ca44f6dd02c09f0c1e1d8 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 23:10:56 +0300 Subject: [PATCH 13/16] test: close shared process env leaks --- scripts/run-hermetic-tests.sh | 43 ++ src/cli/commands/auth.test.ts | 13 + src/cli/commands/aws.test.ts | 15 +- src/cli/commands/daemon.local.test.ts | 15 +- src/cli/commands/email-log.local.test.ts | 15 +- src/cli/commands/inbox.test.ts | 12 +- src/cli/commands/misc.local.test.ts | 15 +- src/cli/commands/send-suppression.test.ts | 19 + src/cli/tui/App.test.tsx | 13 + ...se-concurrent-migration.regression.test.ts | 8 +- src/db/database.test.ts | 15 +- src/db/emails.self-hosted.test.ts | 13 + .../retired-legacy-inbound-identity.test.ts | 15 +- src/db/self-hosted-resource-paging.test.ts | 13 + src/db/self-hosted-resource-routing.test.ts | 13 + src/db/self-hosted-resource-writes.test.ts | 13 + src/db/self-hosted-store.test.ts | 26 +- src/lib/agent-context.local.test.ts | 15 +- src/lib/client-env.test.ts | 29 +- src/lib/config.test.ts | 18 +- src/lib/doctor.test.ts | 15 +- src/lib/inbound-config.test.ts | 29 +- src/lib/local-mail-data-source.test.ts | 15 +- src/lib/mode.test.ts | 21 +- src/lib/s3-sync.test.ts | 3 +- src/lib/self-hosted-mail-data-source.test.ts | 15 +- src/mcp/domain-address-self-hosted.test.ts | 6 +- src/mcp/http.test.ts | 16 +- src/mcp/local-mode.test.ts | 15 +- src/self-hosted-wire-regression.test.ts | 484 +++++++++++++++++- src/server/routes/core-redaction.test.ts | 15 +- src/server/routes/inbound-webhook.test.ts | 15 +- src/server/routes/resend-webhook.test.ts | 15 +- src/server/routes/rest-parity.test.ts | 15 +- 34 files changed, 969 insertions(+), 48 deletions(-) diff --git a/scripts/run-hermetic-tests.sh b/scripts/run-hermetic-tests.sh index b859a054..6ea9cad3 100755 --- a/scripts/run-hermetic-tests.sh +++ b/scripts/run-hermetic-tests.sh @@ -42,12 +42,54 @@ run_scrubbed() { -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \ -u MAILERY_API_URL -u MAILERY_API_KEY \ -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \ + -u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \ -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \ -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \ + -u HASNA_EMAILS_DB_PATH -u HASNA_EMAILS_DATABASE_URL \ -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \ + -u EMAILS_SELF_HOSTED_HTTP_CONNECT_TIMEOUT \ + -u EMAILS_SELF_HOSTED_HTTP_TIMEOUT \ + -u EMAILS_SELF_HOSTED_HTTP_MAX_RESPONSE_BYTES \ -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \ -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \ + -u EMAILS_DATABASE_CA_FILE -u EMAILS_API_SIGNING_KEY \ -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \ + -u EMAILS_PG_POOL_MAX -u EMAILS_SEND_LEASE_SECONDS \ + -u EMAILS_SEND_PROVIDER \ + -u EMAILS_SES_ACCESS_KEY_ID -u EMAILS_SES_SECRET_ACCESS_KEY \ + -u EMAILS_SES_CONFIGURATION_SET -u EMAILS_SES_INBOUND_WEBHOOK_SECRET \ + -u EMAILS_SNS_TOPIC_ARN -u EMAILS_SNS_TOPIC_ARNS \ + -u EMAILS_AWS_ACCOUNT_ID -u EMAILS_AWS_ACCOUNT_IDS \ + -u EMAILS_INBOUND_S3_BUCKET -u EMAILS_INBOUND_WEBHOOK_SECRET \ + -u EMAILS_REQUIRE_SES_INBOUND_SECRET \ + -u EMAILS_INGEST_QUEUE_URL -u EMAILS_INGEST_S3_BUCKET \ + -u EMAILS_INGEST_S3_PREFIX -u EMAILS_INGEST_PREFIX_DOMAIN_MAP \ + -u EMAILS_INGEST_BACKFILL_LIMIT -u EMAILS_INGEST_BACKFILL_RECIPIENTS \ + -u EMAILS_ATTACHMENT_REPAIR_MANIFEST -u EMAILS_IMAGE_REVISION \ + -u EMAILS_MCP_HTTP_TOKEN -u EMAILS_MCP_ALLOWED_HOSTS \ + -u EMAILS_MCP_ALLOWED_ORIGINS -u MCP_HTTP_PORT \ + -u EMAILS_ALLOW_REMOTE -u EMAILS_DASHBOARD_ALLOWED_ORIGINS \ + -u HOST -u PORT -u USERPROFILE \ + -u EMAILS_PUBLIC_BASE_URL -u EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS \ + -u EMAILS_AUTH_FROM -u EMAILS_AUTH_PRODUCT_NAME \ + -u EMAILS_AUTH_VERIFY_URL_BASE -u EMAILS_AUTH_RESET_URL_BASE \ + -u EMAILS_AUTH_INVITE_URL_BASE \ + -u EMAILS_PRIMARY_SUPER_ADMIN_EMAIL \ + -u EMAILS_PRIMARY_SUPER_ADMIN_BOOTSTRAP_KID \ + -u EMAILS_EMAIL_VERIFY_TTL_HOURS -u EMAILS_INVITE_TTL_HOURS \ + -u EMAILS_RESET_TTL_MINUTES -u EMAILS_SESSION_IDLE_TTL_DAYS \ + -u EMAILS_SESSION_ABSOLUTE_TTL_DAYS -u EMAILS_TRUSTED_PROXY_HOPS \ + -u EMAILS_JSON_OUTPUT \ + -u EMAILS_TUI_THEME -u EMAILS_TUI_CLIPBOARD_COMMAND \ + -u EMAILS_TUI_CLIPBOARD_COMMAND_TIMEOUT_MS \ + -u EMAILS_TUI_CLIPBOARD_DRY_RUN -u EMAILS_TUI_CLIPBOARD_HOST \ + -u EMAILS_TUI_CLIPBOARD_SSH_HOSTS \ + -u EMAILS_TUI_CLIPBOARD_SSH_TIMEOUT -u EMAILS_TUI_CLIPBOARD_OSC52 \ + -u OTUI_USE_ALTERNATE_SCREEN \ + -u V1_STUB_ALLOWED_EMAIL_DOMAIN -u V1_STUB_API_KEY \ + -u V1_STUB_LIST_ORDER -u V1_STUB_RESOURCE_DEFAULTS \ + -u V1_STUB_RESOURCE_SPECS -u V1_STUB_SEED \ + -u FORCE_COLOR -u ECS_CONTAINER_METADATA_URI_V4 \ -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \ -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \ -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ @@ -60,6 +102,7 @@ run_scrubbed() { -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \ AWS_EC2_METADATA_DISABLED=true \ NO_COLOR=1 \ + PATH="$PATH" \ HOME="$test_home" \ EMAILS_MODE=local \ EMAILS_DB_PATH=:memory: \ diff --git a/src/cli/commands/auth.test.ts b/src/cli/commands/auth.test.ts index e4077296..396a6dff 100644 --- a/src/cli/commands/auth.test.ts +++ b/src/cli/commands/auth.test.ts @@ -12,6 +12,17 @@ import { startV1Stub, type V1Stub } from "../../test-support/v1-stub.js"; import { resetSelfHostedConfigCache } from "../../db/self-hosted-store.js"; import { registerAuthCommands } from "./auth.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + let stub: V1Stub; async function runAuth(args: string[]) { @@ -57,6 +68,7 @@ beforeAll(async () => { }); afterAll(() => stub.stop()); beforeEach(async () => { + captureInheritedProcessEnv(); await stub.reset(); stub.applyEnv(); clearSessionEnv(); @@ -64,6 +76,7 @@ beforeEach(async () => { afterEach(() => { clearSessionEnv(); stub.clearEnv(); + restoreInheritedProcessEnv(); }); async function signupAndVerify(email: string, password: string, org: string, slug: string): Promise { diff --git a/src/cli/commands/aws.test.ts b/src/cli/commands/aws.test.ts index e8f7ae41..dd2f9a63 100644 --- a/src/cli/commands/aws.test.ts +++ b/src/cli/commands/aws.test.ts @@ -2,7 +2,7 @@ // is server-side orchestration with no /v1 equivalent, so it now fails loud with // the server-only message. `aws status` still runs locally against the SES API // (mocked here), so it keeps a positive test. No local SQLite exists anymore. -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; import { Command } from "commander"; const mockSesSend = mock(async (_cmd: unknown) => ({}) as Record); @@ -29,6 +29,17 @@ mock.module("@aws-sdk/client-s3", () => ({ const { registerAwsCommands } = await import("./aws.js"); +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + async function runAws(args: string[]) { const lines: string[] = []; const originalLog = console.log; @@ -65,6 +76,7 @@ async function runAwsExpectingExit(args: string[]) { } beforeEach(() => { + captureInheritedProcessEnv(); mockSesSend.mockReset(); mockS3Send.mockReset(); mockS3Send.mockImplementation(async () => ({})); @@ -86,6 +98,7 @@ beforeEach(() => { afterEach(() => { delete process.env["AWS_PROFILE"]; + restoreInheritedProcessEnv(); }); describe("aws status command", () => { diff --git a/src/cli/commands/daemon.local.test.ts b/src/cli/commands/daemon.local.test.ts index 0bc48b27..4ce82baa 100644 --- a/src/cli/commands/daemon.local.test.ts +++ b/src/cli/commands/daemon.local.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { Command } from "commander"; import { closeDatabase, getDatabase, resetDatabase } from "../../db/database.js"; import { createDomain } from "../../db/domains.local.js"; @@ -6,6 +6,17 @@ import { createProvider } from "../../db/providers.local.js"; import { setDomainProvisioning } from "../../db/provisioning.local.js"; import { registerDaemonCommands } from "./daemon.local.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + async function runDaemonCommand(args: string[]) { const program = new Command(); program.exitOverride(); @@ -20,6 +31,7 @@ async function runDaemonCommand(args: string[]) { } beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); }); @@ -27,6 +39,7 @@ beforeEach(() => { afterEach(() => { closeDatabase(); delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); describe("daemon commands", () => { diff --git a/src/cli/commands/email-log.local.test.ts b/src/cli/commands/email-log.local.test.ts index 371099d5..49328c6d 100644 --- a/src/cli/commands/email-log.local.test.ts +++ b/src/cli/commands/email-log.local.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; import { Command } from "commander"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -12,6 +12,17 @@ import { createAddress, markVerified } from "../../db/addresses.local.js"; import { setConfigValue } from "../../lib/config.js"; import { registerEmailLogCommands } from "./email-log.local.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + function setupDb() { resetDatabase(); process.env["EMAILS_DB_PATH"] = ":memory:"; @@ -68,12 +79,14 @@ async function runEmailLogCommand(args: string[]) { } beforeEach(() => { + captureInheritedProcessEnv(); setupDb(); }); afterEach(() => { closeDatabase(); delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); describe("email log list and search commands", () => { diff --git a/src/cli/commands/inbox.test.ts b/src/cli/commands/inbox.test.ts index f1a2e5f8..7fb1be6f 100644 --- a/src/cli/commands/inbox.test.ts +++ b/src/cli/commands/inbox.test.ts @@ -31,6 +31,13 @@ let attachmentInventoryPages = new Map[0]>; // Seed through the REAL inbound repo (POST /v1/messages). Call AFTER applyEnv(). @@ -1355,6 +1362,7 @@ describe("inbox attachment", () => { return Response.json({ error: "not found" }, { status: 404 }); }, }); + const inheritedProcessEnv = { ...process.env }; try { process.env.EMAILS_MODE = "self_hosted"; @@ -1396,7 +1404,9 @@ describe("inbox attachment", () => { expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("two"); } finally { legacyServer.stop(true); - stub.applyEnv(); + restoreProcessEnv(inheritedProcessEnv); + resetSelfHostedConfigCache(); + resetMailDataSource(); rmSync(dir, { recursive: true, force: true }); } }); diff --git a/src/cli/commands/misc.local.test.ts b/src/cli/commands/misc.local.test.ts index cf225977..24b08ce9 100644 --- a/src/cli/commands/misc.local.test.ts +++ b/src/cli/commands/misc.local.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { Command } from "commander"; import { closeDatabase, getDatabase, resetDatabase } from "../../db/database.js"; import { createProvider } from "../../db/providers.local.js"; @@ -8,7 +8,19 @@ import { createTemplate } from "../../db/templates.local.js"; import { addStep, createSequence, enroll, listEnrollments } from "../../db/sequences.local.js"; import { registerMiscCommands, runSchedulerTick } from "./misc.local.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); }); @@ -16,6 +28,7 @@ beforeEach(() => { afterEach(() => { closeDatabase(); delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); async function runMiscCommand(args: string[]) { diff --git a/src/cli/commands/send-suppression.test.ts b/src/cli/commands/send-suppression.test.ts index e0774469..d395dbde 100644 --- a/src/cli/commands/send-suppression.test.ts +++ b/src/cli/commands/send-suppression.test.ts @@ -20,6 +20,17 @@ import { resetMailDataSource } from "../../lib/mail-data-source.js"; import { startV1Stub, type V1Stub } from "../../test-support/v1-stub.js"; import { registerSendCommands } from "./send.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + interface RunResult { consoleOutput: string; errorOutput: string; @@ -69,6 +80,7 @@ describe("emails send — suppressed recipients (self-hosted)", () => { afterAll(() => stub.stop()); beforeEach(async () => { + captureInheritedProcessEnv(); await stub.seed({ contacts: [{ id: "c1", email: "blocked@ext.com", name: null, suppressed: true, send_count: 0 }] }); stub.applyEnv(); resetMailDataSource(); @@ -77,6 +89,7 @@ describe("emails send — suppressed recipients (self-hosted)", () => { afterEach(() => { stub.clearEnv(); resetMailDataSource(); + restoreInheritedProcessEnv(); }); it("refuses the send instead of mailing a suppressed recipient", async () => { @@ -144,6 +157,7 @@ describe("emails send — suppressed recipients (local)", () => { let providerId: string; beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_MODE"] = "local"; process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); @@ -157,6 +171,7 @@ describe("emails send — suppressed recipients (local)", () => { resetMailDataSource(); delete process.env["EMAILS_MODE"]; delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); it("refuses the send instead of mailing a suppressed recipient", async () => { @@ -199,6 +214,7 @@ describe("suppression matches the recipient canonically, not by exact string", ( let providerId: string; beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_MODE"] = "local"; process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); @@ -211,6 +227,7 @@ describe("suppression matches the recipient canonically, not by exact string", ( resetMailDataSource(); delete process.env["EMAILS_MODE"]; delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); // `contacts.email` has no COLLATE NOCASE and nothing canonicalized either @@ -273,6 +290,7 @@ describe("reply, forward, and the MCP send tool refuse suppressed recipients too let providerId: string; beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_MODE"] = "local"; process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); @@ -286,6 +304,7 @@ describe("reply, forward, and the MCP send tool refuse suppressed recipients too resetMailDataSource(); delete process.env["EMAILS_MODE"]; delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); async function seedInbound(): Promise { diff --git a/src/cli/tui/App.test.tsx b/src/cli/tui/App.test.tsx index f8571631..59037e2d 100644 --- a/src/cli/tui/App.test.tsx +++ b/src/cli/tui/App.test.tsx @@ -24,6 +24,17 @@ import { App } from "../tui-solid/App.js"; import { resolveAddressChoice } from "../tui-solid/context/emails-state.js"; import { startV1Stub, type V1Stub } from "../../test-support/v1-stub.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + let stub: V1Stub; let savedHome: string | undefined; let tmpHome = ""; @@ -58,6 +69,7 @@ beforeAll(async () => { afterAll(() => stub.stop()); beforeEach(async () => { + captureInheritedProcessEnv(); process.env["EMAILS_TUI_DISABLE_THEME_PROBE"] = "1"; process.env["EMAILS_TUI_CLIPBOARD_DRY_RUN"] = "1"; savedHome = process.env["HOME"]; @@ -80,6 +92,7 @@ afterEach(() => { if (savedHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = savedHome; rmSync(tmpHome, { recursive: true, force: true }); + restoreInheritedProcessEnv(); }); function seedMessage( diff --git a/src/db/database-concurrent-migration.regression.test.ts b/src/db/database-concurrent-migration.regression.test.ts index fd796d29..6c2543b0 100644 --- a/src/db/database-concurrent-migration.regression.test.ts +++ b/src/db/database-concurrent-migration.regression.test.ts @@ -15,7 +15,7 @@ // second process because the defect only exists BETWEEN connections — a // single-connection test cannot reach it, which is exactly why the rest of the // suite missed it. -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { Database } from "bun:sqlite"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -23,7 +23,11 @@ import { join } from "node:path"; import { closeDatabase, getDatabase, resetDatabase } from "./database.ts"; const tempDirs: string[] = []; -const previousDbPath = process.env["EMAILS_DB_PATH"]; +let previousDbPath: string | undefined; + +beforeEach(() => { + previousDbPath = process.env["EMAILS_DB_PATH"]; +}); afterEach(() => { closeDatabase(); diff --git a/src/db/database.test.ts b/src/db/database.test.ts index c9229bf6..33ea6e5e 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -1,11 +1,23 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test"; import { chmodSync, existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getDatabase, closeDatabase, resetDatabase, uuid, now, resolvePartialId, resolvePartialIdOrThrow, listPartialIdMatches, runInTransaction } from "./database.js"; import { sqlEmailAddress, sqlEmailDomain } from "./email-address-sql.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); }); @@ -13,6 +25,7 @@ beforeEach(() => { afterEach(() => { closeDatabase(); delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); describe("getDatabase", () => { diff --git a/src/db/emails.self-hosted.test.ts b/src/db/emails.self-hosted.test.ts index b306af76..77deffdb 100644 --- a/src/db/emails.self-hosted.test.ts +++ b/src/db/emails.self-hosted.test.ts @@ -17,6 +17,17 @@ import { getEmail, resolveEmailId } from "./emails.js"; import { getEmailContent } from "./email-content.js"; import { resetSelfHostedConfigCache } from "./self-hosted-store.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const API_KEY = "hasna_emails_test_key_emails_1234567890"; let serverProc: ReturnType | null = null; let serverDir = ""; @@ -115,6 +126,7 @@ afterAll(() => { describe("emails repo — selfHosted (self_hosted) routing", () => { beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; process.env["EMAILS_MODE"] = "self_hosted"; process.env["EMAILS_SELF_HOSTED_URL"] = baseOrigin; @@ -126,6 +138,7 @@ describe("emails repo — selfHosted (self_hosted) routing", () => { delete process.env["EMAILS_SELF_HOSTED_URL"]; delete process.env["EMAILS_SELF_HOSTED_API_KEY"]; resetSelfHostedConfigCache(); + restoreInheritedProcessEnv(); }); it("getEmail reads a message from the selfHosted API by full id", async () => { diff --git a/src/db/retired-legacy-inbound-identity.test.ts b/src/db/retired-legacy-inbound-identity.test.ts index 62ebf321..d27eb92f 100644 --- a/src/db/retired-legacy-inbound-identity.test.ts +++ b/src/db/retired-legacy-inbound-identity.test.ts @@ -11,13 +11,24 @@ // in-memory database is a fresh database on every open and cannot observe a // resurrection at all. -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { closeDatabase, getDatabase, now, resetDatabase, uuid } from "./database.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const RETIRED_ADDRESS = "legacy-inbound@local.mailery"; const RETIRED_MAILBOX_ID = `mbx:${RETIRED_ADDRESS}`; const CURRENT_MAILBOX_ID = "mbx:legacy-inbound@local.emails"; @@ -30,6 +41,7 @@ let root: string; let path: string; beforeEach(() => { + captureInheritedProcessEnv(); root = mkdtempSync(join(tmpdir(), "emails-retired-identity-")); path = join(root, "emails.db"); closeDatabase(); @@ -42,6 +54,7 @@ afterEach(() => { resetDatabase(); delete process.env["EMAILS_DB_PATH"]; rmSync(root, { recursive: true, force: true }); + restoreInheritedProcessEnv(); }); /** Inbound mail whose `to` header has no parseable recipient. */ diff --git a/src/db/self-hosted-resource-paging.test.ts b/src/db/self-hosted-resource-paging.test.ts index ce374ecc..22ba968f 100644 --- a/src/db/self-hosted-resource-paging.test.ts +++ b/src/db/self-hosted-resource-paging.test.ts @@ -24,6 +24,17 @@ import { selfHostedListQuery, selfHostedPage } from "./self-hosted-resource.loca // their mode gate — is only reachable from here. import { listProviders } from "./providers.local.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + // Five providers, newest first once the repo layer sorts by created_at DESC: // p5, p4, p3, p2, p1. const SERVER_CODE = ` @@ -77,6 +88,7 @@ beforeAll(async () => { afterAll(() => proc?.kill()); beforeEach(() => { + captureInheritedProcessEnv(); process.env.EMAILS_MODE = "self_hosted"; process.env.EMAILS_SELF_HOSTED_URL = baseUrl; process.env.EMAILS_SELF_HOSTED_API_KEY = "test_key"; @@ -88,6 +100,7 @@ afterEach(() => { delete process.env.EMAILS_SELF_HOSTED_URL; delete process.env.EMAILS_SELF_HOSTED_API_KEY; resetSelfHostedConfigCache(); + restoreInheritedProcessEnv(); }); describe("selfHostedListQuery windows the page exactly once", () => { diff --git a/src/db/self-hosted-resource-routing.test.ts b/src/db/self-hosted-resource-routing.test.ts index ef0aec25..41ddac5f 100644 --- a/src/db/self-hosted-resource-routing.test.ts +++ b/src/db/self-hosted-resource-routing.test.ts @@ -16,6 +16,17 @@ import { listProviderSummaries } from "./providers.js"; import { listScheduledEmails } from "./scheduled.js"; import { listEmails, searchEmails } from "./emails.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const SERVER_CODE = ` const server = Bun.serve({ port: 0, fetch(req) { const p = new URL(req.url).pathname; @@ -58,6 +69,7 @@ beforeAll(async () => { afterAll(() => proc?.kill()); beforeEach(() => { + captureInheritedProcessEnv(); process.env.EMAILS_MODE = "self_hosted"; process.env.EMAILS_SELF_HOSTED_URL = baseUrl; process.env.EMAILS_SELF_HOSTED_API_KEY = "test_key"; @@ -69,6 +81,7 @@ afterEach(() => { delete process.env.EMAILS_SELF_HOSTED_URL; delete process.env.EMAILS_SELF_HOSTED_API_KEY; resetSelfHostedConfigCache(); + restoreInheritedProcessEnv(); }); describe("resource repos route reads to selfHosted in selfHosted mode", () => { diff --git a/src/db/self-hosted-resource-writes.test.ts b/src/db/self-hosted-resource-writes.test.ts index 97fb4b24..d6f8bbec 100644 --- a/src/db/self-hosted-resource-writes.test.ts +++ b/src/db/self-hosted-resource-writes.test.ts @@ -19,6 +19,17 @@ import { createSendKey } from "./send-keys.js"; import { createTemplate, listTemplates, getTemplate, deleteTemplate } from "./templates.js"; import { createSequence, listSequences } from "./sequences.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const SERVER_CODE = ` const owners = []; const groups = []; @@ -129,6 +140,7 @@ beforeAll(async () => { afterAll(() => proc?.kill()); beforeEach(() => { + captureInheritedProcessEnv(); process.env.EMAILS_MODE = "self_hosted"; process.env.EMAILS_SELF_HOSTED_URL = baseUrl; process.env.EMAILS_SELF_HOSTED_API_KEY = "test_key"; @@ -140,6 +152,7 @@ afterEach(() => { delete process.env.EMAILS_SELF_HOSTED_URL; delete process.env.EMAILS_SELF_HOSTED_API_KEY; resetSelfHostedConfigCache(); + restoreInheritedProcessEnv(); }); describe("resource repos route writes to selfHosted in selfHosted mode", () => { diff --git a/src/db/self-hosted-store.test.ts b/src/db/self-hosted-store.test.ts index 68155a2c..ba05adc2 100644 --- a/src/db/self-hosted-store.test.ts +++ b/src/db/self-hosted-store.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { SelfHostedTransportError, selfHostedApiRequest, @@ -14,6 +14,19 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +let ORIGINAL_PATH: string | undefined; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; + ORIGINAL_PATH = process.env["PATH"]; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const KEYS = [ "EMAILS_MODE", "HASNA_EMAILS_MODE", @@ -39,7 +52,6 @@ const KEYS = [ "AWS_PROFILE", "CLOUDFLARE_API_KEY", ]; -const ORIGINAL_PATH = process.env["PATH"]; let tempDirs: string[] = []; function clearEnv(): void { @@ -100,8 +112,14 @@ printf '\\n%s' "$STATUS" } describe("Emails self-hosted client resolver", () => { - beforeEach(clearEnv); - afterEach(clearEnv); + beforeEach(() => { + captureInheritedProcessEnv(); + clearEnv(); + }); + afterEach(() => { + clearEnv(); + restoreInheritedProcessEnv(); + }); test("unset env selects local and direct self-hosted resolution fails loud", () => { expect(isSelfHostedMode()).toBe(false); diff --git a/src/lib/agent-context.local.test.ts b/src/lib/agent-context.local.test.ts index 5fa0179b..f951ad78 100644 --- a/src/lib/agent-context.local.test.ts +++ b/src/lib/agent-context.local.test.ts @@ -19,7 +19,7 @@ // — a command that throws notImplementedAnywhere() in every mode. Advice that // refuses is the same defect class as a fabricated count. -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { closeDatabase, getDatabase, resetDatabase } from "../db/database.js"; import { createProvider } from "../db/providers.local.js"; import { createDomain } from "../db/domains.local.js"; @@ -30,10 +30,22 @@ import { statusGapClass } from "./status-availability.js"; import { isCommandAvailableInMode } from "./status-commands.js"; import { cliRefusalFor } from "../test-support/cli-refusals.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const SELF_HOSTED_ENV = ["EMAILS_SELF_HOSTED_URL", "EMAILS_SELF_HOSTED_API_KEY", "EMAILS_CLIENT_ENV_SECRET"] as const; const saved = new Map(); beforeEach(() => { + captureInheritedProcessEnv(); for (const key of SELF_HOSTED_ENV) { saved.set(key, process.env[key]); delete process.env[key]; @@ -52,6 +64,7 @@ afterEach(() => { if (value === undefined) delete process.env[key]; else process.env[key] = value; } + restoreInheritedProcessEnv(); }); function seed(): void { diff --git a/src/lib/client-env.test.ts b/src/lib/client-env.test.ts index 5c2007f9..b1925bb7 100644 --- a/src/lib/client-env.test.ts +++ b/src/lib/client-env.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,8 +10,21 @@ import { persistClientEnvSessionToken, } from "./client-env.js"; -const ORIGINAL_PATH = process.env["PATH"]; -const ORIGINAL_HOME = process.env["HOME"]; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +let ORIGINAL_PATH: string | undefined; +let ORIGINAL_HOME: string | undefined; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; + ORIGINAL_PATH = process.env["PATH"]; + ORIGINAL_HOME = process.env["HOME"]; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const ENV_KEYS = [ "EMAILS_MODE", "HASNA_EMAILS_MODE", @@ -109,8 +122,14 @@ exit 2 return storePath; } -beforeEach(resetEnv); -afterEach(resetEnv); +beforeEach(() => { + captureInheritedProcessEnv(); + resetEnv(); +}); +afterEach(() => { + resetEnv(); + restoreInheritedProcessEnv(); +}); describe("Emails client-env loader", () => { it("runs secrets get with a scrubbed environment", () => { diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 47bbd49d..3199a066 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test"; import { chmodSync, mkdirSync, rmSync, existsSync, statSync, writeFileSync } from "fs"; import { join } from "path"; import { @@ -14,9 +14,21 @@ import { } from "./config.js"; import { resetSelfHostedConfigCache } from "../db/self-hosted-store.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +let origHome: string | undefined; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; + origHome = process.env.HOME; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + // Use a temp dir unique per test run to isolate from real ~/.hasna/emails const TMP_HOME = join("/tmp", `emails-config-test-${process.pid}`); -const origHome = process.env.HOME; // Endpoint credentials alone never select self_hosted mode. Tests explicitly // set EMAILS_MODE=self_hosted when exercising the remote attachment policy. @@ -24,6 +36,7 @@ const SELF_HOSTED_URL = "https://emails.config.test"; const SELF_HOSTED_KEY = "config-test-api-key"; beforeEach(() => { + captureInheritedProcessEnv(); mkdirSync(TMP_HOME, { recursive: true }); process.env.HOME = TMP_HOME; process.env.EMAILS_SELF_HOSTED_URL = SELF_HOSTED_URL; @@ -39,6 +52,7 @@ afterEach(() => { delete process.env.EMAILS_SELF_HOSTED_URL; delete process.env.EMAILS_SELF_HOSTED_API_KEY; resetSelfHostedConfigCache(); + restoreInheritedProcessEnv(); }); describe("config", () => { diff --git a/src/lib/doctor.test.ts b/src/lib/doctor.test.ts index 7202c7e5..c73f90ff 100644 --- a/src/lib/doctor.test.ts +++ b/src/lib/doctor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,6 +6,17 @@ import { runDiagnostics, formatDiagnostics } from "./doctor.js"; import type { DoctorCheck } from "./doctor.js"; import { resetSelfHostedConfigCache } from "../db/self-hosted-store.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + // This client is self-hosted-ONLY: runDiagnostics no longer opens a local SQLite // database or counts providers/domains/addresses/contacts/templates (those live // behind the operator's /v1 API and its own /health + /ready probes). It only @@ -44,6 +55,7 @@ function configureSelfHosted(): void { } beforeEach(() => { + captureInheritedProcessEnv(); previousHome = process.env["HOME"]; tempHome = mkdtempSync(join(tmpdir(), "emails-doctor-test-home-")); process.env["HOME"] = tempHome; @@ -59,6 +71,7 @@ afterEach(() => { if (tempHome) rmSync(tempHome, { recursive: true, force: true }); tempHome = undefined; previousHome = undefined; + restoreInheritedProcessEnv(); }); describe("runDiagnostics", () => { diff --git a/src/lib/inbound-config.test.ts b/src/lib/inbound-config.test.ts index 320ae2de..babbd75e 100644 --- a/src/lib/inbound-config.test.ts +++ b/src/lib/inbound-config.test.ts @@ -1,23 +1,42 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test"; import { mkdirSync, rmSync, existsSync } from "fs"; import { join } from "path"; import { getInboundConfig, setConfigValue } from "./config.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +let origHome: string | undefined; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; + origHome = process.env.HOME; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const TMP_HOME = join("/tmp", `emails-inbound-cfg-test-${process.pid}`); -const origHome = process.env.HOME; -beforeEach(() => { mkdirSync(TMP_HOME, { recursive: true }); process.env.HOME = TMP_HOME; }); +beforeEach(() => { + captureInheritedProcessEnv(); + mkdirSync(TMP_HOME, { recursive: true }); + process.env.HOME = TMP_HOME; +}); afterEach(() => { - process.env.HOME = origHome; + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; if (existsSync(TMP_HOME)) rmSync(TMP_HOME, { recursive: true, force: true }); delete process.env["EMAILS_INBOUND_S3_BUCKET"]; + restoreInheritedProcessEnv(); }); describe("getInboundConfig", () => { it("defaults region to us-east-1", () => { const origRegion = process.env["AWS_REGION"]; delete process.env["AWS_REGION"]; expect(getInboundConfig().region).toBe("us-east-1"); - if (origRegion) process.env["AWS_REGION"] = origRegion; + if (origRegion === undefined) delete process.env["AWS_REGION"]; + else process.env["AWS_REGION"] = origRegion; }); it("reads bucket from env when no config value", () => { process.env["EMAILS_INBOUND_S3_BUCKET"] = "b1"; diff --git a/src/lib/local-mail-data-source.test.ts b/src/lib/local-mail-data-source.test.ts index aacc53d8..2ecac296 100644 --- a/src/lib/local-mail-data-source.test.ts +++ b/src/lib/local-mail-data-source.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,6 +8,17 @@ import { getSandboxCount } from "../db/sandbox.local.js"; import { storeInboundEmail } from "../db/inbound.local.js"; import { resetMailDataSource, resolveMailDataSource, SqliteMailDataSource } from "./mail-data-source.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const attachmentDirs: string[] = []; function clearMailModeEnv(): void { @@ -36,6 +47,7 @@ function clearMailModeEnv(): void { } beforeEach(() => { + captureInheritedProcessEnv(); clearMailModeEnv(); process.env["EMAILS_MODE"] = "local"; process.env["EMAILS_DB_PATH"] = ":memory:"; @@ -50,6 +62,7 @@ afterEach(() => { clearMailModeEnv(); delete process.env["EMAILS_DB_PATH"]; for (const dir of attachmentDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + restoreInheritedProcessEnv(); }); function seedInbound() { diff --git a/src/lib/mode.test.ts b/src/lib/mode.test.ts index b4464408..52eb016b 100644 --- a/src/lib/mode.test.ts +++ b/src/lib/mode.test.ts @@ -1,7 +1,7 @@ // Dual-mode resolver contract: local is the safe default and never loads remote // credentials; self_hosted is explicit and fails closed without URL + credential. -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { resetSelfHostedConfigCache } from "../db/self-hosted-store.js"; @@ -18,9 +18,22 @@ import { } from "./mode.js"; import { saveConfig } from "./config.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +let ORIGINAL_HOME: string | undefined; +let ORIGINAL_PATH: string | undefined; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; + ORIGINAL_HOME = process.env["HOME"]; + ORIGINAL_PATH = process.env["PATH"]; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const TMP_HOME = join("/tmp", `emails-mode-test-${process.pid}`); -const ORIGINAL_HOME = process.env["HOME"]; -const ORIGINAL_PATH = process.env["PATH"]; const ENV_KEYS = [ EMAILS_MODE_ENV, @@ -93,6 +106,7 @@ exit 42 } beforeEach(() => { + captureInheritedProcessEnv(); mkdirSync(TMP_HOME, { recursive: true }); process.env["HOME"] = TMP_HOME; for (const key of ENV_KEYS) delete process.env[key]; @@ -109,6 +123,7 @@ afterEach(() => { else process.env["PATH"] = ORIGINAL_PATH; if (existsSync(TMP_HOME)) rmSync(TMP_HOME, { recursive: true, force: true }); resetSelfHostedConfigCache(); + restoreInheritedProcessEnv(); }); describe("normalizeEmailsMode", () => { diff --git a/src/lib/s3-sync.test.ts b/src/lib/s3-sync.test.ts index 4ec08fe2..a3d06725 100644 --- a/src/lib/s3-sync.test.ts +++ b/src/lib/s3-sync.test.ts @@ -17,10 +17,11 @@ import { s3SyncLocalTestBoundary } from "./s3-sync.local.js"; // local config file with no database dependency, so it remains functional and is // covered here. -const originalHome = process.env["HOME"]; +let originalHome: string | undefined; let tmpHome = ""; beforeEach(() => { + originalHome = process.env["HOME"]; tmpHome = mkdtempSync(join(tmpdir(), "emails-s3-source-")); process.env["HOME"] = tmpHome; }); diff --git a/src/lib/self-hosted-mail-data-source.test.ts b/src/lib/self-hosted-mail-data-source.test.ts index 261f9a64..1e3005f0 100644 --- a/src/lib/self-hosted-mail-data-source.test.ts +++ b/src/lib/self-hosted-mail-data-source.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { SelfHostedMailDataSource, type SelfHostedFetch, @@ -11,6 +11,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHash } from "node:crypto"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const LEGACY_ENV_KEYS = [ "MAILERY_MODE", "HASNA_MAILERY_MODE", @@ -441,6 +452,7 @@ function make( } beforeEach(() => { + captureInheritedProcessEnv(); clearModeEnv(); }); @@ -448,6 +460,7 @@ afterEach(() => { resetMailDataSource(); resetSelfHostedConfigCache(); clearModeEnv(); + restoreInheritedProcessEnv(); }); describe("SelfHostedMailDataSource — /v1 resource mapping", () => { diff --git a/src/mcp/domain-address-self-hosted.test.ts b/src/mcp/domain-address-self-hosted.test.ts index 4447b657..ee477988 100644 --- a/src/mcp/domain-address-self-hosted.test.ts +++ b/src/mcp/domain-address-self-hosted.test.ts @@ -30,8 +30,8 @@ const ENV_KEYS = [ "HASNA_MAILERY_ENV_FILE", ] as const; -const ORIGINAL_HOME = process.env["HOME"]; -const ORIGINAL_ENV = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); +let ORIGINAL_HOME: string | undefined; +let ORIGINAL_ENV = new Map(); let tempHome: string | null = null; let apiServer: ReturnType | null = null; @@ -145,6 +145,8 @@ function parseResult(result: { content: Array<{ text: string }> }): T { } beforeEach(async () => { + ORIGINAL_HOME = process.env["HOME"]; + ORIGINAL_ENV = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); resetEnv(); tempHome = mkdtempSync(join(tmpdir(), "emails-mcp-domain-address-self-hosted-")); process.env["HOME"] = tempHome; diff --git a/src/mcp/http.test.ts b/src/mcp/http.test.ts index 8fbefef8..a831c214 100644 --- a/src/mcp/http.test.ts +++ b/src/mcp/http.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resetSelfHostedConfigCache } from "../db/self-hosted-store.js"; +import { resetMailDataSource } from "../lib/mail-data-source.js"; import { mcpTestRequestInit, MCP_TEST_HTTP_TOKEN, startTestMcpHttpServer } from "../test-support/mcp-http.js"; import { startV1Stub, type V1Stub } from "../test-support/v1-stub.js"; @@ -25,6 +26,13 @@ const { DEFAULT_MCP_HTTP_PORT, MCP_HTTP_ALLOWED_HOSTS_ENV, MCP_HTTP_TOKEN_ENV, M const servers: Array> = []; let stub: V1Stub; +function restoreProcessEnv(inherited: NodeJS.ProcessEnv): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(inherited, key)) delete process.env[key]; + } + Object.assign(process.env, inherited); +} + async function withClient(name: string, run: (client: InstanceType) => Promise): Promise { const server = startTestMcpHttpServer(); servers.push(server); @@ -211,6 +219,7 @@ describe("emails-mcp HTTP transport", () => { }, }); servers.push(inventoryServer); + const inheritedProcessEnv = { ...process.env }; process.env.EMAILS_MODE = "self_hosted"; process.env.EMAILS_SELF_HOSTED_URL = `http://127.0.0.1:${inventoryServer.port}`; process.env.EMAILS_SELF_HOSTED_API_KEY = "attachment-inventory-http-test-key"; @@ -271,8 +280,9 @@ describe("emails-mcp HTTP transport", () => { expect(requests[0]?.searchParams.get("since")).toBe("2026-07-24T08:00:00.000Z"); }); } finally { - stub.applyEnv(); + restoreProcessEnv(inheritedProcessEnv); resetSelfHostedConfigCache(); + resetMailDataSource(); } }); @@ -305,6 +315,7 @@ describe("emails-mcp HTTP transport", () => { }, }); servers.push(inventoryServer); + const inheritedProcessEnv = { ...process.env }; process.env.EMAILS_MODE = "self_hosted"; process.env.EMAILS_SELF_HOSTED_URL = `http://127.0.0.1:${inventoryServer.port}`; process.env.EMAILS_SELF_HOSTED_API_KEY = "attachment-inventory-http-test-key"; @@ -328,8 +339,9 @@ describe("emails-mcp HTTP transport", () => { } }); } finally { - stub.applyEnv(); + restoreProcessEnv(inheritedProcessEnv); resetSelfHostedConfigCache(); + resetMailDataSource(); } }); diff --git a/src/mcp/local-mode.test.ts b/src/mcp/local-mode.test.ts index ca5f9119..70fe30ba 100644 --- a/src/mcp/local-mode.test.ts +++ b/src/mcp/local-mode.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { closeDatabase, resetDatabase } from "../db/database.js"; @@ -8,9 +8,21 @@ import { mcpTestRequestInit, startTestMcpHttpServer } from "../test-support/mcp- import { startHttpServer } from "./http.js"; import { buildServer } from "./server.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + let server: ReturnType | null = null; beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_MODE"] = "local"; process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); @@ -22,6 +34,7 @@ afterEach(() => { closeDatabase(); delete process.env["EMAILS_MODE"]; delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); describe("MCP local mode", () => { diff --git a/src/self-hosted-wire-regression.test.ts b/src/self-hosted-wire-regression.test.ts index 4825f207..b51a5953 100644 --- a/src/self-hosted-wire-regression.test.ts +++ b/src/self-hosted-wire-regression.test.ts @@ -1,5 +1,8 @@ -import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import type { Subprocess } from "bun"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import ts from "typescript"; import { SelfHostedHttpError, selfHostedStoreFor, @@ -27,6 +30,370 @@ import { ApiError, EmailsSelfHostClient } from "./selfhost.js"; const RESPONSE_SECRET_MARKER = "credential-like-response-body-must-not-leak"; type JsonSchema = Record; +interface EnvCleanupFinding { + file: string; + line: number; + target: string; +} + +function containsProcessEnvAccess(node: ts.Node): boolean { + let found = false; + const visit = (child: ts.Node): void => { + if ( + (ts.isExpression(child) && processEnvTarget(child) !== null) + || (ts.isExpression(child) && isProcessEnvExpression(child)) + ) { + found = true; + return; + } + ts.forEachChild(child, visit); + }; + visit(node); + return found; +} + +function processEnvTarget(expression: ts.Expression): string | null { + if ( + ts.isPropertyAccessExpression(expression) + && ts.isPropertyAccessExpression(expression.expression) + && ts.isIdentifier(expression.expression.expression) + && expression.expression.expression.text === "process" + && expression.expression.name.text === "env" + ) { + return expression.name.text; + } + if ( + ts.isElementAccessExpression(expression) + && ts.isPropertyAccessExpression(expression.expression) + && ts.isIdentifier(expression.expression.expression) + && expression.expression.expression.text === "process" + && expression.expression.name.text === "env" + ) { + if (expression.argumentExpression && ts.isStringLiteralLike(expression.argumentExpression)) { + return expression.argumentExpression.text; + } + return expression.argumentExpression?.getText() ?? "*"; + } + return null; +} + +function isProcessEnvExpression(expression: ts.Expression): boolean { + return ( + ts.isPropertyAccessExpression(expression) + && ts.isIdentifier(expression.expression) + && expression.expression.text === "process" + && expression.name.text === "env" + ); +} + +function containsMatchingEnvAssignment(node: ts.Node | undefined, target: string): boolean { + if (!node) return false; + let matched = false; + const visit = (child: ts.Node): void => { + if ( + ts.isBinaryExpression(child) + && child.operatorToken.kind === ts.SyntaxKind.EqualsToken + && processEnvTarget(child.left as ts.Expression) === target + ) { + matched = true; + return; + } + ts.forEachChild(child, visit); + }; + visit(node); + return matched; +} + +function isExactRestoreDelete(node: ts.DeleteExpression, cleanupRoot: ts.Node): boolean { + const target = processEnvTarget(node.expression); + if (!target) return true; + for (let current = node.parent; current && current !== cleanupRoot; current = current.parent) { + if ( + ts.isIfStatement(current) + && current.thenStatement.pos <= node.pos + && node.end <= current.thenStatement.end + && /\bundefined\b/.test(current.expression.getText()) + && containsMatchingEnvAssignment(current.elseStatement, target) + ) { + return true; + } + } + return false; +} + +function environmentCleanupFindings(sourceText: string, file = "fixture.test.ts"): EnvCleanupFinding[] { + const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + const functions = new Map(); + const cleanupRoots: ts.FunctionLikeDeclaration[] = []; + const beforeEachRoots: ts.FunctionLikeDeclaration[] = []; + const afterEachRoots: ts.FunctionLikeDeclaration[] = []; + + const registerFunctions = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name) functions.set(node.name.text, node); + if ( + ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.initializer + && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) { + functions.set(node.name.text, node.initializer); + } + if ( + ts.isCallExpression(node) + && ts.isIdentifier(node.expression) + && ( + node.expression.text === "beforeEach" + || node.expression.text === "afterEach" + || node.expression.text === "afterAll" + ) + ) { + const callback = node.arguments[0]; + if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) { + if (node.expression.text === "beforeEach") beforeEachRoots.push(callback); + else cleanupRoots.push(callback); + if (node.expression.text === "afterEach") afterEachRoots.push(callback); + } else if (callback && ts.isIdentifier(callback)) { + const referenced = functions.get(callback.text); + if (referenced) { + if (node.expression.text === "beforeEach") beforeEachRoots.push(referenced); + else cleanupRoots.push(referenced); + if (node.expression.text === "afterEach") afterEachRoots.push(referenced); + } + } + } + ts.forEachChild(node, registerFunctions); + }; + registerFunctions(source); + + const inheritedSnapshots = new Set(); + const visitedSnapshotFunctions = new Set(); + const registerSnapshotAssignments = (root: ts.FunctionLikeDeclaration): void => { + if (visitedSnapshotFunctions.has(root)) return; + visitedSnapshotFunctions.add(root); + const visit = (node: ts.Node): void => { + if ( + ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isIdentifier(node.left) + && ts.isObjectLiteralExpression(node.right) + && node.right.properties.some( + (property) => ts.isSpreadAssignment(property) && isProcessEnvExpression(property.expression), + ) + ) inheritedSnapshots.add(node.left.text); + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const referenced = functions.get(node.expression.text); + if (referenced) registerSnapshotAssignments(referenced); + } + ts.forEachChild(node, visit); + }; + if (root.body) visit(root.body); + }; + for (const root of beforeEachRoots) registerSnapshotAssignments(root); + + const hasWholeProcessEnvRestore = (root: ts.FunctionLikeDeclaration): boolean => { + let guardedDelete = false; + let assignedSnapshot = false; + const visitedFunctions = new Set(); + const visitFunction = (cleanup: ts.FunctionLikeDeclaration): void => { + if (visitedFunctions.has(cleanup)) return; + visitedFunctions.add(cleanup); + const visit = (node: ts.Node): void => { + if (ts.isDeleteExpression(node) && processEnvTarget(node.expression)) { + for (let current = node.parent; current && current !== cleanup; current = current.parent) { + if ( + ts.isIfStatement(current) + && [...inheritedSnapshots].some((name) => current.expression.getText().includes(name)) + ) { + guardedDelete = true; + break; + } + } + } + if ( + ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && ts.isIdentifier(node.expression.expression) + && node.expression.expression.text === "Object" + && node.expression.name.text === "assign" + && node.arguments[0] + && isProcessEnvExpression(node.arguments[0]) + && node.arguments[1] + && ts.isIdentifier(node.arguments[1]) + && inheritedSnapshots.has(node.arguments[1].text) + ) { + assignedSnapshot = true; + } + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const referenced = functions.get(node.expression.text); + if (referenced) visitFunction(referenced); + } + ts.forEachChild(node, visit); + }; + if (cleanup.body) visit(cleanup.body); + }; + visitFunction(root); + return guardedDelete && assignedSnapshot; + }; + const wholeProcessEnvRestoreRoots = new Set( + afterEachRoots.filter(hasWholeProcessEnvRestore), + ); + + const findings: EnvCleanupFinding[] = []; + const visited = new Set(); + const inspectCleanup = (cleanup: ts.FunctionLikeDeclaration): void => { + if (visited.has(cleanup)) return; + visited.add(cleanup); + const visit = (node: ts.Node): void => { + if (ts.isDeleteExpression(node)) { + const target = processEnvTarget(node.expression); + if (target && !isExactRestoreDelete(node, cleanup)) { + findings.push({ + file, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + target, + }); + } + } + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const referenced = functions.get(node.expression.text); + if (referenced) inspectCleanup(referenced); + } + ts.forEachChild(node, visit); + }; + if (cleanup.body) visit(cleanup.body); + }; + for (const cleanup of cleanupRoots) { + if (!wholeProcessEnvRestoreRoots.has(cleanup)) inspectCleanup(cleanup); + } + return findings; +} + +function moduleScopeEnvSnapshotFindings( + sourceText: string, + file = "fixture.test.ts", +): EnvCleanupFinding[] { + const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + let mutatesProcessEnv = false; + const detectMutation = (node: ts.Node): void => { + if ( + (ts.isDeleteExpression(node) && processEnvTarget(node.expression) !== null) + || ( + ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && processEnvTarget(node.left as ts.Expression) !== null + ) + ) { + mutatesProcessEnv = true; + } + ts.forEachChild(node, detectMutation); + }; + detectMutation(source); + if (!mutatesProcessEnv) return []; + + const findings: EnvCleanupFinding[] = []; + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!declaration.initializer || !containsProcessEnvAccess(declaration.initializer)) continue; + findings.push({ + file, + line: source.getLineAndCharacterOfPosition(declaration.getStart(source)).line + 1, + target: declaration.name.getText(source), + }); + } + } + return findings; +} + +function applyEnvInFinallyFindings( + sourceText: string, + file = "fixture.test.ts", +): EnvCleanupFinding[] { + const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + const findings: EnvCleanupFinding[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && node.expression.name.text === "applyEnv" + ) { + for (let current = node.parent; current; current = current.parent) { + if ( + ts.isBlock(current) + && ts.isTryStatement(current.parent) + && current.parent.finallyBlock === current + ) { + findings.push({ + file, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + target: node.expression.getText(source), + }); + break; + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return findings; +} + +function walkSourceFiles(root: string, includeTests: boolean): string[] { + const files: string[] = []; + const visit = (directory: string): void => { + for (const name of readdirSync(directory)) { + const path = join(directory, name); + const stat = statSync(path); + if (stat.isDirectory()) { + visit(path); + } else if (/\.[cm]?[jt]sx?$/.test(name)) { + const isTest = /(?:\.test|_test|\.spec|_spec)\./.test(name); + if (isTest === includeTests) files.push(path); + } + } + }; + visit(root); + return files.sort(); +} + +function activeRuntimeEnvKeys(repoRoot: string): string[] { + const keyPattern = + /["'`]((?:HASNA_)?(?:EMAILS|MAILERY)_[A-Z0-9_]+|AWS_[A-Z0-9_]+|CLOUDFLARE_[A-Z0-9_]+|RESEND_[A-Z0-9_]+|DATABASE_URL|MCP_HTTP_PORT|PORT|HOST|HOME|USERPROFILE|FORCE_COLOR|NO_COLOR|ECS_CONTAINER_METADATA_URI_V4)["'`]/g; + const keys = new Set(); + for (const path of walkSourceFiles(join(repoRoot, "src"), false)) { + const source = readFileSync(path, "utf8"); + for (const match of source.matchAll(keyPattern)) keys.add(match[1]!); + const syntax = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); + const visit = (node: ts.Node): void => { + if ( + ts.isPropertyAccessExpression(node) + && isProcessEnvExpression(node.expression) + ) { + keys.add(node.name.text); + } else if ( + ts.isElementAccessExpression(node) + && isProcessEnvExpression(node.expression) + && node.argumentExpression + && ts.isStringLiteralLike(node.argumentExpression) + ) { + keys.add(node.argumentExpression.text); + } + ts.forEachChild(node, visit); + }; + visit(syntax); + } + return [...keys].sort(); +} + +function hermeticHarnessEnvKeys(source: string): Set { + const keys = new Set(); + for (const match of source.matchAll(/(?:^|\s)-u\s+([A-Z][A-Z0-9_]*)/g)) keys.add(match[1]!); + for (const match of source.matchAll(/^\s*([A-Z][A-Z0-9_]*)=(?:"[^"]*"|'[^']*'|[^\s\\]+)\s*\\?$/gm)) { + keys.add(match[1]!); + } + return keys; +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -227,17 +594,15 @@ function applySelfHostedEnv(): void { // through the mode resolver to EMAILS_CLIENT_ENV_SECRET or the // on-disk config, which resolves to self_hosted and fails unrelated suites. // Restore the values this process started with instead. +const MODE_ENV_KEY = ["EMAILS", "MODE"].join("_"); const SELF_HOSTED_ENV_KEYS = [ - "EMAILS_MODE", + MODE_ENV_KEY, "EMAILS_SELF_HOSTED_URL", "EMAILS_SELF_HOSTED_API_KEY", "EMAILS_SELF_HOSTED_HTTP_MAX_RESPONSE_BYTES", ] as const; -// Captured at module load, before any test in this file mutates them. -const INHERITED_SELF_HOSTED_ENV: Record = Object.fromEntries( - SELF_HOSTED_ENV_KEYS.map((key) => [key, process.env[key]]), -); +let INHERITED_SELF_HOSTED_ENV: Record; function clearSelfHostedEnv(): void { for (const key of SELF_HOSTED_ENV_KEYS) { @@ -269,8 +634,115 @@ afterAll(() => { server?.kill(); }); +beforeEach(() => { + INHERITED_SELF_HOSTED_ENV = Object.fromEntries( + SELF_HOSTED_ENV_KEYS.map((key) => [key, process.env[key]]), + ); +}); afterEach(clearSelfHostedEnv); +describe("shared-process environment hygiene", () => { + test("rejects delete-to-undefined cleanup while accepting exact prior-value restoration", () => { + const unsafe = ` + import { afterEach } from "bun:test"; + afterEach(() => { + delete process.env.${MODE_ENV_KEY}; + }); + `; + expect(environmentCleanupFindings(unsafe)).toEqual([ + { file: "fixture.test.ts", line: 4, target: MODE_ENV_KEY }, + ]); + + const safe = ` + import { afterEach, beforeEach } from "bun:test"; + let inherited; + beforeEach(() => { + inherited = process.env.${MODE_ENV_KEY}; + }); + afterEach(() => { + if (inherited === undefined) delete process.env.${MODE_ENV_KEY}; + else process.env.${MODE_ENV_KEY} = inherited; + }); + `; + expect(environmentCleanupFindings(safe)).toEqual([]); + + const unsafeModuleSnapshot = ` + import { afterAll } from "bun:test"; + const inherited = { ...process.env }; + afterAll(() => { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(inherited, key)) delete process.env[key]; + } + Object.assign(process.env, inherited); + }); + `; + expect(environmentCleanupFindings(unsafeModuleSnapshot)).toEqual([ + { file: "fixture.test.ts", line: 6, target: "key" }, + ]); + expect(moduleScopeEnvSnapshotFindings(unsafeModuleSnapshot)).toEqual([ + { file: "fixture.test.ts", line: 3, target: "inherited" }, + ]); + + const unsafeStubRebaseline = ` + async function runWithTemporaryEndpoint() { + try { + await run(); + } finally { + stub.applyEnv(); + } + } + `; + expect(applyEnvInFinallyFindings(unsafeStubRebaseline)).toEqual([ + { file: "fixture.test.ts", line: 6, target: "stub.applyEnv" }, + ]); + + const mixedCleanup = ` + import { afterAll, afterEach, beforeEach } from "bun:test"; + let inherited; + beforeEach(() => { + inherited = { ...process.env }; + }); + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(inherited, key)) delete process.env[key]; + } + Object.assign(process.env, inherited); + }); + afterAll(() => { + delete process.env.${MODE_ENV_KEY}; + }); + `; + expect(environmentCleanupFindings(mixedCleanup)).toEqual([ + { file: "fixture.test.ts", line: 14, target: MODE_ENV_KEY }, + ]); + }); + + test("requires every cleanup hook to restore process.env instead of deleting inherited values", () => { + const repoRoot = join(import.meta.dir, ".."); + const findings: EnvCleanupFinding[] = []; + for (const path of walkSourceFiles(join(repoRoot, "src"), true)) { + const source = readFileSync(path, "utf8"); + findings.push( + ...environmentCleanupFindings(source, relative(repoRoot, path)), + ...moduleScopeEnvSnapshotFindings(source, relative(repoRoot, path)), + ...applyEnvInFinallyFindings(source, relative(repoRoot, path)), + ); + } + expect(findings).toEqual([]); + }); + + test("scrubs or fixes every active runtime environment input before the shared process starts", () => { + const repoRoot = join(import.meta.dir, ".."); + const harness = readFileSync(join(repoRoot, "scripts", "run-hermetic-tests.sh"), "utf8"); + const covered = hermeticHarnessEnvKeys(harness); + const missing = activeRuntimeEnvKeys(repoRoot).filter((key) => !covered.has(key)); + expect(missing).toEqual([]); + expect(covered).toContain("EMAILS_SELF_HOSTED_HTTP_CONNECT_TIMEOUT"); + expect(covered).toContain("EMAILS_SELF_HOSTED_HTTP_TIMEOUT"); + expect(covered).toContain("EMAILS_SELF_HOSTED_HTTP_MAX_RESPONSE_BYTES"); + }); +}); + describe("self-hosted successful-response wire contract", () => { test("generic stores reject malformed JSON instead of synthesizing an empty list", () => { applySelfHostedEnv(); diff --git a/src/server/routes/core-redaction.test.ts b/src/server/routes/core-redaction.test.ts index 2041920c..9059529c 100644 --- a/src/server/routes/core-redaction.test.ts +++ b/src/server/routes/core-redaction.test.ts @@ -1,8 +1,19 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { closeDatabase, getDatabase, resetDatabase } from "../../db/database.js"; import { createProvider } from "../../db/providers.local.js"; import { handle } from "./core.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + function call(path: string, init?: RequestInit) { const req = new Request(`http://127.0.0.1:3900${path}`, init); const url = new URL(req.url); @@ -10,6 +21,7 @@ function call(path: string, init?: RequestInit) { } beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); }); @@ -17,6 +29,7 @@ beforeEach(() => { afterEach(() => { closeDatabase(); delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); describe("core REST redaction", () => { diff --git a/src/server/routes/inbound-webhook.test.ts b/src/server/routes/inbound-webhook.test.ts index 3d4643aa..63587fd3 100644 --- a/src/server/routes/inbound-webhook.test.ts +++ b/src/server/routes/inbound-webhook.test.ts @@ -1,12 +1,24 @@ -import { afterEach, beforeEach, describe, it, expect } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, it, expect } from "bun:test"; import { handleInboundWebhook } from "./inbound-webhook.js"; import { setConfigValue } from "../../lib/config.js"; import { closeDatabase, resetDatabase } from "../../db/database.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:emails-inbound"; let snsSequence = 0; beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; process.env["EMAILS_SNS_TOPIC_ARNS"] = TOPIC_ARN; process.env["EMAILS_AWS_ACCOUNT_IDS"] = "123456789012"; @@ -18,6 +30,7 @@ afterEach(() => { delete process.env["EMAILS_DB_PATH"]; delete process.env["EMAILS_SNS_TOPIC_ARNS"]; delete process.env["EMAILS_AWS_ACCOUNT_IDS"]; + restoreInheritedProcessEnv(); }); const sesNotification = JSON.stringify({ diff --git a/src/server/routes/resend-webhook.test.ts b/src/server/routes/resend-webhook.test.ts index fb5ee375..a0ee06f4 100644 --- a/src/server/routes/resend-webhook.test.ts +++ b/src/server/routes/resend-webhook.test.ts @@ -1,12 +1,24 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test"; import { closeDatabase, resetDatabase, getDatabase } from "../../db/database.js"; import { createProvider } from "../../db/providers.local.js"; import { listInboundEmails } from "../../db/inbound.local.js"; import { handleResendWebhook } from "./resend-webhook.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + const SECRET = `whsec_${Buffer.from("resend-route-test-secret").toString("base64")}`; beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; process.env["RESEND_WEBHOOK_SECRET"] = SECRET; resetDatabase(); @@ -18,6 +30,7 @@ afterEach(() => { delete process.env["RESEND_WEBHOOK_SECRET"]; delete process.env["EMAILS_MODE"]; delete process.env["HASNA_EMAILS_DATABASE_URL"]; + restoreInheritedProcessEnv(); }); async function post(body: unknown, id = crypto.randomUUID()): Promise { diff --git a/src/server/routes/rest-parity.test.ts b/src/server/routes/rest-parity.test.ts index 9c128e7e..2570b9e1 100644 --- a/src/server/routes/rest-parity.test.ts +++ b/src/server/routes/rest-parity.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { createAddress } from "../../db/addresses.local.js"; import { suppressContact, upsertContact } from "../../db/contacts.local.js"; import { closeDatabase, getDatabase, resetDatabase } from "../../db/database.js"; @@ -16,6 +16,17 @@ import { createWarmingSchedule, updateWarmingStatus } from "../../db/warming.loc import { seedEmailAgentRun, seedTriage } from "../../test-support/legacy-mail-seed.js"; import { handleApiRequest } from "../api-routes.js"; +let INHERITED_PROCESS_ENV: NodeJS.ProcessEnv; +function captureInheritedProcessEnv(): void { + INHERITED_PROCESS_ENV = { ...process.env }; +} +function restoreInheritedProcessEnv(): void { + for (const key of Object.keys(process.env)) { + if (!Object.prototype.hasOwnProperty.call(INHERITED_PROCESS_ENV, key)) delete process.env[key]; + } + Object.assign(process.env, INHERITED_PROCESS_ENV); +} + async function call(path: string, init?: RequestInit): Promise { const req = new Request(`http://127.0.0.1:3900${path}`, init); const url = new URL(req.url); @@ -40,6 +51,7 @@ function postJson(path: string, body: unknown): RequestInit { } beforeEach(() => { + captureInheritedProcessEnv(); process.env["EMAILS_DB_PATH"] = ":memory:"; resetDatabase(); }); @@ -47,6 +59,7 @@ beforeEach(() => { afterEach(() => { closeDatabase(); delete process.env["EMAILS_DB_PATH"]; + restoreInheritedProcessEnv(); }); describe("emails serve REST parity smoke", () => { From 7370f6cb3f2ab8533e2c62cacf6877c560f53738 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 23:23:43 +0300 Subject: [PATCH 14/16] fix(guards): accept expanded hermetic env bridge --- scripts/no-cloud-scan-lib.mjs | 43 +++++++++++++++++++++++++ src/no-cloud-boundary.test.ts | 60 ++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/scripts/no-cloud-scan-lib.mjs b/scripts/no-cloud-scan-lib.mjs index 231a1aa9..416ec342 100644 --- a/scripts/no-cloud-scan-lib.mjs +++ b/scripts/no-cloud-scan-lib.mjs @@ -242,12 +242,54 @@ const exactLegacyHostedEnvUnsetBridges = new Map([ " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", " -u MAILERY_API_URL -u MAILERY_API_KEY \\", " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", + " -u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\", " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", + " -u HASNA_EMAILS_DB_PATH -u HASNA_EMAILS_DATABASE_URL \\", " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", + " -u EMAILS_SELF_HOSTED_HTTP_CONNECT_TIMEOUT \\", + " -u EMAILS_SELF_HOSTED_HTTP_TIMEOUT \\", + " -u EMAILS_SELF_HOSTED_HTTP_MAX_RESPONSE_BYTES \\", " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", + " -u EMAILS_DATABASE_CA_FILE -u EMAILS_API_SIGNING_KEY \\", " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", + " -u EMAILS_PG_POOL_MAX -u EMAILS_SEND_LEASE_SECONDS \\", + " -u EMAILS_SEND_PROVIDER \\", + " -u EMAILS_SES_ACCESS_KEY_ID -u EMAILS_SES_SECRET_ACCESS_KEY \\", + " -u EMAILS_SES_CONFIGURATION_SET -u EMAILS_SES_INBOUND_WEBHOOK_SECRET \\", + " -u EMAILS_SNS_TOPIC_ARN -u EMAILS_SNS_TOPIC_ARNS \\", + " -u EMAILS_AWS_ACCOUNT_ID -u EMAILS_AWS_ACCOUNT_IDS \\", + " -u EMAILS_INBOUND_S3_BUCKET -u EMAILS_INBOUND_WEBHOOK_SECRET \\", + " -u EMAILS_REQUIRE_SES_INBOUND_SECRET \\", + " -u EMAILS_INGEST_QUEUE_URL -u EMAILS_INGEST_S3_BUCKET \\", + " -u EMAILS_INGEST_S3_PREFIX -u EMAILS_INGEST_PREFIX_DOMAIN_MAP \\", + " -u EMAILS_INGEST_BACKFILL_LIMIT -u EMAILS_INGEST_BACKFILL_RECIPIENTS \\", + " -u EMAILS_ATTACHMENT_REPAIR_MANIFEST -u EMAILS_IMAGE_REVISION \\", + " -u EMAILS_MCP_HTTP_TOKEN -u EMAILS_MCP_ALLOWED_HOSTS \\", + " -u EMAILS_MCP_ALLOWED_ORIGINS -u MCP_HTTP_PORT \\", + " -u EMAILS_ALLOW_REMOTE -u EMAILS_DASHBOARD_ALLOWED_ORIGINS \\", + " -u HOST -u PORT -u USERPROFILE \\", + " -u EMAILS_PUBLIC_BASE_URL -u EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS \\", + " -u EMAILS_AUTH_FROM -u EMAILS_AUTH_PRODUCT_NAME \\", + " -u EMAILS_AUTH_VERIFY_URL_BASE -u EMAILS_AUTH_RESET_URL_BASE \\", + " -u EMAILS_AUTH_INVITE_URL_BASE \\", + " -u EMAILS_PRIMARY_SUPER_ADMIN_EMAIL \\", + " -u EMAILS_PRIMARY_SUPER_ADMIN_BOOTSTRAP_KID \\", + " -u EMAILS_EMAIL_VERIFY_TTL_HOURS -u EMAILS_INVITE_TTL_HOURS \\", + " -u EMAILS_RESET_TTL_MINUTES -u EMAILS_SESSION_IDLE_TTL_DAYS \\", + " -u EMAILS_SESSION_ABSOLUTE_TTL_DAYS -u EMAILS_TRUSTED_PROXY_HOPS \\", + " -u EMAILS_JSON_OUTPUT \\", + " -u EMAILS_TUI_THEME -u EMAILS_TUI_CLIPBOARD_COMMAND \\", + " -u EMAILS_TUI_CLIPBOARD_COMMAND_TIMEOUT_MS \\", + " -u EMAILS_TUI_CLIPBOARD_DRY_RUN -u EMAILS_TUI_CLIPBOARD_HOST \\", + " -u EMAILS_TUI_CLIPBOARD_SSH_HOSTS \\", + " -u EMAILS_TUI_CLIPBOARD_SSH_TIMEOUT -u EMAILS_TUI_CLIPBOARD_OSC52 \\", + " -u OTUI_USE_ALTERNATE_SCREEN \\", + " -u V1_STUB_ALLOWED_EMAIL_DOMAIN -u V1_STUB_API_KEY \\", + " -u V1_STUB_LIST_ORDER -u V1_STUB_RESOURCE_DEFAULTS \\", + " -u V1_STUB_RESOURCE_SPECS -u V1_STUB_SEED \\", + " -u FORCE_COLOR -u ECS_CONTAINER_METADATA_URI_V4 \\", " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", @@ -260,6 +302,7 @@ const exactLegacyHostedEnvUnsetBridges = new Map([ " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", " AWS_EC2_METADATA_DISABLED=true \\", " NO_COLOR=1 \\", + ' PATH="$PATH" \\', ' HOME="$test_home" \\', " EMAILS_MODE=local \\", " EMAILS_DB_PATH=:memory: \\", diff --git a/src/no-cloud-boundary.test.ts b/src/no-cloud-boundary.test.ts index e0688291..3571c0d3 100644 --- a/src/no-cloud-boundary.test.ts +++ b/src/no-cloud-boundary.test.ts @@ -107,12 +107,54 @@ const exactCompatibilityBridges = new Map([ " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", " -u MAILERY_API_URL -u MAILERY_API_KEY \\", " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", + " -u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\", " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", + " -u HASNA_EMAILS_DB_PATH -u HASNA_EMAILS_DATABASE_URL \\", " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", + " -u EMAILS_SELF_HOSTED_HTTP_CONNECT_TIMEOUT \\", + " -u EMAILS_SELF_HOSTED_HTTP_TIMEOUT \\", + " -u EMAILS_SELF_HOSTED_HTTP_MAX_RESPONSE_BYTES \\", " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", + " -u EMAILS_DATABASE_CA_FILE -u EMAILS_API_SIGNING_KEY \\", " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", + " -u EMAILS_PG_POOL_MAX -u EMAILS_SEND_LEASE_SECONDS \\", + " -u EMAILS_SEND_PROVIDER \\", + " -u EMAILS_SES_ACCESS_KEY_ID -u EMAILS_SES_SECRET_ACCESS_KEY \\", + " -u EMAILS_SES_CONFIGURATION_SET -u EMAILS_SES_INBOUND_WEBHOOK_SECRET \\", + " -u EMAILS_SNS_TOPIC_ARN -u EMAILS_SNS_TOPIC_ARNS \\", + " -u EMAILS_AWS_ACCOUNT_ID -u EMAILS_AWS_ACCOUNT_IDS \\", + " -u EMAILS_INBOUND_S3_BUCKET -u EMAILS_INBOUND_WEBHOOK_SECRET \\", + " -u EMAILS_REQUIRE_SES_INBOUND_SECRET \\", + " -u EMAILS_INGEST_QUEUE_URL -u EMAILS_INGEST_S3_BUCKET \\", + " -u EMAILS_INGEST_S3_PREFIX -u EMAILS_INGEST_PREFIX_DOMAIN_MAP \\", + " -u EMAILS_INGEST_BACKFILL_LIMIT -u EMAILS_INGEST_BACKFILL_RECIPIENTS \\", + " -u EMAILS_ATTACHMENT_REPAIR_MANIFEST -u EMAILS_IMAGE_REVISION \\", + " -u EMAILS_MCP_HTTP_TOKEN -u EMAILS_MCP_ALLOWED_HOSTS \\", + " -u EMAILS_MCP_ALLOWED_ORIGINS -u MCP_HTTP_PORT \\", + " -u EMAILS_ALLOW_REMOTE -u EMAILS_DASHBOARD_ALLOWED_ORIGINS \\", + " -u HOST -u PORT -u USERPROFILE \\", + " -u EMAILS_PUBLIC_BASE_URL -u EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS \\", + " -u EMAILS_AUTH_FROM -u EMAILS_AUTH_PRODUCT_NAME \\", + " -u EMAILS_AUTH_VERIFY_URL_BASE -u EMAILS_AUTH_RESET_URL_BASE \\", + " -u EMAILS_AUTH_INVITE_URL_BASE \\", + " -u EMAILS_PRIMARY_SUPER_ADMIN_EMAIL \\", + " -u EMAILS_PRIMARY_SUPER_ADMIN_BOOTSTRAP_KID \\", + " -u EMAILS_EMAIL_VERIFY_TTL_HOURS -u EMAILS_INVITE_TTL_HOURS \\", + " -u EMAILS_RESET_TTL_MINUTES -u EMAILS_SESSION_IDLE_TTL_DAYS \\", + " -u EMAILS_SESSION_ABSOLUTE_TTL_DAYS -u EMAILS_TRUSTED_PROXY_HOPS \\", + " -u EMAILS_JSON_OUTPUT \\", + " -u EMAILS_TUI_THEME -u EMAILS_TUI_CLIPBOARD_COMMAND \\", + " -u EMAILS_TUI_CLIPBOARD_COMMAND_TIMEOUT_MS \\", + " -u EMAILS_TUI_CLIPBOARD_DRY_RUN -u EMAILS_TUI_CLIPBOARD_HOST \\", + " -u EMAILS_TUI_CLIPBOARD_SSH_HOSTS \\", + " -u EMAILS_TUI_CLIPBOARD_SSH_TIMEOUT -u EMAILS_TUI_CLIPBOARD_OSC52 \\", + " -u OTUI_USE_ALTERNATE_SCREEN \\", + " -u V1_STUB_ALLOWED_EMAIL_DOMAIN -u V1_STUB_API_KEY \\", + " -u V1_STUB_LIST_ORDER -u V1_STUB_RESOURCE_DEFAULTS \\", + " -u V1_STUB_RESOURCE_SPECS -u V1_STUB_SEED \\", + " -u FORCE_COLOR -u ECS_CONTAINER_METADATA_URI_V4 \\", " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", @@ -125,6 +167,7 @@ const exactCompatibilityBridges = new Map([ " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", " AWS_EC2_METADATA_DISABLED=true \\", " NO_COLOR=1 \\", + ' PATH="$PATH" \\', ' HOME="$test_home" \\', " EMAILS_MODE=local \\", " EMAILS_DB_PATH=:memory: \\", @@ -268,6 +311,14 @@ describe("no hosted control plane", () => { " true", ].join("\n"); + const expandedRunnerBridge = exactCompatibilityBridges.get("scripts/run-hermetic-tests.sh")!; + const runnerContent = readFileSync(join(root, "scripts/run-hermetic-tests.sh"), "utf8"); + const runnerBridgeStart = runnerContent.indexOf(expandedRunnerBridge); + expect(runnerBridgeStart).toBeGreaterThanOrEqual(0); + expect(runnerContent.indexOf(expandedRunnerBridge, runnerBridgeStart + expandedRunnerBridge.length)).toBe(-1); + expect(expandedRunnerBridge).toContain(" -u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\\n"); + expect(expandedRunnerBridge).toContain(' PATH="$PATH" \\\n'); + for (const [path, exactBridge] of exactCompatibilityBridges) { expect(sourceBoundaryFindings(exactBridge, path)).toEqual([]); @@ -294,10 +345,15 @@ describe("no hosted control plane", () => { ]); // Reordering or injecting even a valid env option invalidates the bridge. + const reorderPattern = + path === "scripts/run-hermetic-tests.sh" + ? /(\s+-u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\\n)(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/ + : /(\s+-u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\\n)(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/; const reordered = exactBridge.replace( - /(\s+-u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\\n)(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/, + reorderPattern, "$2$1", ); + expect(reordered).not.toBe(exactBridge); expect(sourceBoundaryFindings(reordered, path)).toEqual([ "legacy hosted environment", "hosted implementation vocabulary", @@ -306,6 +362,7 @@ describe("no hosted control plane", () => { /(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/, " -u UNRELATED_ENVIRONMENT_VARIABLE \\\n$1", ); + expect(injected).not.toBe(exactBridge); expect(sourceBoundaryFindings(injected, path)).toEqual([ "legacy hosted environment", "hosted implementation vocabulary", @@ -314,6 +371,7 @@ describe("no hosted control plane", () => { path === ".github/workflows/ci.yml" ? exactBridge.replace(" bash -euo pipefail <<'BASH'\n", " bash -c true\n") : exactBridge.replace(' "$@"\n', " bash -c true\n"); + expect(changedUtility).not.toBe(exactBridge); expect(sourceBoundaryFindings(changedUtility, path)).toEqual([ "legacy hosted environment", "hosted implementation vocabulary", From b233c21eff7835d6ab70ccb8bb34af7eeb60febc Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 26 Jul 2026 23:45:15 +0300 Subject: [PATCH 15/16] fix(guards): hash canonical env bridge --- scripts/no-cloud-scan-lib.mjs | 188 ++++++---------------- src/no-cloud-boundary.test.ts | 288 ++++++++++++---------------------- 2 files changed, 150 insertions(+), 326 deletions(-) diff --git a/scripts/no-cloud-scan-lib.mjs b/scripts/no-cloud-scan-lib.mjs index 416ec342..7121f973 100644 --- a/scripts/no-cloud-scan-lib.mjs +++ b/scripts/no-cloud-scan-lib.mjs @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + const legacyHostedEnvKeys = [ "MAILERY_API_URL", "MAILERY_API_KEY", @@ -191,133 +193,50 @@ export const artifactBoundaryPatterns = boundaryPatternsForScope(ARTIFACT_SCOPE) /** Patterns enforced on the committed source tree. */ export const sourceBoundaryPatterns = boundaryPatternsForScope(SOURCE_SCOPE); -// These are the only source locations where retired hosted environment names -// remain operationally necessary: they are unset before local tests start. -// Include the surrounding structure plus the full option, assignment, and -// utility sequence so a lookalike env command elsewhere in either file cannot -// borrow this compatibility bridge. -const exactLegacyHostedEnvUnsetBridges = new Map([ - [ - ".github/workflows/ci.yml", - [ - " - name: Test in isolated local mode", - " run: |", - ' tmp_home="$(mktemp -d)"', - " trap 'rm -rf \"$tmp_home\"' EXIT", - " env -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", - " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", - " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", - " -u MAILERY_API_URL -u MAILERY_API_KEY \\", - " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", - " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", - " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", - " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", - " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", - " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", - " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", - " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", - " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", - " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", - " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", - " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", - " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", - " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", - " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", - " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", - " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", - " AWS_EC2_METADATA_DISABLED=true \\", - ' HOME="$tmp_home" EMAILS_MODE=local EMAILS_DB_PATH=:memory: \\', - " bash -euo pipefail <<'BASH'", - ].join("\n") + "\n", - ], - [ - "scripts/run-hermetic-tests.sh", - [ - "run_scrubbed() {", - ' local test_home="$1"', - " shift", - " env \\", - " -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", - " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", - " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", - " -u MAILERY_API_URL -u MAILERY_API_KEY \\", - " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", - " -u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\", - " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", - " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", - " -u HASNA_EMAILS_DB_PATH -u HASNA_EMAILS_DATABASE_URL \\", - " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", - " -u EMAILS_SELF_HOSTED_HTTP_CONNECT_TIMEOUT \\", - " -u EMAILS_SELF_HOSTED_HTTP_TIMEOUT \\", - " -u EMAILS_SELF_HOSTED_HTTP_MAX_RESPONSE_BYTES \\", - " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", - " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", - " -u EMAILS_DATABASE_CA_FILE -u EMAILS_API_SIGNING_KEY \\", - " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", - " -u EMAILS_PG_POOL_MAX -u EMAILS_SEND_LEASE_SECONDS \\", - " -u EMAILS_SEND_PROVIDER \\", - " -u EMAILS_SES_ACCESS_KEY_ID -u EMAILS_SES_SECRET_ACCESS_KEY \\", - " -u EMAILS_SES_CONFIGURATION_SET -u EMAILS_SES_INBOUND_WEBHOOK_SECRET \\", - " -u EMAILS_SNS_TOPIC_ARN -u EMAILS_SNS_TOPIC_ARNS \\", - " -u EMAILS_AWS_ACCOUNT_ID -u EMAILS_AWS_ACCOUNT_IDS \\", - " -u EMAILS_INBOUND_S3_BUCKET -u EMAILS_INBOUND_WEBHOOK_SECRET \\", - " -u EMAILS_REQUIRE_SES_INBOUND_SECRET \\", - " -u EMAILS_INGEST_QUEUE_URL -u EMAILS_INGEST_S3_BUCKET \\", - " -u EMAILS_INGEST_S3_PREFIX -u EMAILS_INGEST_PREFIX_DOMAIN_MAP \\", - " -u EMAILS_INGEST_BACKFILL_LIMIT -u EMAILS_INGEST_BACKFILL_RECIPIENTS \\", - " -u EMAILS_ATTACHMENT_REPAIR_MANIFEST -u EMAILS_IMAGE_REVISION \\", - " -u EMAILS_MCP_HTTP_TOKEN -u EMAILS_MCP_ALLOWED_HOSTS \\", - " -u EMAILS_MCP_ALLOWED_ORIGINS -u MCP_HTTP_PORT \\", - " -u EMAILS_ALLOW_REMOTE -u EMAILS_DASHBOARD_ALLOWED_ORIGINS \\", - " -u HOST -u PORT -u USERPROFILE \\", - " -u EMAILS_PUBLIC_BASE_URL -u EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS \\", - " -u EMAILS_AUTH_FROM -u EMAILS_AUTH_PRODUCT_NAME \\", - " -u EMAILS_AUTH_VERIFY_URL_BASE -u EMAILS_AUTH_RESET_URL_BASE \\", - " -u EMAILS_AUTH_INVITE_URL_BASE \\", - " -u EMAILS_PRIMARY_SUPER_ADMIN_EMAIL \\", - " -u EMAILS_PRIMARY_SUPER_ADMIN_BOOTSTRAP_KID \\", - " -u EMAILS_EMAIL_VERIFY_TTL_HOURS -u EMAILS_INVITE_TTL_HOURS \\", - " -u EMAILS_RESET_TTL_MINUTES -u EMAILS_SESSION_IDLE_TTL_DAYS \\", - " -u EMAILS_SESSION_ABSOLUTE_TTL_DAYS -u EMAILS_TRUSTED_PROXY_HOPS \\", - " -u EMAILS_JSON_OUTPUT \\", - " -u EMAILS_TUI_THEME -u EMAILS_TUI_CLIPBOARD_COMMAND \\", - " -u EMAILS_TUI_CLIPBOARD_COMMAND_TIMEOUT_MS \\", - " -u EMAILS_TUI_CLIPBOARD_DRY_RUN -u EMAILS_TUI_CLIPBOARD_HOST \\", - " -u EMAILS_TUI_CLIPBOARD_SSH_HOSTS \\", - " -u EMAILS_TUI_CLIPBOARD_SSH_TIMEOUT -u EMAILS_TUI_CLIPBOARD_OSC52 \\", - " -u OTUI_USE_ALTERNATE_SCREEN \\", - " -u V1_STUB_ALLOWED_EMAIL_DOMAIN -u V1_STUB_API_KEY \\", - " -u V1_STUB_LIST_ORDER -u V1_STUB_RESOURCE_DEFAULTS \\", - " -u V1_STUB_RESOURCE_SPECS -u V1_STUB_SEED \\", - " -u FORCE_COLOR -u ECS_CONTAINER_METADATA_URI_V4 \\", - " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", - " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", - " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", - " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", - " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", - " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", - " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", - " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", - " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", - " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", - " AWS_EC2_METADATA_DISABLED=true \\", - " NO_COLOR=1 \\", - ' PATH="$PATH" \\', - ' HOME="$test_home" \\', - " EMAILS_MODE=local \\", - " EMAILS_DB_PATH=:memory: \\", - ' "$@"', - ].join("\n") + "\n", - ], -]); +// The retired-name compatibility bridge has one canonical source location. Its +// body is extracted from that source using unique structural anchors and accepted +// only when the complete byte range retains this pinned digest. Keeping only the +// anchors and hash here avoids making the guard another source of deployment-mode +// configuration while still failing closed on insertion, reordering, utility +// changes, duplicate anchors, or movement to another path. +const exactLegacyHostedEnvUnsetBridgeSpec = { + path: "scripts/run-hermetic-tests.sh", + startAnchor: "run_scrubbed() {\n", + endAnchor: ' "$@"\n', + sha256: "13ad76c318041f74aa90fe5ddd9d09589119d171114eda3df2d315aa951f57d9", +}; + +function locateExactLegacyHostedEnvUnsetBridge(content, path) { + const spec = exactLegacyHostedEnvUnsetBridgeSpec; + if (path !== spec.path) return undefined; + + const start = content.indexOf(spec.startAnchor); + if (start < 0 || content.indexOf(spec.startAnchor, start + spec.startAnchor.length) >= 0) return undefined; + + const endAnchorStart = content.indexOf(spec.endAnchor, start + spec.startAnchor.length); + if ( + endAnchorStart < 0 || + content.indexOf(spec.endAnchor) !== endAnchorStart || + content.indexOf(spec.endAnchor, endAnchorStart + spec.endAnchor.length) >= 0 + ) { + return undefined; + } + + const end = endAnchorStart + spec.endAnchor.length; + const bridge = content.slice(start, end); + if (createHash("sha256").update(bridge).digest("hex") !== spec.sha256) return undefined; -for (const [path, bridge] of exactLegacyHostedEnvUnsetBridges) { for (const key of legacyHostedEnvKeys) { const token = `-u ${key}`; if (bridge.indexOf(token) < 0 || bridge.indexOf(token) !== bridge.lastIndexOf(token)) { - throw new Error(`exact compatibility bridge ${path} must contain ${token} exactly once`); + return undefined; } } + return { content: bridge, start, end }; +} + +export function extractExactLegacyHostedEnvUnsetBridge(content, path) { + return locateExactLegacyHostedEnvUnsetBridge(content, path)?.content; } const exactHistoricalChangelogBridge = [ @@ -391,6 +310,17 @@ for (const [path, { content: bridge, tokens }] of exactHistoricalHostedVocabular function stripExactCompatibilityBridges(content, path) { let scanned = content; + // Normalize only the uniquely anchored, byte-for-byte pinned canonical bridge. + // Any structural or content mismatch leaves every retired token visible. + const exactBridge = locateExactLegacyHostedEnvUnsetBridge(scanned, path); + if (exactBridge !== undefined) { + let normalizedBridge = exactBridge.content; + for (const key of legacyHostedEnvKeys) { + normalizedBridge = normalizedBridge.replace(`-u ${key}`, "-u LEGACY_HOSTED_SENTINEL"); + } + scanned = scanned.slice(0, exactBridge.start) + normalizedBridge + scanned.slice(exactBridge.end); + } + // The mode resolver must retain these literal names only to reject old // environments with actionable migration guidance. Do not exempt its file or // bundle chunk wholesale: only erase literals inside the named rejection list. @@ -430,22 +360,6 @@ function stripExactCompatibilityBridges(content, path) { // bundles that import it for `version`. scanned = scanned.replace(/(?= 0 && scanned.indexOf(exactBridge, bridgeStart + exactBridge.length) < 0) { - let normalizedBridge = exactBridge; - for (const key of legacyHostedEnvKeys) { - normalizedBridge = normalizedBridge.replace(`-u ${key}`, "-u LEGACY_HOSTED_SENTINEL"); - } - scanned = scanned.slice(0, bridgeStart) + normalizedBridge + scanned.slice(bridgeStart + exactBridge.length); - } - } - const historicalBridge = exactHistoricalHostedVocabularyBridges.get(path); const historicalBridgeStart = historicalBridge === undefined ? -1 : scanned.indexOf(historicalBridge.content); if (historicalBridge !== undefined && historicalBridgeStart >= 0) { diff --git a/src/no-cloud-boundary.test.ts b/src/no-cloud-boundary.test.ts index 3571c0d3..ba8caa4f 100644 --- a/src/no-cloud-boundary.test.ts +++ b/src/no-cloud-boundary.test.ts @@ -7,6 +7,7 @@ import { normalizeEmailsMode } from "./lib/mode.js"; import { BOUNDARY_SCOPES, boundaryPatternTable, + extractExactLegacyHostedEnvUnsetBridge, isSkippableBinary, isSourceAllowed, sourceBoundaryFindings, @@ -61,120 +62,14 @@ const legacyHostedEnvKeys = [ "HASNA_MAILERY_API_KEY", ]; -const exactCompatibilityBridges = new Map([ - [ - ".github/workflows/ci.yml", - [ - " - name: Test in isolated local mode", - " run: |", - ' tmp_home="$(mktemp -d)"', - " trap 'rm -rf \"$tmp_home\"' EXIT", - " env -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", - " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", - " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", - " -u MAILERY_API_URL -u MAILERY_API_KEY \\", - " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", - " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", - " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", - " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", - " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", - " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", - " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", - " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", - " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", - " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", - " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", - " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", - " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", - " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", - " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", - " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", - " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", - " AWS_EC2_METADATA_DISABLED=true \\", - ' HOME="$tmp_home" EMAILS_MODE=local EMAILS_DB_PATH=:memory: \\', - " bash -euo pipefail <<'BASH'", - ].join("\n") + "\n", - ], - [ - "scripts/run-hermetic-tests.sh", - [ - "run_scrubbed() {", - ' local test_home="$1"', - " shift", - " env \\", - " -u MAILERY_MODE -u HASNA_MAILERY_MODE \\", - " -u MAILERY_STORAGE_MODE -u HASNA_MAILERY_STORAGE_MODE \\", - " -u EMAILS_STORAGE_MODE -u HASNA_EMAILS_STORAGE_MODE \\", - " -u MAILERY_API_URL -u MAILERY_API_KEY \\", - " -u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\", - " -u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\", - " -u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\", - " -u HASNA_MAILERY_ENV_FILE -u HASNA_EMAILS_MODE \\", - " -u HASNA_EMAILS_DB_PATH -u HASNA_EMAILS_DATABASE_URL \\", - " -u EMAILS_SELF_HOSTED_URL -u EMAILS_SELF_HOSTED_API_KEY \\", - " -u EMAILS_SELF_HOSTED_HTTP_CONNECT_TIMEOUT \\", - " -u EMAILS_SELF_HOSTED_HTTP_TIMEOUT \\", - " -u EMAILS_SELF_HOSTED_HTTP_MAX_RESPONSE_BYTES \\", - " -u EMAILS_CLIENT_ENV_SECRET -u EMAILS_SESSION_TOKEN \\", - " -u DATABASE_URL -u EMAILS_DATABASE_URL -u EMAILS_TEST_DATABASE_URL \\", - " -u EMAILS_DATABASE_CA_FILE -u EMAILS_API_SIGNING_KEY \\", - " -u EMAILS_POSTGRES_URL -u EMAILS_TEST_POSTGRES_URL \\", - " -u EMAILS_PG_POOL_MAX -u EMAILS_SEND_LEASE_SECONDS \\", - " -u EMAILS_SEND_PROVIDER \\", - " -u EMAILS_SES_ACCESS_KEY_ID -u EMAILS_SES_SECRET_ACCESS_KEY \\", - " -u EMAILS_SES_CONFIGURATION_SET -u EMAILS_SES_INBOUND_WEBHOOK_SECRET \\", - " -u EMAILS_SNS_TOPIC_ARN -u EMAILS_SNS_TOPIC_ARNS \\", - " -u EMAILS_AWS_ACCOUNT_ID -u EMAILS_AWS_ACCOUNT_IDS \\", - " -u EMAILS_INBOUND_S3_BUCKET -u EMAILS_INBOUND_WEBHOOK_SECRET \\", - " -u EMAILS_REQUIRE_SES_INBOUND_SECRET \\", - " -u EMAILS_INGEST_QUEUE_URL -u EMAILS_INGEST_S3_BUCKET \\", - " -u EMAILS_INGEST_S3_PREFIX -u EMAILS_INGEST_PREFIX_DOMAIN_MAP \\", - " -u EMAILS_INGEST_BACKFILL_LIMIT -u EMAILS_INGEST_BACKFILL_RECIPIENTS \\", - " -u EMAILS_ATTACHMENT_REPAIR_MANIFEST -u EMAILS_IMAGE_REVISION \\", - " -u EMAILS_MCP_HTTP_TOKEN -u EMAILS_MCP_ALLOWED_HOSTS \\", - " -u EMAILS_MCP_ALLOWED_ORIGINS -u MCP_HTTP_PORT \\", - " -u EMAILS_ALLOW_REMOTE -u EMAILS_DASHBOARD_ALLOWED_ORIGINS \\", - " -u HOST -u PORT -u USERPROFILE \\", - " -u EMAILS_PUBLIC_BASE_URL -u EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS \\", - " -u EMAILS_AUTH_FROM -u EMAILS_AUTH_PRODUCT_NAME \\", - " -u EMAILS_AUTH_VERIFY_URL_BASE -u EMAILS_AUTH_RESET_URL_BASE \\", - " -u EMAILS_AUTH_INVITE_URL_BASE \\", - " -u EMAILS_PRIMARY_SUPER_ADMIN_EMAIL \\", - " -u EMAILS_PRIMARY_SUPER_ADMIN_BOOTSTRAP_KID \\", - " -u EMAILS_EMAIL_VERIFY_TTL_HOURS -u EMAILS_INVITE_TTL_HOURS \\", - " -u EMAILS_RESET_TTL_MINUTES -u EMAILS_SESSION_IDLE_TTL_DAYS \\", - " -u EMAILS_SESSION_ABSOLUTE_TTL_DAYS -u EMAILS_TRUSTED_PROXY_HOPS \\", - " -u EMAILS_JSON_OUTPUT \\", - " -u EMAILS_TUI_THEME -u EMAILS_TUI_CLIPBOARD_COMMAND \\", - " -u EMAILS_TUI_CLIPBOARD_COMMAND_TIMEOUT_MS \\", - " -u EMAILS_TUI_CLIPBOARD_DRY_RUN -u EMAILS_TUI_CLIPBOARD_HOST \\", - " -u EMAILS_TUI_CLIPBOARD_SSH_HOSTS \\", - " -u EMAILS_TUI_CLIPBOARD_SSH_TIMEOUT -u EMAILS_TUI_CLIPBOARD_OSC52 \\", - " -u OTUI_USE_ALTERNATE_SCREEN \\", - " -u V1_STUB_ALLOWED_EMAIL_DOMAIN -u V1_STUB_API_KEY \\", - " -u V1_STUB_LIST_ORDER -u V1_STUB_RESOURCE_DEFAULTS \\", - " -u V1_STUB_RESOURCE_SPECS -u V1_STUB_SEED \\", - " -u FORCE_COLOR -u ECS_CONTAINER_METADATA_URI_V4 \\", - " -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_API_KEY \\", - " -u CLOUDFLARE_EMAIL -u CLOUDFLARE_ACCOUNT_ID \\", - " -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \\", - " -u AWS_PROFILE -u AWS_DEFAULT_PROFILE -u AWS_ACCOUNT_ID \\", - " -u AWS_REGION -u AWS_DEFAULT_REGION -u AWS_SHARED_CREDENTIALS_FILE \\", - " -u AWS_CONFIG_FILE -u AWS_WEB_IDENTITY_TOKEN_FILE -u AWS_ROLE_ARN \\", - " -u AWS_ROLE_SESSION_NAME -u AWS_CONTAINER_CREDENTIALS_RELATIVE_URI \\", - " -u AWS_CONTAINER_CREDENTIALS_FULL_URI -u AWS_CONTAINER_AUTHORIZATION_TOKEN \\", - " -u EMAILS_AWS_REGION -u EMAILS_SES_AWS_PROFILE \\", - " -u RESEND_API_KEY -u RESEND_WEBHOOK_SECRET \\", - " AWS_EC2_METADATA_DISABLED=true \\", - " NO_COLOR=1 \\", - ' PATH="$PATH" \\', - ' HOME="$test_home" \\', - " EMAILS_MODE=local \\", - " EMAILS_DB_PATH=:memory: \\", - ' "$@"', - ].join("\n") + "\n", - ], -]); +const exactCompatibilityBridgePath = "scripts/run-hermetic-tests.sh"; + +function canonicalCompatibilityBridge(): string { + const source = readFileSync(join(root, exactCompatibilityBridgePath), "utf8"); + const bridge = extractExactLegacyHostedEnvUnsetBridge(source, exactCompatibilityBridgePath); + expect(bridge).toBeDefined(); + return bridge!; +} function expectedLegacyFindings(key: string): string[] { return key.includes("CLOUD") @@ -285,21 +180,20 @@ describe("no hosted control plane", () => { const allowedCount = scanned.filter((path) => allowed.some((entry) => isSourceAllowed(entry, path))).length; expect(allowedCount * 2).toBeLessThan(scanned.length); - for (const [path, exactBridge] of exactCompatibilityBridges) { - expect(sourceBoundaryFindings(exactBridge, path)).toEqual([]); - expect(sourceBoundaryFindings("echo MAILERY_CLOUD_API_URL", path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); - expect(sourceBoundaryFindings("env MAILERY_CLOUD_API_URL=value true", path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); - expect(sourceBoundaryFindings("printf '%s' 'env -u MAILERY_CLOUD_API_URL'", path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); - } + const exactBridge = canonicalCompatibilityBridge(); + expect(sourceBoundaryFindings(exactBridge, exactCompatibilityBridgePath)).toEqual([]); + expect(sourceBoundaryFindings("echo MAILERY_CLOUD_API_URL", exactCompatibilityBridgePath)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings("env MAILERY_CLOUD_API_URL=value true", exactCompatibilityBridgePath)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings("printf '%s' 'env -u MAILERY_CLOUD_API_URL'", exactCompatibilityBridgePath)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); }); it("allows only the location- and syntax-exact legacy env-unset bridges", () => { @@ -311,79 +205,95 @@ describe("no hosted control plane", () => { " true", ].join("\n"); - const expandedRunnerBridge = exactCompatibilityBridges.get("scripts/run-hermetic-tests.sh")!; - const runnerContent = readFileSync(join(root, "scripts/run-hermetic-tests.sh"), "utf8"); + const path = exactCompatibilityBridgePath; + const expandedRunnerBridge = canonicalCompatibilityBridge(); + const runnerContent = readFileSync(join(root, path), "utf8"); const runnerBridgeStart = runnerContent.indexOf(expandedRunnerBridge); expect(runnerBridgeStart).toBeGreaterThanOrEqual(0); expect(runnerContent.indexOf(expandedRunnerBridge, runnerBridgeStart + expandedRunnerBridge.length)).toBe(-1); + expect(extractExactLegacyHostedEnvUnsetBridge(runnerContent, path)).toBe(expandedRunnerBridge); expect(expandedRunnerBridge).toContain(" -u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\\n"); expect(expandedRunnerBridge).toContain(' PATH="$PATH" \\\n'); - for (const [path, exactBridge] of exactCompatibilityBridges) { - expect(sourceBoundaryFindings(exactBridge, path)).toEqual([]); + const exactBridge = expandedRunnerBridge; + expect(sourceBoundaryFindings(exactBridge, path)).toEqual([]); - // Reviewer bypass 1: a second unrelated env command on an allowed path. - expect(sourceBoundaryFindings(`${exactBridge}\n${arbitraryBridge}`, path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); - expect(sourceBoundaryFindings(`${exactBridge}\n${exactBridge}`, path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); + // Reviewer bypass 1: a second unrelated env command on an allowed path. + expect(sourceBoundaryFindings(`${exactBridge}\n${arbitraryBridge}`, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings(`${exactBridge}\n${exactBridge}`, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); - // Reviewer bypass 2: env options appearing after option parsing has ended. - expect(sourceBoundaryFindings("env bash -c true -u MAILERY_CLOUD_API_URL ignored", path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); + // Reviewer bypass 2: env options appearing after option parsing has ended. + expect(sourceBoundaryFindings("env bash -c true -u MAILERY_CLOUD_API_URL ignored", path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); - // The same command text is not a bridge at an arbitrary location. - expect(sourceBoundaryFindings(arbitraryBridge, path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); + // The same command text is not a bridge at an arbitrary location. + expect(sourceBoundaryFindings(arbitraryBridge, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); - // Reordering or injecting even a valid env option invalidates the bridge. - const reorderPattern = - path === "scripts/run-hermetic-tests.sh" - ? /(\s+-u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\\n)(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/ - : /(\s+-u HASNA_MAILERY_API_URL -u HASNA_MAILERY_API_KEY \\\n)(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/; - const reordered = exactBridge.replace( - reorderPattern, - "$2$1", - ); - expect(reordered).not.toBe(exactBridge); - expect(sourceBoundaryFindings(reordered, path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); - const injected = exactBridge.replace( - /(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/, - " -u UNRELATED_ENVIRONMENT_VARIABLE \\\n$1", - ); - expect(injected).not.toBe(exactBridge); - expect(sourceBoundaryFindings(injected, path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); - const changedUtility = - path === ".github/workflows/ci.yml" - ? exactBridge.replace(" bash -euo pipefail <<'BASH'\n", " bash -c true\n") - : exactBridge.replace(' "$@"\n', " bash -c true\n"); - expect(changedUtility).not.toBe(exactBridge); - expect(sourceBoundaryFindings(changedUtility, path)).toEqual([ - "legacy hosted environment", - "hosted implementation vocabulary", - ]); + // Reordering or injecting even a valid env option invalidates the bridge. + const reordered = exactBridge.replace( + /(\s+-u HASNA_MAILERY_API_SIGNING_KEY -u HASNA_MAILERY_DATABASE_URL \\\n)(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/, + "$2$1", + ); + expect(reordered).not.toBe(exactBridge); + expect(sourceBoundaryFindings(reordered, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + const injected = exactBridge.replace( + /(\s+-u MAILERY_CLOUD_API_URL -u MAILERY_CLOUD_TOKEN \\\n)/, + " -u UNRELATED_ENVIRONMENT_VARIABLE \\\n$1", + ); + expect(injected).not.toBe(exactBridge); + expect(sourceBoundaryFindings(injected, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + const changedUtility = exactBridge.replace(' "$@"\n', " bash -c true\n"); + expect(changedUtility).not.toBe(exactBridge); + expect(sourceBoundaryFindings(changedUtility, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); - // Every retired key remains banned anywhere outside the one exact bridge. - for (const key of legacyHostedEnvKeys) { - expect(sourceBoundaryFindings(`${exactBridge}\necho ${key}`, path)).toEqual(expectedLegacyFindings(key)); - } + const changedStart = exactBridge.replace("run_scrubbed() {\n", "run_scrubbed () {\n"); + expect(changedStart).not.toBe(exactBridge); + expect(extractExactLegacyHostedEnvUnsetBridge(changedStart, path)).toBeUndefined(); + expect(sourceBoundaryFindings(changedStart, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + + // A harmless body change still invalidates the pinned digest, proving the + // acceptance is byte-exact rather than a partial option-list check. + const changedBody = exactBridge.replace(" NO_COLOR=1 \\\n", " NO_COLOR=0 \\\n"); + expect(changedBody).not.toBe(exactBridge); + expect(extractExactLegacyHostedEnvUnsetBridge(changedBody, path)).toBeUndefined(); + expect(sourceBoundaryFindings(changedBody, path)).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + + // Every retired key remains banned anywhere outside the one exact bridge. + for (const key of legacyHostedEnvKeys) { + expect(sourceBoundaryFindings(`${exactBridge}\necho ${key}`, path)).toEqual(expectedLegacyFindings(key)); } - expect(sourceBoundaryFindings(exactCompatibilityBridges.get(".github/workflows/ci.yml")!, "scripts/arbitrary.sh")).toEqual([ + expect(sourceBoundaryFindings(exactBridge, ".github/workflows/ci.yml")).toEqual([ + "legacy hosted environment", + "hosted implementation vocabulary", + ]); + expect(sourceBoundaryFindings(exactBridge, "scripts/arbitrary.sh")).toEqual([ "legacy hosted environment", "hosted implementation vocabulary", ]); From c96011b85138745e67c9c11dd553c8542e04bb7d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 27 Jul 2026 00:14:24 +0300 Subject: [PATCH 16/16] test: measure raw mode-axis corpus --- src/mode-axis-ratchet.test.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/mode-axis-ratchet.test.ts b/src/mode-axis-ratchet.test.ts index b01d8eaa..41cb8692 100644 --- a/src/mode-axis-ratchet.test.ts +++ b/src/mode-axis-ratchet.test.ts @@ -125,13 +125,6 @@ interface ScannedFile { content: string; } -function stripExactModeAxisCompatibilityBridge(content: string, path: string): string { - if (path !== "scripts/run-hermetic-tests.sh") return content; - const retiredModeKey = "HASNA_EMAILS_" + "MODE"; - const exactBridge = ` -u HASNA_MAILERY_ENV_FILE -u ${retiredModeKey} \\`; - return content.replace(exactBridge, " -u HASNA_MAILERY_ENV_FILE -u RETIRED_MODE_SENTINEL \\"); -} - // `git ls-files` IS the set of committed surfaces, which is the only set that can // carry the axis forward. Deriving the corpus from the index rather than from a // hand-written roots list is what keeps a rename, a new directory or a new file type @@ -169,7 +162,7 @@ function scannedFiles(): ScannedFile[] { if (!statSync(join(root, path)).isFile()) continue; const buffer = readFileSync(join(root, path)); if (isSkippableBinary(path, buffer)) continue; - files.push({ path, content: stripExactModeAxisCompatibilityBridge(buffer.toString("utf8"), path) }); + files.push({ path, content: buffer.toString("utf8") }); } // THE FLOOR LIVES HERE, not in one test. Every assertion that reads the corpus goes // through this function, so none of them can be run — by `-t`, by `it.skip`, or by a @@ -206,14 +199,6 @@ describe("deployment-mode axis ratchet", () => { const kinds = new Set(scanned.map((file) => extname(file.path).toLowerCase() || "(extensionless)")); for (const kind of [".ts", ".tsx", ".mjs", ".md", ".tf", ".yml", ".json"]) expect(kinds).toContain(kind); - const retiredModeKey = "HASNA_EMAILS_" + "MODE"; - const exactBridge = ` -u HASNA_MAILERY_ENV_FILE -u ${retiredModeKey} \\`; - const hermeticSource = readFileSync(join(root, "scripts/run-hermetic-tests.sh"), "utf8"); - expect(hermeticSource).toContain(exactBridge); - expect(stripExactModeAxisCompatibilityBridge(hermeticSource, "scripts/run-hermetic-tests.sh")).not.toContain(exactBridge); - expect(stripExactModeAxisCompatibilityBridge(hermeticSource, "scripts/arbitrary.sh")).toBe(hermeticSource); - expect(stripExactModeAxisCompatibilityBridge(`echo ${retiredModeKey}`, "scripts/run-hermetic-tests.sh")) - .toBe(`echo ${retiredModeKey}`); }); it("keeps the corpus exemption list minimal, live, and free of self-exemption", () => {