diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d72a0b1..fe5690d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,17 +59,39 @@ jobs: set -euo pipefail python -m pip install --quiet --upgrade pip python -m pip install --quiet . pyinstaller + # `bootstrap/regen.py` is shipped as DATA, not as a module: it is + # piped to the consuming repository's own interpreter, and nothing + # in stackward imports it — which is what keeps pydantic out of the + # bundle. PyInstaller's import analysis therefore never sees it, so + # it has to be added explicitly. python -m PyInstaller --noconfirm --clean --onedir \ - --name stackward --paths src --distpath dist --workpath build entry.py + --name stackward --paths src --distpath dist --workpath build \ + --add-data "src/stackward/bootstrap/regen.py:stackward/bootstrap" \ + entry.py - name: It must actually run - # A bundle that builds is not a bundle that works. doctor performs a - # real Argon2id + AES-GCM round trip, so this catches a native - # extension that failed to freeze — the classic failure of this route. + # A bundle that builds is not a bundle that works. The checks, and + # the reasoning behind each, live in `scripts/check-bundle.sh` -- + # one definition, run here against what this job just built and + # again in `publish` against the artifact actually being released. + # Two copies of this is how `publish` came to test only `--version` + # and `doctor`, the two commands a missing lazily-dispatched module + # cannot break. run: | set -euo pipefail - ./dist/stackward/stackward --version - ./dist/stackward/stackward doctor + scripts/check-bundle.sh ./dist/stackward/stackward + + # Not in the shared script: this one inspects the PyInstaller + # output tree, which only this job has. `regen.py` ships as data + # precisely so nothing imports it and pydantic stays test-only. If + # it ever became an import, the bundle would still build, doctor + # would still pass, and the only visible symptom would be a fatter + # download -- so the absence has to be asserted, not assumed. + if find dist -iname '*pydantic*' -print -quit | grep -q .; then + echo "::error::pydantic was bundled; it is a test-only dependency" >&2 + find dist -iname '*pydantic*' | head -20 >&2 + exit 1 + fi - name: Sign for macOS if: runner.os == 'macOS' @@ -110,16 +132,23 @@ jobs: - name: Verify the install path before publishing it # Install exactly as a user would, from the artifacts about to be - # released, and run the smoke test. A release that cannot be installed - # is deleted, not patched: the tag is already immutable in people's - # lockfiles and instructions. + # released, and run the same check the build job ran -- against the + # bytes actually being published, which is the only artifact whose + # correctness this job can still act on. A release that cannot be + # installed is deleted, not patched: the tag is already immutable in + # people's lockfiles and instructions. + # + # `scripts/check-bundle.sh`, not a second smoke test written here: + # this step used to run `--version` and `doctor` alone, and those are + # exactly the two commands that stayed green through a bundle that + # was missing every lazily dispatched one. run: | set -euo pipefail cd artifacts sha256sum --check stackward-Linux-x86_64.tar.gz.sha256 mkdir -p /tmp/probe && tar -xzf stackward-Linux-x86_64.tar.gz -C /tmp/probe - /tmp/probe/stackward/stackward --version - /tmp/probe/stackward/stackward doctor + # Absolute: the working directory is `artifacts`, not the checkout. + "$GITHUB_WORKSPACE/scripts/check-bundle.sh" /tmp/probe/stackward/stackward - name: Publish env: diff --git a/.gitignore b/.gitignore index 0f55415..d7d3a25 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,17 @@ state.json .env .env.* !.env.example + +# Agent and tooling scratch directories. These hold working notes, review +# transcripts and plan files — prose about how the work was done, written +# for a private audience. On a public repository that is a worse exposure +# than a stray build artefact: the neutrality gate greps for known terms, +# and cannot catch a novel detail described in a sentence. +# +# Listed here rather than relying on an ignore file the tooling drops +# inside its own directory. That file is untracked, so it protects only the +# checkout that happened to create it — a fresh clone gets nothing, and +# anything written outside the subdirectory it covers was never protected +# at all. +.superpowers/ +.claude/ diff --git a/README.md b/README.md index f337e08..774d6f8 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,45 @@ at the top of the file is what makes it safe. What makes it *un*safe is a value hand-edited in as plaintext, which looks exactly like every other line in the diff. `stackward` exists to make that mistake hard. -> **Status: early.** `v0.1.0` ships `--version` and `doctor` only. It exists to -> prove the release and install path before features are built on top of it. +> **Status: early.** `v0.1.0` shipped `--version` and `doctor` alone, to prove +> the release and install path before anything was built on top of it. That +> path held, and every command in the table below is now implemented. +> Interfaces may change. -## What it will do +## What it does | Command | Purpose | |---|---| | `check-config` | Refuse plaintext credentials in a stack config file | | `pre-commit` | The above, over staged content, as a git hook | | `hooks install` | Install that hook, honouring `core.hooksPath` | -| `credentials` | Keep backend credentials in one encrypted, profile-scoped store | -| `exec` / `shell` | Run `pulumi` with those credentials injected into the child process | +| `credentials` | Create the encrypted store, seal a profile's credentials into it, list what is there, rotate the password | | `login` | Point Pulumi at a profile's backend | +| `exec` / `shell` | Run `pulumi` with a profile's credentials injected into the child process | | `set-secrets` | Publish stack secrets from a declared manifest, values on stdin | | `check-passphrase` | Verify a passphrase actually decrypts a stack, rather than merely exiting 0 | +| `sync-declared-secrets` | Regenerate the committed graph of secrets a repo's own models declare | +| `doctor` | Report the resolved configuration and environment | + +`credentials` is what fills the encrypted, profile-scoped store the three +session commands read from. A value never reaches it as an argument: +`credentials set` reads `KEY=VALUE` lines on stdin — the format of the `.env` +file it exists to replace — or prompts for each name without echo. Nothing it +prints is ever a value: `list` prints profile names, `show` prints the +credential *names* an envelope carries, and neither prints what is beside +them. + +`set` seals **every** name it is given, while `exec`/`shell` inject only the +three they need — so a `.env` carrying more than those three has the extras +sealed too. That is deliberate: a real `.env` holds more, and refusing it +would break the very workflow this command exists for. `show` names any such +extra, so it is visible rather than merely true. + +```sh +stackward credentials init # once, per machine +stackward credentials set --profile NAME < .env # every name in it; then rm .env +stackward credentials show --profile NAME # names only, never values +``` ## Design commitments @@ -38,9 +62,23 @@ These are constraints, not aspirations — each has a test. mechanism; which keys are sensitive, which paths are managed and which model declares them are all read from the repo's `.stackward.toml`. The tool ships no defaults about anyone's infrastructure. -- **No backend assumptions.** A profile carries a Pulumi backend URL and it is - passed through, so `s3://`, `gs://`, `azblob://` and `file://` all work. No - storage vendor is named anywhere in this codebase. +- **A backend URL is passed through, not interpreted.** A profile carrying + `backend_url` gets exactly that string handed to Pulumi, so `s3://`, `gs://`, + `azblob://` and `file://` all work without this tool knowing which is which. + The exception is the optional *component* form (`bucket`, `prefix`, + `endpoint`, `region`), which is a convenience for S3-compatible storage and + can only ever compose an `s3://` URL — use `backend_url` for anything else. +- **Nobody's own infrastructure is named here.** No bucket, region, endpoint, + account, domain, namespace or model class appears anywhere in this + repository, and the hook and CI job described under *Development* below are + what enforce that. A few de-facto-standard names do appear, and none of them + is a choice this tool makes: `exec` and `shell` inject `AWS_ACCESS_KEY_ID` + and `AWS_SECRET_ACCESS_KEY`, because that is the vocabulary Pulumi's own DIY + backend reads out of a child process's environment; they also *remove* + `AWS_SESSION_TOKEN` and `AWS_PROFILE` from the child, because either one + sitting beside a freshly injected key pair contradicts it and the resulting + failure names neither; and the component form above composes an `s3://` URL, + because that is the scheme Pulumi uses for every S3-compatible store. - **Values reach the Pulumi CLI on stdin**, never in a command line, so they do not appear in `ps` or in shell history. - **Failures are closed.** If the tool cannot determine whether a file is safe, diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..31d8071 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,440 @@ +# stackward — implementation plan + +Task breakdown for the credential gate and the credential store. Each task +names the exact behaviour required; where a value is stated here, use it +verbatim rather than inventing an equivalent. + +## Global Constraints + +These bind every task. A change that violates one is wrong even if its own +tests pass. + +1. **Runtime dependencies are `pyyaml` and `cryptography`, and nothing else.** + Never import the Pulumi SDK — shell out to the `pulumi` binary, as the code + already does for `git`. CI fails on a new direct dependency. +2. **The gate resolves no credentials.** `check-config` and `pre-commit` read + files only. No code reachable from them may prompt, open a network + connection, or import a credential provider. This is what keeps a commit + from being blocked by an expired session, and it is asserted by a test. +3. **Fail closed.** If the tool cannot determine whether something is safe, it + exits non-zero and names the fix. It never passes because a check could not + run. A missing policy file is a refusal, not a skip. +4. **Never print a credential, and never print a denied term.** Report + locations — file, line, config path — not the matching text. Errors and + test failures included. +5. **No environment-specific knowledge.** No organisation, vendor, domain, + bucket, region, namespace or model-class name appears in this repository. + Everything specific to a user's environment arrives through + `.stackward.toml` or a file that config names. +6. **Values reach external commands on stdin**, never in `argv` — command + lines are visible in `ps` and shell history. +7. **Python 3.11+.** Use `tomllib` from the standard library; do not add a TOML + dependency. + +--- + +## Task 1: Path grammar + +**Goal.** One canonical representation for a config path, used by findings, +manifests and matching alike. Two grammars in one tool is why the same helper +gets written twice. + +**File.** `src/stackward/paths.py`, tests in `tests/test_paths.py`. + +**Requirements.** +- Follow Pulumi's `--path` syntax: dotted segments, `[n]` for list indices, and + `["..."]` for any key containing `.`, `[`, `]` or `"`. +- `parse(text) -> list[str | int]` — segments, list indices as `int`. +- `render(segments) -> str` — the inverse; bracket-quote only when required. +- `render(parse(x)) == x` for every canonical input, and + `parse(render(y)) == y` for every segment list. +- A key containing a quote is escaped as `\"` inside the bracket form. +- Malformed input raises `ValueError` naming the offending position. + +**Tests must cover.** A plain dotted path; a list index; a key containing a +dot; a key containing a bracket; a key containing a quote; an empty-string key; +a segment that looks like an integer but is a dict key (`["1"]` is the string +`"1"`, `[1]` is the index `1` — they must not collapse); round-trip both +directions; three distinct malformed inputs. + +--- + +## Task 2: Repository configuration + +**Goal.** Load and validate `.stackward.toml`, and enforce `min_version`. + +**File.** `src/stackward/config.py`, tests in `tests/test_config.py`. Move +`find_repo_config` out of `cli.py` into here and update its callers. + +**Requirements.** +- Parse with `tomllib`. Unknown top-level keys are an error naming the key — + a typo must not be silently ignored. +- Recognised: `profile`, `python`, `min_version`, and tables `[check]`, + `[secrets.*]`, `[secrets.source]`. +- `[check]` keys: `model_net` (`"artifact"` or `"none"`, default `"none"`), + `stack_models` (table of `"" = "module:Class"`), `sensitive_keys` (list, + **extends** the built-ins), `sensitive_parents` (list, **replaces** the + built-ins), `allowed_references` (list). The extend/replace asymmetry is + deliberate and must be documented in a comment where it is implemented. +- Built-in `sensitive_keys` default: `password`, `passphrase`, `token`, + `secret`, `apikey`, `api_key`, `jwt`, `credential`, `private_key`, + `access_key_id`, `secret_access_key`. +- `min_version` compares against `stackward.__version__` using a numeric + tuple comparison, not string ordering. If the installed version is lower, + exit non-zero naming the required version and the upgrade command — except + for the commands in `cli.VERSION_CHECK_EXEMPT`, which must still run. +- Config absent → return `None`; callers decide. Config present but invalid → + raise, never fall back to defaults. + +**Tests must cover.** Each recognised key; an unknown key erroring; the +extend-vs-replace asymmetry proven for both lists; `min_version` accepting an +equal and a higher installed version and rejecting a lower one; that +`"0.10.0" > "0.9.0"` compares correctly (string ordering gets this wrong); +exemption of `doctor` and `self-update`. + +--- + +## Task 3: The heuristic net and `check-config` + +**Goal.** Detect a plaintext credential in a stack config file. + +**Files.** `src/stackward/nets/heuristic.py`, `src/stackward/commands/check_config.py`, +tests in `tests/test_heuristic.py`. + +**Requirements.** +- Walk the parsed YAML structure. Do **not** re-parse rendered path strings — + a dict key may legally contain `.` or `[`, and string matching loses it. + Emit paths using Task 1's `render`. +- A leaf is a **finding** when its key matches `sensitive_keys` + (case-insensitive substring) **or** any ancestor key is in + `sensitive_parents`, unless it is encrypted or empty. +- **Encrypted** means the leaf's *parent dict* is exactly `{"secure": ...}` — + one key, named `secure`. Test the parent mapping, never + `path.endswith(".secure")`: a malformed `{"secure": ..., "other": ...}` is + not encrypted, and a suffix test would pass it. +- **Empty** means `value is None` or `value == ""`. Use identity for booleans: + `value is True` / `value is False` are not empty. In Python `0 == False` and + `1 == True`, so a containment test wrongly skips integer `0` and `1` — and + integer leaves occur in real config. Integers `0` and `1` **are** findings. +- `allowed_references` lists paths that name another secret rather than + holding one; they are never findings. +- `check-config FILE...` exits 0 when clean, 1 with findings, 2 on a usage or + parse error. Findings print as `: plaintext credential at ''`, + sorted, one per line. Never print the value. +- Unparseable YAML, or a YAML document that is not a mapping, is an error — + not "no findings". + +**Tests must cover.** Every rule above, each as its own test: a clean config; +a plaintext match by key; a match by sensitive parent; an encrypted leaf +passing; the malformed `{secure, other}` leaf being flagged; `None` and `""` +passing; `True`/`False` passing; integers `0`, `1` and a larger integer being +flagged; a dict key containing a `.` being reported with correct bracket +quoting; an `allowed_references` path passing; a non-mapping document erroring. + +--- + +## Task 4: `pre-commit` + +**Goal.** Run the gate over staged content, as a git hook body. + +**Files.** `src/stackward/commands/pre_commit.py`, tests in `tests/test_pre_commit.py`. + +**Requirements.** +- Select staged files with `git diff --cached --name-only --diff-filter=ACMR`. + `R` matters: a rename plus an edit stages as `R`, and omitting it lets that + change bypass the gate entirely. +- Match stack config files by basename: `Pulumi..yaml`, where `` + may contain dots (`Pulumi.prod.eu.yaml` is legal). Match `Pulumi.` prefix and + `.yaml` suffix, not a restrictive character class. +- Refuse outright — before any other check — if a Pulumi **state export** is + staged: a file named `state.json` or matching `*.stack-export.json`. These + carry every resource input including credentials. +- Check **staged** content, not the working tree: read each file with + `git show ":"`. An unstaged fix must not let a staged credential + through. +- Handle a path containing spaces or non-ASCII: use `-z` and split on NUL. +- An unmerged index entry, or a staged symlink whose blob is not YAML, is an + error, not a pass. +- Exit 0 clean, 1 blocked. On block, print the findings and the exact + `pulumi config set --secret --path ''` remediation, and state that + `--no-verify` bypasses. + +**Tests must cover.** Each rule, using a real temporary git repository (create +it in the test with `git init`; do not mock git). Include: a rename+edit +staged as `R` being caught; a staged state export being refused; the staged +version being checked rather than the working tree; a filename containing a +space. + +--- + +## Task 5: `hooks install` + +**Goal.** Install the `pre-commit` hook into a repository, safely. + +**Files.** `src/stackward/commands/install_hooks.py`, tests in +`tests/test_install_hooks.py`. + +**Requirements.** +- Resolve the hooks directory with `git rev-parse --git-path hooks`. Do not + hardcode `.git/hooks`: `core.hooksPath` is redirected by several common + tools, and installing where git does not look is a silent no-op. +- **Refuse if the resolved hooks directory is tracked by git** (test with + `git ls-files --error-unmatch ` or equivalent). Writing there commits a + hook that runs a binary other clones may not have installed. Print the + reason and suggest chaining from the tracked hook instead. +- The hook body contains **no logic**: it locates the repository and execs + `stackward pre-commit`. Embed the absolute path to the running executable, + with a `PATH` lookup as fallback. A bare `exec stackward` from a GUI client + with a minimal `PATH` exits 127 and blocks every commit. +- Mark the generated hook with a fixed marker line so it can be recognised + later. If an existing hook is present and lacks the marker, back it up to + `.backup.` rather than refusing or overwriting. +- `chmod 0o755` explicitly. +- Idempotent: installing twice leaves one hook and creates no second backup. + +**Tests must cover.** Each rule, against a real temporary git repository: +default location; a redirected `core.hooksPath`; refusal on a tracked hooks +directory; an existing foreign hook being backed up; idempotence; the mode +being `0o755`; the body containing an absolute path. + +--- + +## Task 6: Credential store — profiles and encryption + +**Goal.** One profile-scoped store for backend credentials, encrypted at rest. + +**Files.** `src/stackward/store.py`, `src/stackward/crypto.py`, tests in +`tests/test_store.py` and `tests/test_crypto.py`. + +**Requirements.** +- Location: `${XDG_CONFIG_HOME:-~/.config}/stackward/`, holding `config` + (TOML, mode 0o600, backend identity) and `credentials` + (mode 0o600, directory 0o700). + - **Revised during implementation**, from "mode 0o644, backend identity, no + credentials". Both halves were superseded by the requirement below that + `backend_url` be passed through **verbatim**: `postgres://user:password@host/db` + is a documented, supported backend form, so `config` can hold a credential + after all. Enforcing "no credentials" would mean parsing a URL this project + deliberately does not parse, and would reject a backend Pulumi accepts. + The consequences are carried instead: mode `0o600` (`store.CONFIG_MODE`), + and a parse failure reports a coordinate rather than tomllib's own message, + which quotes document text (`config.toml_position`). Recorded here rather + than corrected silently, because "no credentials" reads like a rule the + code broke when it is a premise the code disproved. +- `config` holds `default_profile` and `[profile.]` tables. A profile + carries **either** `backend_url` (passed through verbatim — `s3://`, `gs://`, + `azblob://`, `file://` must all work) **or** the component form `bucket`, + `prefix`, `endpoint`, `region` for an S3-compatible store. There is no + default backend: a profile is required and an absent one is an error. +- Profile selection precedence, first match wins: `--profile`, + `STACKWARD_PROFILE`, the repo config's `profile`, `default_profile`, then + error. Never guess. +- **Encryption**: one envelope per profile section. Argon2id for key + derivation (`cryptography.hazmat.primitives.kdf.argon2.Argon2id`) and + AES-256-GCM for sealing. Envelope is versioned JSON with fields `v`, `kdf`, + `salt`, `nonce`, `ct`, each base64. A fresh random salt and nonce per seal. +- **The profile name is the AAD**, so an envelope cannot be moved between + profiles undetected. +- A **store-level verifier**: a known-plaintext envelope written at init, so a + mistyped password fails immediately rather than weeks later at first use. +- Password rotation re-seals **every** envelope or none. +- All writes are atomic: write to a temporary file in the same directory, + `fsync`, set the mode, then `rename`. A crash must not leave a truncated + credentials file. +- Warn when a file's mode is more permissive than required. + +**Tests must cover.** Seal/open round trip; a wrong password failing cleanly +via the GCM tag rather than returning garbage; an envelope resealed under +profile A failing to open as profile B (AAD); the ciphertext containing none +of the plaintext (assert a known value's bytes are absent); rotation being +all-or-nothing under an induced failure; atomic write leaving the original +intact when the write fails; every precedence branch of profile selection; +the absent-profile error. + +--- + +## Task 7: `login`, `exec`, `shell` + +**Goal.** Point Pulumi at a profile's backend, and run commands with that +profile's credentials in the environment. + +**Files.** `src/stackward/commands/session.py`, tests in `tests/test_session.py`. + +**Requirements.** +- `pulumi login` persists only the backend URL. `AWS_ACCESS_KEY_ID`, + `AWS_SECRET_ACCESS_KEY` and `PULUMI_CONFIG_PASSPHRASE` are read from the + environment of **every subsequent** `pulumi` process. A store only + `stackward` can read therefore breaks a plain `pulumi up`, and `exec` is what + makes the store usable at all. +- `stackward exec -- ` decrypts the profile, injects those three + variables **and** `PULUMI_BACKEND_URL`, and runs the command as a child. + Secrets exist only in the child's environment; never write them to disk, + and never log them. +- `stackward shell` is the same with the user's `$SHELL` as the command. +- `stackward login` resolves the profile's backend URL and runs + `pulumi login `. +- **Backend guard:** before any mutating command, compare the resolved + profile's backend URL with Pulumi's current backend; refuse on mismatch, + naming both. This is what makes profiles safe rather than merely convenient. +- Propagate the child's exit status exactly. +- `--` terminates option parsing; `exec` with no command is a usage error. + +**Tests must cover.** The injected environment containing exactly the expected +keys (assert the values are passed but never logged); exit-status propagation +including a non-zero and a signal; the backend guard refusing a mismatch and +allowing a match; `exec` with no command erroring; that no secret appears in +captured stdout or stderr on any path. + +--- + +## Task 8: `set-secrets` + +**Goal.** Publish stack secrets from a declared manifest, values on stdin. + +**Files.** `src/stackward/commands/set_secrets.py`, tests in +`tests/test_set_secrets.py`. + +**Requirements.** +- Manifest lives in `.stackward.toml` under `[secrets.""]` with + `secret`, `plaintext` and `unmanaged` tables mapping config path → logical + name. A single-project repository uses one table; project directories are + always repository-root-relative. +- Value sources, precedence highest first: the process environment, then each + file in `[secrets.source].files` in order, later overriding earlier. + `{stack}` in a filename is substituted. +- Parse `KEY=VALUE` files: ignore blanks and `#` comments, strip a leading + `export `, strip one matched pair of surrounding quotes. Warn when such a + file's mode is more permissive than 0o600. +- Values are passed to `pulumi config set --secret --path ` **on stdin**. +- A name whose value is unset is **skipped, never cleared** — the command only + ever touches what it was given a value for. +- `--dry-run` reports what would be set without running anything. +- `--stack S` is forwarded to `pulumi`; omitted when not given. +- Paths listed under `[required]` cause a non-zero exit when unresolved. +- **Drift detection**: for declared `[[drift_pairs]]` of a bootstrap and a + managed variable, compare the local bootstrap value against the published + value (read with `pulumi config get`, which decrypts — `pulumi config` list + does not, and exits 0 under any passphrase). Warn only when they differ and + the published value was readable. Never print either value. +- A timeout or a missing `pulumi` binary is reported and returns failure for + that entry without aborting the run mid-rotation; the summary names what was + not set. + +**Tests must cover.** Precedence across environment and two files; skip-if-unset; +`--dry-run` writing nothing; the value reaching the subprocess via stdin and +**not** via `argv`; a required unresolved path failing; drift warning firing on +a difference and staying silent when equal or unreadable; a subprocess timeout +being reported without aborting the remaining entries; that no test prints a +value. + +--- + +## Task 9: `check-passphrase` + +**Goal.** Prove a passphrase actually decrypts a stack, which no ordinary +`pulumi` command does. + +**Files.** `src/stackward/commands/check_passphrase.py`, tests in +`tests/test_check_passphrase.py`. + +**Requirements.** +- Read the passphrase from stdin, falling back to `getpass` when stdin is a + TTY. Never accept it as an argument. +- Obtain a stack's state with `pulumi stack export --stack ` and read + `deployment.secrets_providers.state.salt`, whose format is + `v1::v1::`. +- Derive with **PBKDF2-HMAC-SHA256, 1,000,000 iterations, 32-byte key** — this + must match Pulumi's own scheme, and is deliberately different from the + Argon2id used for our own store, which is not interoperating with anything. +- Open with AES-GCM; the plaintext is the literal `b"pulumi"`. Report per + stack: accepted or rejected. +- Exit 0 only if every named stack accepted. +- Report a fingerprint (first 16 hex of SHA-256) rather than any part of the + passphrase, so two candidates can be told apart in a log safely. + +**Tests must cover.** A synthesised salt that the correct passphrase opens; the +same salt rejecting a wrong passphrase; a malformed salt string erroring rather +than crashing; the passphrase never appearing in output; the fingerprint being +stable and 16 characters. + +--- + +## Task 10: The model net + +**Goal.** Detect a credential the heuristic cannot name, using declarations +carried in a committed artifact. + +**Files.** `src/stackward/nets/model.py`, `src/stackward/bootstrap/regen.py`, +`src/stackward/commands/sync_declared.py`, tests in `tests/test_model_net.py`. + +**Background that determines the design.** A shipped binary bundles its own +interpreter and cannot import the consuming repository's classes. The +declarations must therefore cross that boundary as data. The obvious form — a +flat list of path patterns — is wrong: a self-referential model yields a +finite pattern list while the data it describes is unbounded, so the flat form +fails **open** exactly where it must fail closed. + +**Requirements.** +- The artifact is a **graph**, not a pattern list: models → fields → marks → + child-model references, written to `.stackward/declared-secrets.json`. The + matcher walks the graph and the data together, so recursion and mutual + recursion work by construction. +- Marking convention, published as this tool's contract: + `json_schema_extra={"secret": True}` on a pydantic field. `[check].declared_paths_fn` + overrides it for a non-standard convention. +- A marked field covers **everything beneath it**, whatever its annotation — + matching is prefix-based, not exact. +- `sync-declared-secrets` shells out to the interpreter named by `python` in + the repo config, imports the declared classes, and writes the artifact. It + **refuses** any annotation containing a model that it cannot walk — + `Union[A, B]`, `Sequence[A]`, `Mapping[str, A]`, bare `dict` — rather than + silently emitting nothing for it. +- Record a `sources` map: every file contributing a marked field, by git blob + id, walking the **MRO** so a mark inherited from a base class in another + file is captured. For modules outside the repository, record the dependency + lockfile's hash instead. +- Freshness: `check-config` reads the artifact **from the git index** + (`git show ":.stackward/declared-secrets.json"`), not the working tree — an + unstaged regeneration must not let a stale committed artifact pass. A blob-id + mismatch is a hard failure naming the regeneration command. +- `model_net = "none"` is a declared mode. It is never a fallback: a missing or + stale artifact under `"artifact"` mode fails, it does not degrade. + +**Tests must cover.** Synthetic pydantic models only (pydantic is a test-only +dependency). Include: a self-referential model (`Node` with `children: +list[Node]`) matching at depth; mutual recursion; a mark inherited from a base +class in another module; a mark carried by an `Annotated` alias defined +elsewhere; prefix coverage of a marked container's descendants; a dict key +containing `.`; refusal of each unwalkable annotation; a stale artifact +failing; `"none"` mode skipping cleanly. + +--- + +## Task 11: Provider interface + +**Goal.** Make the credential source swappable, so adopting a managed secret +store is a configuration change rather than a migration. + +**Files.** `src/stackward/providers/__init__.py` and one module per provider, +tests in `tests/test_providers.py`. + +**Requirements.** +- Two roles. `CredentialStore` resolves a profile's bootstrap set; + implementations: `file` (Task 6's encrypted store) and `env`. + `SecretSource` resolves manifest values; implementations: `dotenv` and `env`. +- Selection is by `provider = "..."` in the profile's `credentials` table and + in `[secrets.source]` respectively. +- A remote provider shells out to its CLI. Adding one must not add a Python + dependency. +- **No provider module may be imported from the gate path.** Enforce with a + test that imports the `check-config` entry point in a subprocess and asserts + no provider module appears in `sys.modules`. +- Fetched values are held in memory for the invocation only; never written to + disk. + +**Tests must cover.** The same operation producing identical results and +identical subprocess invocations against two different providers seeded with +the same values — this is the "swapping the provider changes nothing for the +caller" guarantee, and asserting it is what makes it true; the gate-path import +test; that a provider failure is reported rather than silently yielding an +empty value. diff --git a/pyproject.toml b/pyproject.toml index 31558e0..bed4123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,14 @@ license = { text = "Apache-2.0" } # pull in grpcio and friends for no benefit. dependencies = [ "pyyaml>=6.0", - "cryptography>=42.0", + # 44.0 is the floor because it is the first release shipping + # `cryptography.hazmat.primitives.kdf.argon2`, which `stackward.crypto` + # imports at module scope. Established by installing each release into a + # throwaway environment and importing it — 42.0.0, 43.0.0 and 43.0.3 all + # raise ModuleNotFoundError, 44.0.0 imports and derives — not from the + # changelog and not from memory. Below this floor the credential store does + # not degrade at runtime: the module does not load at all. + "cryptography>=44.0", ] [project.scripts] diff --git a/scripts/check-bundle.sh b/scripts/check-bundle.sh new file mode 100755 index 0000000..26890d4 --- /dev/null +++ b/scripts/check-bundle.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Prove that a built bundle actually works. Takes the binary to check as $1. +# +# **One definition, run by both release jobs.** `build` runs it against what +# it has just produced; `publish` runs it against the artifact it is about to +# release, unpacked exactly as a user would. A second, separately-maintained +# copy is precisely how the publish job came to smoke-test only `--version` +# and `doctor` -- the two commands registered directly rather than through +# `cli._dispatch`, and so the only two that kept passing while every lazily +# dispatched command was missing from the binary entirely. The bundle that +# regression would have shipped passes a `--version`/`doctor` check by +# construction. +# +# bash, not sh: `set -o pipefail` is not POSIX and is silently absent under +# dash, which would let the `doctor | tee` pipeline below report the exit +# status of `tee`. +set -euo pipefail + +bundle="${1:?usage: check-bundle.sh }" +# Resolved to an absolute path once, here: the checks below run from +# elsewhere on purpose, and each caller invokes this from a different +# directory. +case "$bundle" in + /*) ;; + *) bundle="$PWD/$bundle" ;; +esac + +echo "Checking bundle: $bundle" + +# A bundle that builds is not a bundle that works. doctor performs a real +# Argon2id + AES-GCM round trip, so this catches a native extension that +# failed to freeze -- the classic failure of this route. +# +# doctor also reads `regen.py` back out of the bundle through the same +# accessor `sync-declared-secrets` uses. That file is carried by the +# `--add-data` entry in the build job rather than by import analysis, so +# dropping it would produce a binary that builds, starts, and then fails at +# the one command that needs it -- in the shipped artifact and nowhere else. +# doctor exits non-zero on that, and the grep states the expectation so a +# future edit to doctor's output cannot silently retire this check. +"$bundle" --version + +# A temporary file, not `doctor.txt` in the working directory: one caller +# runs this from the checkout and the other from the directory it unpacked +# the release artifacts into. +# +# Every `mktemp` here passes an explicit `X`-suffixed template, which GNU and +# BSD both accept. The build matrix includes a macOS runner and this workflow +# only ever fires on a tag, so a bare `mktemp` that BSD rejects would first be +# discovered during a release. +doctor_out="$(mktemp "${TMPDIR:-/tmp}/stackward-doctor.XXXXXX")" +trap 'rm -f "$doctor_out"' EXIT +"$bundle" doctor | tee "$doctor_out" +grep -q "^model walker regen.py readable" "$doctor_out" + +# A lazily dispatched command must actually load in the bundle. +# `cli._dispatch` imports each command module only when its subcommand runs, +# and PyInstaller can only see an import it can read as a literal statement: +# while that dispatch named its module dynamically, every one of these +# commands was missing from the binary and died on `ModuleNotFoundError` with +# a traceback and exit code 1 -- the code reserved for "a credential was +# found" -- while `--version` and `doctor` kept passing this very check. +# +# Two commands, from two different modules, and neither needs a credential, a +# store or a network: each refuses with exit code 2 after its module has +# loaded, which is exactly the evidence wanted. `set -e` is off for these +# because a non-zero exit is the expected outcome. +# +# Run from an empty directory, not the caller's: `cli.main` enforces a +# repository's `min_version` floor *before* dispatch, so a `.stackward.toml` +# above the working directory could make both commands exit 2 without either +# module ever loading -- the check would then pass on exactly the evidence it +# exists to reject. That floor is now read on its own, past any key the +# binary does not recognise (`config.read_min_version`), so an *invalid* +# config above the working directory can produce the refusal too, which makes +# the empty directory load-bearing rather than merely tidy. +for command in "credentials list" "exec"; do + # Made before `set -e` is lifted: a `mktemp` that fails is a broken runner, + # not evidence about the bundle, and must not be read as one. + probe="$(mktemp -d "${TMPDIR:-/tmp}/stackward-probe.XXXXXX")" + set +e + # shellcheck disable=SC2086 + out="$(cd "$probe" && XDG_CONFIG_HOME="$PWD" "$bundle" $command 2>&1)" + code=$? + set -e + if [ "$code" -ne 2 ]; then + echo "::error::\`stackward $command\` exited $code, expected 2" >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + rm -rf "$probe" + case "$out" in + *ModuleNotFoundError*|*Traceback*|*"invalid choice"*) + # "invalid choice" is the other way this passes for the wrong reason: + # an unregistered subcommand also exits 2, from argparse, having loaded + # nothing at all. + echo "::error::\`stackward $command\` did not load in the bundle" >&2 + printf '%s\n' "$out" >&2 + exit 1 + ;; + esac +done + +echo "Bundle check passed: $bundle" diff --git a/src/stackward/bootstrap/__init__.py b/src/stackward/bootstrap/__init__.py new file mode 100644 index 0000000..9af32a0 --- /dev/null +++ b/src/stackward/bootstrap/__init__.py @@ -0,0 +1,38 @@ +"""Source that is shipped to be *run by another interpreter*, not imported. + +`stackward` may run as a frozen binary carrying its own interpreter, which +cannot import the consuming repository's classes — so the one step that must +import them, generating the declared-secrets artifact, runs under the +repository's own interpreter instead (`[python]` in `.stackward.toml`). That +step needs pydantic; `stackward` itself must never import or bundle it. + +The two requirements together are what this package exists for. `regen.py` is +a real, standalone module — readable, lintable, and directly runnable — but +`stackward` only ever reads it as **text** and pipes it to the repository's +interpreter. Nothing in `stackward` imports it, so PyInstaller's analysis +never follows it, and pydantic never enters the bundle. + +Because it is data rather than an imported module, the frozen build has to +carry it explicitly: `.github/workflows/release.yml` passes +`--add-data src/stackward/bootstrap/regen.py:stackward/bootstrap`, and +`generator_source` reads it back through `importlib.resources`, which +resolves both an ordinary installation and a PyInstaller bundle. That +round trip was verified against a real frozen bundle rather than assumed. +""" + +from __future__ import annotations + +import importlib.resources + +GENERATOR_NAME = "regen.py" + + +def generator_source() -> str: + """The generator program's text, for the repository's interpreter. + + The single accessor for it, used by `commands.sync_declared` and by the + tests alike — a test that read the file by path instead would assert + nothing about the frozen build, where that path does not exist. + """ + resource = importlib.resources.files(__package__).joinpath(GENERATOR_NAME) + return resource.read_text(encoding="utf-8") diff --git a/src/stackward/bootstrap/regen.py b/src/stackward/bootstrap/regen.py new file mode 100644 index 0000000..76cbebf --- /dev/null +++ b/src/stackward/bootstrap/regen.py @@ -0,0 +1,683 @@ +"""Generate the declared-secrets graph. Runs under the *repository's* +interpreter, never under `stackward`'s own. + +This program is never imported by `stackward`. `commands.sync_declared` reads +it as text (see `bootstrap.generator_source`) and pipes it to the interpreter +named by `[python]` in `.stackward.toml`, which is the only interpreter that +can import the repository's models. That is what keeps pydantic a test-only +dependency of `stackward` itself: the half that needs pydantic runs somewhere +else, and only JSON crosses back. + +It reads a request as JSON in `argv[1]` and writes the graph as JSON to the +file that request names (see `main` for why not stdout). It resolves no +credentials, reads no secret store and contacts nothing — it imports declared +classes and inspects their fields. + +**It refuses rather than emitting nothing.** Silently skipping an annotation +this program cannot walk is the fail-open the whole model-net design exists +to prevent: the artifact would look complete while covering less than the +repository declared. So an *unmarked* field whose annotation could hold a +model but is not one of the walkable forms is an error naming the field and +the two ways to clear it. A *marked* field's annotation is never examined at +all — coverage is prefix-based, so everything beneath a mark is covered +whatever its type. + +The walkable forms are exactly: a `BaseModel` subclass, `list[X]`, +`dict[str, X]`, `Optional[X]` / `X | None`, and any annotation that provably +cannot hold a model (`str`, `int`, an `Enum`, `Literal[...]`, `list[str]`, +`Sequence[str]`, and so on — these become leaves, with nothing beneath them +to declare). Everything else is refused: `Union[A, B]`, `Sequence[A]`, +`Mapping[str, A]`, `set[A]`, `tuple[A, ...]`, a bare `dict` or `list`, `Any` +and `object` — the last four because "could this hold a model?" has no +answer for them, and an unanswerable question must not be answered "no". +""" + +from __future__ import annotations + +import inspect +import json +import sys +import types +import typing +from pathlib import Path +from typing import Any, NoReturn + +# Containers written without parameters. Whether one holds a model is not +# knowable from the annotation, so an unmarked field annotated with one is +# refused rather than guessed at. +_BARE_CONTAINERS = (dict, list, set, frozenset, tuple) + +# Union spellings: `typing.Union[...]` and PEP 604's `X | Y`, which +# `get_origin` reports as `types.UnionType` rather than `typing.Union`. +_UNION_ORIGINS = (typing.Union, types.UnionType) + + +class Unwalkable(Exception): + """An annotation, alias form or model this program refuses to guess at.""" + + +def _die(message: str) -> NoReturn: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def _strip_annotated(annotation: Any) -> Any: + """`Annotated[X, ...]` down to `X`. + + pydantic already strips the top-level `Annotated` off `FieldInfo.annotation` + (which is how a mark carried by an alias defined in another module lands + on the field at all), but a nested one — `list[Annotated[X, ...]]` — is + still there, so the walk strips as it descends rather than once at entry. + """ + while hasattr(annotation, "__metadata__") and hasattr(annotation, "__origin__"): + annotation = annotation.__origin__ + return annotation + + +def _is_model(annotation: Any) -> bool: + from pydantic import BaseModel + + return isinstance(annotation, type) and issubclass(annotation, BaseModel) + + +def _is_root_model(model: type) -> bool: + """A model whose whole value *is* its single `root` field. + + Refused, because its coverage is unknowable rather than empty. In the + document such a model appears as the inner value directly, with no `root` + key — so walking it as an ordinary model would look up field names that + can never occur, produce no coverage at all, and say nothing about having + done so. That is exactly the silent under-coverage this artifact exists to + eliminate, so it gets the same treatment as an unwalkable annotation. + + Walking one correctly is not the small change it looks. A mark on the + `root` field means the whole value at the *parent* position is covered, + and there is no way to say that in this schema when the model is reached + through a `list[...]` or `dict[str, ...]` — the mark would have to + propagate through a container node, which carries no `secret` of its own. + Refusing is the honest answer until the schema can express it. + + Imported defensively: `RootModel` exists in every pydantic 2.x, but a + generator that crashed on an unexpected import would fail *less* clearly + than one that reports what it could not decide. + """ + try: + from pydantic import RootModel + except ImportError: # pragma: no cover - pydantic 2.x always has it + return False + return isinstance(model, type) and issubclass(model, RootModel) + + +def _declares_fields(klass: type) -> bool: + """Does `klass` declare fields of its own? + + True when it carries at least one annotation of its own whose name does + not start with an underscore. That is pydantic's own rule, not an + approximation of it: an underscore-prefixed annotated name is a *private + attribute*, which pydantic never collects as a field, so it can never + carry a mark under this tool's convention either. + + The distinction is what separates a structure from a scalar without + enumerating either. A pydantic dataclass, a `TypedDict`, a `NamedTuple` + and a plain annotated mixin all declare public annotations; `str`, an + `Enum`, `Decimal`, `UUID`, `datetime` and `abc.ABC` declare none, and + pydantic's own scalar wrappers (`AnyUrl`, `SecretStr`) declare only + private ones. Refusing that second group would make the rule unusable — + "narrow the annotation" is not even available for `AnyUrl`. + + Read from `__dict__` rather than the attribute: since Python 3.10, + `klass.__annotations__` lazily *creates* an empty dict on a class that + has none, and on some builtins raises instead. + + Known conservative case, stated rather than hidden: a class whose only + annotations are `ClassVar` declares no fields to pydantic but does to + this rule, so it refuses. That fails closed with an actionable message, + and detecting `ClassVar` through a possibly-stringised annotation would + be a guess where this is a fact. + """ + annotations = klass.__dict__.get("__annotations__") + if not annotations: + return False + return any(not name.startswith("_") for name in annotations) + + +def _declares_fields_anywhere(klass: type) -> bool: + """`_declares_fields` over the whole MRO — a class inherits its shape.""" + for base in getattr(klass, "__mro__", (klass,)): + if base is not object and _declares_fields(base): + return True + return False + + +def _model_id(model: type) -> str: + """`module:QualName` — stable, readable, and meaningful in a diff of the + committed artifact, which synthetic indices would not be.""" + return f"{model.__module__}:{model.__qualname__}" + + +def _may_hold_model(annotation: Any) -> bool: + """Could a value described by `annotation` contain a declared model? + + True also when the answer is *unknown* — `Any`, `object`, a bare + container, an unresolved forward reference, an annotation form this + program does not recognise. That direction is the whole point: an + unanswerable question is answered "yes, refuse", never "no, emit + nothing". + """ + annotation = _strip_annotated(annotation) + if annotation is None or annotation is type(None) or annotation is Ellipsis: + return False + if annotation is Any or annotation is object: + return True + if isinstance(annotation, (str, typing.ForwardRef)): + # An unresolved forward reference: the model was never rebuilt, so + # what it refers to cannot be inspected here. + return True + if _is_model(annotation): + return True + if annotation in _BARE_CONTAINERS: + return True + + origin = typing.get_origin(annotation) + if origin is typing.Literal: + # Literal's arguments are *values*, not types; none can be a model. + return False + args = typing.get_args(annotation) + if origin is not None: + # A parameterised generic: recurse. An unparameterised one + # (`typing.Dict`) has an origin but no arguments, and is as unknowable + # as a bare `dict`. + return True if not args else any(_may_hold_model(arg) for arg in args) + if isinstance(annotation, (list, tuple)): + # `Callable[[A], B]` carries its parameter types in a list. + return any(_may_hold_model(item) for item in annotation) + if isinstance(annotation, type): + # The last branch, and the one a blanket `False` gets catastrophically + # wrong. A pydantic dataclass, a `TypedDict` and a `NamedTuple` are + # none of the things tested above — not a `BaseModel`, no + # `get_origin`, not a bare container — so they all arrive here, and + # answering "no" would drop every marked field inside them from the + # graph with no refusal: the whole category silently uncovered. Only a + # class that declares no fields of its own anywhere in its MRO is a + # leaf; see `_declares_fields`. + return _declares_fields_anywhere(annotation) + return True + + +class _GraphBuilder: + """Walks declared classes into the graph the matcher reads. + + Holds the model worklist so recursion and mutual recursion terminate: + a model already in `models` is referenced by id and not re-walked, which + is safe precisely because the artifact is a graph — the matcher follows + the reference as many times as the *data* requires. + """ + + def __init__(self, is_secret: Any) -> None: + self.is_secret = is_secret + self.models: dict[str, dict[str, dict[str, Any]]] = {} + self.classes: dict[str, type] = {} + + def add(self, model: type) -> str: + model_id = _model_id(model) + if model_id in self.models: + return model_id + if _is_root_model(model): + # Checked here rather than at each call site, so a root model + # reached as a declared root and one reached through a field are + # refused by the same line. See `_is_root_model`. + raise Unwalkable( + f"{model_id}: a RootModel appears in a document as its inner " + "value, with no field name to match, so any mark it carries " + "would cover nothing. Declare an ordinary BaseModel with named " + "fields, or mark the field that holds it " + "(json_schema_extra={'secret': True}), which covers everything " + "beneath it whatever its type" + ) + # Registered before its fields are walked, so a self-reference + # resolves to this same id instead of recursing forever. + self.models[model_id] = {} + self.classes[model_id] = model + self.models[model_id] = self._fields(model, model_id) + return model_id + + def _fields(self, model: type, model_id: str) -> dict[str, dict[str, Any]]: + fields: dict[str, dict[str, Any]] = {} + # Every key any field could appear under, whether or not that field + # ends up emitted. Tracked separately from `fields` because an entry + # is only stored when it says something (see below): keying the + # collision check on `fields` alone made it order-dependent — an + # unmarked scalar never claimed its key, so a later marked field + # aliased to that name took it silently, while the same two fields + # declared in the other order refused. + claimed: set[str] = set() + marked = self.is_secret(model) + for name, info in model.model_fields.items(): + where = f"{model_id}.{name}" + entry: dict[str, Any] = {} + if name in marked: + # A marked field's annotation is never examined: coverage is + # prefix-based, so everything beneath it is covered whatever + # it is, and refusing an unwalkable annotation there would + # refuse a declaration that is already complete. + entry["secret"] = True + else: + child = self._resolve( + info.annotation, where, model_id, info.annotation + ) + if child is not None: + entry["child"] = child + for key in _data_keys(name, info, where): + if key in claimed: + raise Unwalkable( + f"{where}: data key {key!r} is claimed by more than one " + "field; give the fields distinct names or aliases" + ) + claimed.add(key) + if entry: + # A field with neither a mark nor a model beneath it is + # omitted rather than written as `{}`: the matcher treats + # "absent" and "declares nothing" identically, and a model + # with fifty scalar fields would otherwise bury the marks — + # the only thing this artifact exists to carry — under + # forty-nine empty objects in every review of it. + fields[key] = entry + return fields + + def _resolve( + self, annotation: Any, where: str, model_id: str, written: Any + ) -> dict[str, Any] | None: + """One annotation, as a graph node — or `None` when nothing beneath it + is a declared model, which is the common case and is written as the + absence of a `child` entry.""" + annotation = _strip_annotated(annotation) + if annotation is None or annotation is type(None): + return None + if _is_model(annotation): + return {"kind": "model", "model": self.add(annotation)} + + origin = typing.get_origin(annotation) + args = typing.get_args(annotation) + + if origin in _UNION_ORIGINS: + present = [a for a in args if a is not type(None)] + if len(present) == 1: + # `Optional[X]` / `X | None`: one real member, so there is + # exactly one thing to walk. `None` itself is not a mapping + # and has nothing beneath it. + return self._resolve(present[0], where, model_id, written) + if any(_may_hold_model(member) for member in present): + raise Unwalkable( + f"{where}: a union of more than one type cannot be walked, " + "because which member a value belongs to is not knowable " + "from the annotation" + ) + return None + if origin is list and len(args) == 1: + item = self._resolve(args[0], where, model_id, written) + return None if item is None else {"kind": "list", "item": item} + if origin is dict and len(args) == 2 and _strip_annotated(args[0]) is str: + value = self._resolve(args[1], where, model_id, written) + return None if value is None else {"kind": "dict", "value": value} + + if _may_hold_model(annotation): + # Names the field, its class and the annotation itself, then every + # way forward. A refusal a reader cannot act on is a refusal that + # gets silenced with model_net = "none", which loses the whole net + # -- and first adoption of this tool is exactly when several of + # these arrive at once. + raise Unwalkable( + f"{where}: annotation {_describe(written)}" + f"{_at(written, annotation)} may hold a " + f"declared model, and {model_id} cannot be walked through it. " + "Walkable forms are a model, list[X], dict[str, X] and " + "Optional[X]. Three ways forward: narrow the annotation to one " + "of those; or mark the field " + "(json_schema_extra={'secret': True}), which covers everything " + "beneath it whatever its type; or, if this repository's marking " + "convention is not this tool's, declare " + "check.declared_paths_fn" + ) + return None + + +def _describe(annotation: Any) -> str: + """An annotation as a reader would recognise it in their own source. + + `repr` on a bare class is ``, which reads as noise in a + message whose whole job is to point at a line of code; typing constructs + already repr as they were written. Carries type names from the consuming + repository, which are not credentials -- the same category as the module + and class names its `.stackward.toml` already states in the clear. + """ + if isinstance(annotation, type): + return getattr(annotation, "__qualname__", None) or repr(annotation) + return repr(annotation) + + +def _at(written: Any, offending: Any) -> str: + """` (at X)`, when the part that refused is not the whole annotation. + + The message has to name the annotation as the reader *wrote* it — + `dict[str, Any]` — or they are sent looking for a line that says `Any` + and does not exist. Naming the offending part as well keeps the precision + that made the inner-only message tempting. + """ + if written is offending: + return "" + return f" (at {_describe(offending)})" + + +def _data_keys(name: str, info: Any, where: str) -> list[str]: + """Every key this field may appear under in a stack config document. + + The field's own name plus any string alias. Keying only by field name + would miss an aliased field silently, which is a fail-open; keying by + every name it could carry over-matches at worst, and an over-match is + suppressed by the same encrypted/empty rules as any other leaf. + + A non-string `validation_alias` (`AliasChoices`, `AliasPath`) is refused, + marked field included: without a determinable data key there is no + position at which to apply coverage at all, and a mark that covers + nothing is worse than one that does not exist. + """ + keys = [name] + for attribute in ("alias", "validation_alias", "serialization_alias"): + value = getattr(info, attribute, None) + if value is None: + continue + if not isinstance(value, str): + raise Unwalkable( + f"{where}: {attribute} is not a plain string, so the key this " + "field appears under is not knowable; use a string alias" + ) + if value not in keys: + keys.append(value) + return keys + + +def _standard_marks(model: type) -> set[str]: + """This tool's published marking convention: + `json_schema_extra={"secret": True}`. + + A callable `json_schema_extra` is refused rather than ignored — its result + depends on a schema-generation context that does not exist here, so + whether the field is marked cannot be determined, and "cannot determine" + must not become "not marked". + """ + marked: set[str] = set() + for name, info in model.model_fields.items(): + extra = getattr(info, "json_schema_extra", None) + if extra is None: + continue + if callable(extra): + raise Unwalkable( + f"{_model_id(model)}.{name}: json_schema_extra is a callable, so " + "whether this field is marked cannot be determined here; use a " + "plain dict, or declare check.declared_paths_fn" + ) + if isinstance(extra, dict) and extra.get("secret") is True: + marked.add(name) + return marked + + +def _custom_marks(function: Any) -> Any: + """`check.declared_paths_fn` as a marking-convention override. + + Called as `fn(model_class)` and must return the *field names* that model + marks as secret. Field names, not paths: the graph is what makes + recursion work, and a hook returning flat paths would reintroduce exactly + the enumeration this design replaced — so a returned string containing + `.` or `[` is refused rather than interpreted. A name that is not a field + of the model is refused too, since a typo that marked nothing would be + indistinguishable from a field deliberately left unmarked. + """ + + def marks(model: type) -> set[str]: + try: + returned = function(model) + except Exception as exc: # noqa: BLE001 - the hook is repository code + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn raised " + f"{type(exc).__name__}: {exc}" + ) from exc + names: set[str] = set() + for item in returned: + if not isinstance(item, str): + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn returned a " + f"{type(item).__name__}; it must return field names" + ) + if "." in item or "[" in item: + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn returned " + f"{item!r}; it must return field names, not paths" + ) + if item not in model.model_fields: + raise Unwalkable( + f"{_model_id(model)}: check.declared_paths_fn returned " + f"{item!r}, which is not a field of this model" + ) + names.add(item) + return names + + return marks + + +def _import_object(target: str, what: str) -> Any: + module_name, _, attribute = target.partition(":") + try: + module = __import__(module_name, fromlist=["*"]) + except Exception as exc: # noqa: BLE001 - repository code, any failure + raise Unwalkable( + f"{what} {target!r}: cannot import {module_name!r}: " + f"{type(exc).__name__}: {exc}" + ) from exc + try: + return getattr(module, attribute) + except AttributeError as exc: + raise Unwalkable( + f"{what} {target!r}: {module_name!r} has no {attribute!r}" + ) from exc + + +def _annotated_aliases(model: type) -> list[Any]: + """Every `Annotated[...]` object appearing in `model`'s own annotations. + + Collected from the *raw* hints rather than from `FieldInfo`, because + pydantic resolves an alias's mark onto the field and discards the wrapper + — which is exactly why an alias defined in another module can change a + mark with the model's own file untouched. The objects collected here are + matched by identity against module globals in `_collect_sources`, which + is what finds the module that defines the alias. + """ + try: + hints = typing.get_type_hints(model, include_extras=True) + except Exception as exc: # noqa: BLE001 - repository code, any failure + raise Unwalkable( + f"{_model_id(model)}: annotations cannot be resolved " + f"({type(exc).__name__}: {exc}), so the modules contributing them " + "cannot be recorded; a model whose sources cannot be recorded " + "cannot be checked for staleness" + ) from exc + + found: list[Any] = [] + seen: set[int] = set() + + def visit(annotation: Any) -> None: + if id(annotation) in seen: + return + seen.add(id(annotation)) + if hasattr(annotation, "__metadata__"): + found.append(annotation) + for argument in typing.get_args(annotation): + visit(argument) + + for hint in hints.values(): + visit(hint) + return found + + +def _inside(source: Any, repo_root: Path) -> bool: + try: + Path(source).resolve().relative_to(repo_root) + except ValueError: + return False + return True + + +def _collect_sources( + classes: list[type], extra_objects: list[Any], repo_root: Path +) -> tuple[list[str], list[str]]: + """Every file and out-of-repo module that contributed a mark. + + Two mechanisms, because a mark can arrive two ways that neither one alone + sees: + + - the **MRO** of every reachable model, so a mark inherited from a base + class in another file is recorded. `getsourcefile(cls)` alone gives + only the subclass's own file, which is the case this closes. The walk + is *not* restricted to `BaseModel` subclasses: pydantic collects a + marked field from a plain `class Mixin:` into + `Root(Mixin, BaseModel).model_fields`, so a filter keyed on + `issubclass(base, BaseModel)` takes the mark and discards the file it + came from. + - **identity** of every `Annotated` alias used by those models, matched + against the globals of every imported module. An alias is a module-level + object, and the annotation resolves to that very object, so the module + holding it can be found this way and no other — nothing on the resolved + field records where the alias was written. + + `pydantic.BaseModel` itself is excluded from the MRO walk. Including it + would make pydantic an out-of-repo contributor for *every* repository, + and so make a tracked lockfile a universal precondition, for no signal: + the marks this tool reads are its own convention, not pydantic's. Every + *other* base is recorded when it is repo-local (free — the blob id is in + the index already) and, when it is not, only if it declares fields of its + own. Without that second condition `class Config(BaseModel, ABC)` would + record `abc` and reimpose the very lockfile precondition the `BaseModel` + exclusion exists to avoid. + + A module with no `__file__` (a builtin, a namespace package, this program + itself) is skipped: there is nothing to record a blob id for. That is a + stated boundary of the blob-id layer, not a claim it does not exist. + """ + from pydantic import BaseModel + + files: set[str] = set() + external: set[str] = set() + + def record(source: Any, module_name: str) -> None: + if not source: + return + if _inside(source, repo_root): + files.add(str(Path(source).resolve())) + else: + external.add(module_name) + + for model in classes: + for base in model.__mro__: + if base is object or base is BaseModel: + continue + try: + source = inspect.getsourcefile(base) + except (TypeError, OSError): + # TypeError for a builtin; OSError ("source code not + # available") for a class whose module has no file at all. + source = None + if source is None: + continue + if _inside(source, repo_root): + # Repo-local: recorded unconditionally. A blob id for a file + # already in the index costs nothing, and a base that declares + # no field today is one edit away from declaring a marked one. + record(source, base.__module__) + elif _declares_fields(base): + # Out of the repository: recorded only when it actually + # declares fields, because recording one makes a tracked + # lockfile a precondition for generating at all. That is why + # this cannot simply be "every base except object" — + # `class Config(BaseModel, ABC)` would pin `abc`. + external.add(base.__module__) + + wanted = {id(obj) for model in classes for obj in _annotated_aliases(model)} + wanted.update(id(obj) for obj in extra_objects) + if wanted: + for module_name, module in list(sys.modules.items()): + if module is None or module_name == "__main__": + continue + namespace = getattr(module, "__dict__", None) + if not isinstance(namespace, dict): + continue + if any(id(value) in wanted for value in list(namespace.values())): + record(getattr(module, "__file__", None), module_name) + + return sorted(files), sorted(external) + + +def generate(request: dict[str, Any]) -> dict[str, Any]: + """The whole job: import the declared classes, build the graph, and + report the files and out-of-repo modules that contributed to it.""" + repo_root = Path(request["repo_root"]).resolve() + sys.path.insert(0, str(repo_root)) + + hook_target = request.get("declared_paths_fn") + extra_objects: list[Any] = [] + if hook_target: + hook = _import_object(hook_target, "check.declared_paths_fn") + is_secret = _custom_marks(hook) + extra_objects.append(hook) + else: + is_secret = _standard_marks + + builder = _GraphBuilder(is_secret) + roots: dict[str, str] = {} + for namespace, target in sorted(request["stack_models"].items()): + model = _import_object(target, "check.stack_models") + if not _is_model(model): + raise Unwalkable( + f"check.stack_models {target!r}: not a pydantic BaseModel subclass" + ) + roots[namespace] = builder.add(model) + + classes = [builder.classes[key] for key in sorted(builder.classes)] + # The hook itself decides which fields are marked, so the module defining + # it is as much a source as any model's file. It is passed in as an object + # rather than resolved by name: the identity scan in `_collect_sources` + # finds it in its own module's globals, and so classifies it repo-local or + # out-of-repo by exactly the same rule as everything else. + files, external = _collect_sources(classes, extra_objects, repo_root) + return { + "roots": roots, + "models": builder.models, + "files": files, + "external": external, + } + + +def main(argv: list[str]) -> int: + """Read the request from `argv[1]`, write the graph to the file it names. + + The result goes to a file rather than to stdout on purpose: importing a + repository's modules runs that repository's import-time code, which is + free to print. A banner on stdout would corrupt a JSON document written + there, and would do it intermittently — only in the repositories that + happen to print something. stdout and stderr stay available for exactly + that output, and for this program's own error messages. + """ + if len(argv) != 2: + _die("usage: ") + try: + request = json.loads(argv[1]) + except ValueError as exc: + _die(f"request is not valid JSON: {exc}") + try: + result = generate(request) + except Unwalkable as exc: + _die(str(exc)) + Path(request["output"]).write_text(json.dumps(result), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/stackward/cli.py b/src/stackward/cli.py index 97f261a..a075483 100644 --- a/src/stackward/cli.py +++ b/src/stackward/cli.py @@ -1,9 +1,15 @@ """Command dispatch. -v0.1.0 deliberately ships only `--version` and `doctor`. The point of this -release is to prove the distribution path — build, sign, publish, download, -install, run — while it is still cheap to change. Features land on top of a -distribution mechanism that is already known to work, not the other way round. +`v0.1.0` shipped `--version` and `doctor` alone, deliberately: the point of +that release was to prove the distribution path — build, sign, publish, +download, install, run — while it was still cheap to change. That path held, +and the commands below landed on top of a distribution mechanism already +known to work, rather than the other way round. + +Every subcommand that can touch a credential is registered through +`_dispatch`, which imports its module only when the subcommand actually +runs. That is not a startup-time optimisation; see `_dispatch` for what it +is. """ from __future__ import annotations @@ -17,13 +23,107 @@ from pathlib import Path from . import __version__ +from .bootstrap import GENERATOR_NAME, generator_source +from .commands.check_config import cmd_check_config +from .commands.install_hooks import cmd_install_hooks +from .commands.pre_commit import cmd_pre_commit +from .config import ( + Config, + MinVersionError, + enforce_min_version, + find_repo_config, + read_min_version, +) # Commands that must keep working even when a repo demands a newer stackward # than the one installed. Without this exemption the upgrade instruction would # itself be blocked by the check that prints it. +# +# `self-update` is *reserved*, not registered: `build_parser` below defines no +# such subcommand, and `enforce_min_version`'s message points at `install.sh` +# instead. The name stays here so that a future `self-update` is exempt on the +# day it is added rather than the day someone notices it is not -- an exempt +# name that no parser accepts costs nothing, while a missing one would block +# the very command that fixes the block. VERSION_CHECK_EXEMPT = frozenset({"doctor", "self-update"}) -CONFIG_FILENAME = ".stackward.toml" + +# Every command module reached through `_dispatch`, as literal `import` +# statements. See `_import_command` for why this is not a dictionary, a +# registry, or `importlib.import_module`. +def _import_command(module_name: str): + """The `commands` submodule named by `module_name`, imported now. + + Written as one literal `import` statement per module rather than as + `importlib.import_module(f".commands.{module_name}", __package__)`, + which is what this was, because of what a *frozen* build can see. + PyInstaller resolves imports by reading `import` statements out of + compiled bytecode -- including statements inside a function body, which + is exactly what keeps these lazy (`crypto_selftest`'s own function-level + `import cryptography` is why the cryptography backend is in the bundle at + all). It cannot resolve a module name assembled at runtime. + + So with the f-string form, **none** of the lazily dispatched commands was + in the shipped binary. Verified against the release workflow's own build + command: `stackward exec` in the bundle died with `ModuleNotFoundError: + No module named 'stackward.commands.session'`, a raw traceback, and exit + code 1 -- the code this tool reserves exclusively for "a credential was + found". The failure existed only in the artifact everyone installs, which + is the same shape as the two failures `doctor`'s self-tests were added + to catch, and for the same reason it is not something a source checkout + can notice. + + An `if` chain, and not a mapping of names to import thunks, because a + mapping built at module scope would run every one of those thunks when + this module is imported -- which is the whole cost being avoided. The + same shape, for the same reason, as + `session._build_credential_store`'s provider dispatch. + """ + if module_name == "session": + from .commands import session as module + elif module_name == "credentials": + from .commands import credentials as module + elif module_name == "set_secrets": + from .commands import set_secrets as module + elif module_name == "sync_declared": + from .commands import sync_declared as module + elif module_name == "check_passphrase": + from .commands import check_passphrase as module + else: # pragma: no cover - a name `build_parser` never passes + raise RuntimeError(f"no command module named {module_name!r}") + return module + + +def _dispatch(module_name: str, function_name: str): + """A subcommand entry point that imports its module only when the + subcommand is actually run. + + Global Constraint 2 says no code reachable from `check-config` or + `pre-commit` may prompt, open a network connection, or import a + credential provider -- so that a commit cannot be blocked by an expired + session or a flaky connection. Importing the credential commands at this + module's own scope broke that: `import stackward.cli` pulled in + `commands.check_passphrase` -> `cryptography`, and `commands.session` -> + `store` -> `crypto`, on *every* invocation. With `cryptography` made + unimportable, `stackward pre-commit` died on a raw traceback and exited + 1 -- the code this tool reserves exclusively for "a credential was + found", reporting a leak in a repository that had none. + + Written as a thunk rather than a module-level `__getattr__`, because + `build_parser` names every entry point on every run: `__getattr__` would + import all of them again the moment the parser was built, which is the + whole cost being avoided. The lookup is `getattr` at *call* time, so + `monkeypatch.setattr(stackward.commands.session, "cmd_login", ...)` + still takes effect -- a thunk that captured the function at import time + would silently bypass such a patch. + """ + + def dispatch(args: argparse.Namespace) -> int: + return getattr(_import_command(module_name), function_name)(args) + + dispatch.__name__ = function_name + dispatch.__qualname__ = f"{module_name}.{function_name}" + return dispatch def config_home() -> Path: @@ -33,22 +133,6 @@ def config_home() -> Path: return base / "stackward" -def find_repo_config(start: Path | None = None) -> Path | None: - """Nearest .stackward.toml at or above `start`, stopping at the repo root. - - Returns None rather than falling back to a default: a tool that guesses - which backend it is talking to is worse than one that refuses. - """ - current = (start or Path.cwd()).resolve() - for candidate in [current, *current.parents]: - config = candidate / CONFIG_FILENAME - if config.is_file(): - return config - if (candidate / ".git").exists(): - break - return None - - def _tool_version(name: str) -> str: """Report an external tool's version, or why it cannot be used.""" path = shutil.which(name) @@ -95,6 +179,34 @@ def crypto_selftest() -> str: return f"UNAVAILABLE: {type(exc).__name__}: {exc}" +def generator_selftest() -> str: + """Prove the model walker's source is reachable, rather than assuming it. + + `sync-declared-secrets` ships `bootstrap/regen.py` as a **data** file and + pipes its text to the consuming repository's interpreter; nothing imports + it, which is what keeps pydantic out of the bundle. The cost of that is + that PyInstaller's import analysis cannot see it either, so it is carried + by an explicit `--add-data` entry in the release workflow -- and if that + entry is ever dropped, the binary builds cleanly, starts cleanly, and + then `sync-declared-secrets` fails in the one artifact everyone installs + and nowhere else. + + That is the same failure shape as `crypto_selftest`'s: something that + works everywhere except the shipped bundle. So it gets the same treatment + -- `doctor` is what a release smoke test runs, and it checks the real + accessor the shipped code uses rather than the file's path on disk, which + does not exist in a bundle. It can only fail if the packaging is wrong: + an installed wheel and a source checkout both carry the file already. + """ + try: + source = generator_source() + except Exception as exc: # noqa: BLE001 - report any failure, never crash doctor + return f"UNAVAILABLE: {type(exc).__name__}: {exc}" + if "def generate(" not in source: + return f"FAILED: {GENERATOR_NAME} is present but is not the generator" + return f"{GENERATOR_NAME} readable ({len(source)} bytes)" + + def cmd_doctor(_args: argparse.Namespace) -> int: """Report what stackward can see. Never prints a credential value.""" config = find_repo_config() @@ -113,9 +225,13 @@ def cmd_doctor(_args: argparse.Namespace) -> int: print() crypto = crypto_selftest() print(f"crypto {crypto}") - # Exit non-zero when the credential layer could not work here. doctor is - # what a release smoke test runs, so it has to fail rather than narrate. - return 1 if crypto.startswith(("UNAVAILABLE", "FAILED")) else 0 + generator = generator_selftest() + print(f"model walker {generator}") + # Exit non-zero when the credential layer or the model walker could not + # work here. doctor is what a release smoke test runs, so it has to fail + # rather than narrate -- and both of these can only break in a bundle. + broken = ("UNAVAILABLE", "FAILED") + return 1 if crypto.startswith(broken) or generator.startswith(broken) else 0 def build_parser() -> argparse.ArgumentParser: @@ -129,15 +245,201 @@ def build_parser() -> argparse.ArgumentParser: doctor = sub.add_parser("doctor", help="report resolved configuration and environment") doctor.set_defaults(func=cmd_doctor) + check_config = sub.add_parser( + "check-config", help="scan stack config file(s) for a plaintext credential" + ) + check_config.add_argument("files", nargs="+", metavar="FILE") + check_config.set_defaults(func=cmd_check_config) + + pre_commit = sub.add_parser( + "pre-commit", + help="check staged content for a plaintext credential (git hook body)", + ) + pre_commit.set_defaults(func=cmd_pre_commit) + + hooks = sub.add_parser("hooks", help="manage this repository's git hooks") + hooks_sub = hooks.add_subparsers(dest="hooks_command", metavar="") + hooks_install = hooks_sub.add_parser( + "install", help="install the pre-commit gate into this repository's hooks" + ) + hooks_install.set_defaults(func=cmd_install_hooks) + + login = sub.add_parser("login", help="point pulumi at a profile's backend") + login.add_argument("--profile", help="profile to use (overrides selection precedence)") + login.set_defaults(func=_dispatch("session", "cmd_login")) + + exec_ = sub.add_parser( + "exec", help="run a command with a profile's credentials injected" + ) + exec_.add_argument("--profile", help="profile to use (overrides selection precedence)") + exec_.add_argument( + "argv", nargs="*", metavar="COMMAND", help="command to run, after --" + ) + exec_.set_defaults(func=_dispatch("session", "cmd_exec")) + + shell = sub.add_parser( + "shell", help="open $SHELL with a profile's credentials injected" + ) + shell.add_argument("--profile", help="profile to use (overrides selection precedence)") + shell.set_defaults(func=_dispatch("session", "cmd_shell")) + + set_secrets = sub.add_parser( + "set-secrets", help="publish declared stack secrets into pulumi config" + ) + set_secrets.add_argument("--stack", help="stack name, forwarded to pulumi") + set_secrets.add_argument( + "--dry-run", + action="store_true", + help="report what would be set without running pulumi", + ) + set_secrets.set_defaults(func=_dispatch("set_secrets", "cmd_set_secrets")) + + sync_declared = sub.add_parser( + "sync-declared-secrets", + help="regenerate the declared-secrets artifact from this repo's models", + ) + sync_declared.set_defaults(func=_dispatch("sync_declared", "cmd_sync_declared")) + + # `credentials`, like the session commands, is registered through + # `_dispatch`: its module imports `store` -> `crypto` -> `cryptography`, + # and `tests/test_gate_isolation.py` asserts that a `check-config` run + # loads none of those. Nothing in the help text below may name anything + # from that module either -- an f-string interpolating + # `session.CREDENTIAL_NAMES` here would import it at *this* module's + # scope and break the same property in a way no reader would connect to + # a help string. + credentials = sub.add_parser( + "credentials", help="manage the encrypted credential store" + ) + credentials_sub = credentials.add_subparsers( + dest="credentials_command", metavar="" + ) + + credentials_init = credentials_sub.add_parser( + "init", help="create the credential store (asks for a new password twice)" + ) + credentials_init.set_defaults(func=_dispatch("credentials", "cmd_credentials_init")) + + credentials_set = credentials_sub.add_parser( + "set", + help=( + "seal a profile's credentials, read as KEY=VALUE lines on stdin " + "(replaces the profile's whole envelope; never takes a value as " + "an argument)" + ), + ) + credentials_set.add_argument( + "--profile", help="profile to use (overrides selection precedence)" + ) + credentials_set.set_defaults(func=_dispatch("credentials", "cmd_credentials_set")) + + credentials_list = credentials_sub.add_parser( + "list", help="list the profiles that have credentials sealed (names only)" + ) + credentials_list.set_defaults(func=_dispatch("credentials", "cmd_credentials_list")) + + credentials_show = credentials_sub.add_parser( + "show", + help="list the credential names a profile carries, never their values", + ) + credentials_show.add_argument( + "--profile", help="profile to use (overrides selection precedence)" + ) + credentials_show.set_defaults(func=_dispatch("credentials", "cmd_credentials_show")) + + credentials_rotate = credentials_sub.add_parser( + "rotate", help="re-seal every envelope under a new password, or none" + ) + credentials_rotate.set_defaults( + func=_dispatch("credentials", "cmd_credentials_rotate") + ) + + check_passphrase = sub.add_parser( + "check-passphrase", + help="prove a passphrase decrypts one or more stacks (never as an argument)", + ) + check_passphrase.add_argument("stacks", nargs="+", metavar="STACK") + check_passphrase.set_defaults(func=_dispatch("check_passphrase", "cmd_check_passphrase")) + return parser +def _min_version_refusal(command: str) -> int | None: + """`2` if this repository demands a newer `stackward` than the installed + one, otherwise `None`. + + One guard, not two. Everything from finding the config to comparing the + versions is inside a single `try`, and every failure other than + `MinVersionError` means "there is no floor I can establish here" and + lets the command run. `doctor` is the command you run *because* + `.stackward.toml` is broken, and Task 2's carry-forward ruling requires + it to keep working when it is; a floor check that refused to run before + dispatch would take that away. Nothing is lost by staying quiet: + anything genuinely wrong with the file is reported, in better words, by + whichever command actually needs it — both gate commands refuse outright + on an unparseable or missing policy. + + A second, outer guard around this would be worse than none: with two, + neither is falsifiable, and a test that deletes either one still passes. + The same rule is why the floor is read by `read_min_version` rather than + by `load_config` *and* re-ordered inside `_build_config`: one mechanism, + so that deleting it turns a test red. + + `read_min_version`, not `load_config`, because the case this refusal + exists for is the case a full load cannot report. A repository that + demands a newer stackward is very likely to be using something that + newer stackward added, and an unrecognised top-level key makes + `load_config` raise before `min_version` is ever looked at -- so the + broad guard below would swallow it and the command would run, having + reported an unknown key rather than the upgrade that explains it. + Reading the floor alone keeps this refusal working on exactly the + forward-compatible config it is for. Nothing else about the file is + trusted here, and nothing else needs to be: it is validated in full by + whichever command reads it for its own purposes. + + It reads the *working tree*, deliberately, even though `pre-commit` + reads its `[check]` policy from the index. `min_version` is a statement + about the binary you are running right now, not about the commit you are + making, and this runs before dispatch — before there is any reason to + believe the current directory is even a git repository. + """ + try: + path = find_repo_config() + min_version = read_min_version(path) if path is not None else None + enforce_min_version(Config(min_version=min_version), command) + except MinVersionError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except Exception: # noqa: BLE001 - a broken config must not break dispatch + return None + return None + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if not getattr(args, "func", None): parser.print_help() return 2 + + # The `min_version` floor, enforced here rather than in each command, so + # it applies to every subcommand instead of whichever ones remembered to + # ask. It runs *before* the gate commands' own missing-policy refusal, + # which costs nothing: with no config there is no floor to enforce + # (`enforce_min_version(None, ...)` returns immediately) and the command + # then refuses with its own, more useful message. The order only matters + # when a config exists, and there the floor should win -- a repository + # whose policy needs a newer stackward is telling you that this binary + # may not understand the policy it is about to read. + # + # `main` is outside `@fail_closed`, so an escaping exception here would + # exit 1: the code reserved for "a credential was found". That is why + # `_min_version_refusal` returns an exit code rather than raising, and + # why its own guard is broad. + refusal = _min_version_refusal(getattr(args, "command", "") or "") + if refusal is not None: + return refusal + return args.func(args) diff --git a/src/stackward/commands/__init__.py b/src/stackward/commands/__init__.py new file mode 100644 index 0000000..5167706 --- /dev/null +++ b/src/stackward/commands/__init__.py @@ -0,0 +1,3 @@ +"""CLI command implementations, one module per subcommand.""" + +from __future__ import annotations diff --git a/src/stackward/commands/check_config.py b/src/stackward/commands/check_config.py new file mode 100644 index 0000000..cc61faa --- /dev/null +++ b/src/stackward/commands/check_config.py @@ -0,0 +1,400 @@ +"""`check-config`: the credential nets, run over one or more stack config files. + +Registered in `cli.build_parser` as `stackward check-config FILE...`. Exit +codes are deliberately distinct: 0 clean, 1 a credential was found, 2 the +check could not run at all (an unreadable path, unparsable YAML, a +`.stackward.toml` this module could not load *or could not find*, a document +that is not a mapping, or a model-net artifact that is missing, malformed or +stale) — +never conflated, so a fail-closed refusal reads the same way whichever check +made it: "the gate did not get to answer". + +**Two nets, unioned.** `nets.heuristic` names a credential by the shape of +its key and needs no cooperation from the repository. `nets.model` names one +the repository *declared*, by walking a committed graph of its own pydantic +models against the document. A leaf either net names is a finding, and a leaf +both name is reported once — `scan_file` unions the two through a set, so a +path found twice does not print twice. + +**A missing `.stackward.toml` is exit 2, not a scan under defaults.** Global +Constraint 3 states it directly: "A missing policy file is a refusal, not a +skip." `CheckConfig()`'s own defaults carry `model_net = "none"`, so +defaulting on absence would turn the *declared* heuristic-only mode into a +silent fallback — the exact shape of "passed because a check could not run" +that fail-closed exists to prevent. Both gate commands refuse instead, and +name the minimal file to create. + +**This command reads the working tree; `pre-commit` reads the index.** That +asymmetry is deliberate and is not the bug it looks like. `check-config` +takes file paths on the command line and scans them from disk, so its policy +has to come from disk too: a policy read out of the index would describe a +commit the caller may not be making, and would make `check-config` unable to +answer "is the file I am looking at right now clean?" — which is the whole +question it exists to answer, most often on a file that is not staged at all. +`pre-commit` answers a different question ("is what I am about to commit +clean?") and so reads both content *and* policy from the index; see +`pre_commit._load_check_policy` for why anything else there fails open. + +**A stale model net is exit 2, not exit 1.** `load_model_net` raises rather +than returning an empty net when the artifact is missing, unreadable or no +longer matches the sources it was generated from, and this module maps that +to "could not run". Reporting it as a finding would file a fail-closed +refusal under "a credential was found"; reporting it as clean would be worse +still. `model_net = "none"` is the *declared* way to run without that net, +and is never reached as a fallback from a failure. +""" + +from __future__ import annotations + +import argparse +import functools +import re +import sys +from collections.abc import Callable +from pathlib import Path + +import yaml + +from ..config import ( + CONFIG_FILENAME, + CheckConfig, + ConfigError, + find_repo_config, + load_config, +) +from ..nets.heuristic import DocumentError, find_plaintext_credentials +from ..nets.model import ( + ModelNet, + ModelNetError, + find_declared_credentials, + load_model_net, +) + +# Matches a repr-quoted fragment the way PyYAML's `%r` interpolation +# produces one — e.g. `'2'`, `` '`' ``, `'id001'`. Python's `repr()` flips +# to double quotes whenever the value contains `'` and no `"` (so `%r` on a +# lone apostrophe is `"'"`, not `'''`), which is why both delimiter forms +# are matched here: matching only `'...'` leaves exactly that one trigger +# character unredacted. See `describe_yaml_error` for why every such +# fragment gets redacted rather than trusted. +_QUOTED_FRAGMENT = re.compile(r"'[^']*'|\"[^\"]*\"") + + +# The minimal policy file both gate commands name when there is none. An +# explicit `model_net` is part of the minimum on purpose: it is the one key +# whose default ("none") silently drops a whole net, so a repository has to +# have chosen it rather than inherited it. +MINIMAL_POLICY = '[check]\nmodel_net = "none"' + +_MINIMAL_POLICY_BLOCK = "\n".join( + f" {line}" for line in MINIMAL_POLICY.splitlines() +) + + +class CheckError(Exception): + """A file could not be checked at all: an unreadable path, unparsable + YAML, or a document that is not a mapping. Maps to exit code 2, never + 1 — that code means "a credential was found", which this is not. + + Reused as-is by `commands.pre_commit` for the same "could not run" + conditions on a staged blob (undecodable content, unparsable YAML, a + non-mapping document, an unmerged index entry, a git command that + failed) — one exception type for one meaning, not a second class that + would have to be kept consistent with this one by hand.""" + + +class MissingPolicyError(CheckError): + """The repository declares no `.stackward.toml` at all. + + A subclass of `CheckError` because it means exactly what `CheckError` + means — the check could not run, exit 2 — while still being + distinguishable from "this file would not parse" for a caller that wants + to say something more useful. Deliberately *not* a `config.ConfigError`: + that type's own contract is "present but unusable", and `config` reports + absence by returning `None` rather than raising, because `doctor` has to + keep working in a repository that has no policy yet. + """ + + +def missing_policy_message(*, where: str, remedy: str) -> str: + """The refusal text for a missing policy file, in one place. + + Both gate commands print it, and they must not drift: they differ only + in where they looked (`where`) and what closes the gap (`remedy`). The + shape follows `sync_declared._load_repo_config`, the precedent already + in the tree for "absence is a refusal here" — say what is missing, say + why this command cannot proceed without it, and name the file to create. + """ + return ( + f"no {CONFIG_FILENAME} {where}; a repository must declare its " + "credential policy before this gate will scan under one — defaulting " + "would make the weaker net a silent fallback rather than a declared " + f"mode.\ncreate {CONFIG_FILENAME} at the repository root with at " + f"least:\n\n{_MINIMAL_POLICY_BLOCK}\n\n" + '(use model_net = "artifact" instead to also run the declared-secrets ' + f"net)\n{remedy}" + ) + + +def fail_closed( + command: Callable[[argparse.Namespace], int], +) -> Callable[[argparse.Namespace], int]: + """Wrap a command entry point so any exception it does not already + handle itself becomes exit code 2, never left to propagate. + + Both `cmd_check_config` and `cmd_pre_commit` already wrap each + per-file scan individually — see either function's own per-file + `except CheckError` / catch-all `except Exception` pair — but that + only protects the scan step itself. Policy loading + (`_load_check_policy`, built on `find_repo_config`/`load_config`) and, + in `pre_commit`, the staged-file listing (`_staged_paths`) both run + *outside* that loop, each guarded only by `except ConfigError` or + `except CheckError` respectively. A review of this module found the + gap concretely: `find_repo_config` walks upward through parent + directories with bare `.is_file()`/`.exists()` calls and no + try/except of its own, so a `PermissionError` on a non-traversable + parent surfaces as a plain `OSError` — neither `ConfigError` nor + `CheckError` — which would otherwise escape both commands uncaught + and exit 1 by Python's own default: the code this tool reserves + exclusively for "a credential was found", misreporting exactly that + for an invocation that never got far enough to check anything. + + This decorator is the outer boundary that catches precisely what the + inner, per-file guards do not, from one definition shared by both + commands rather than two independently-maintained copies of the same + try/except (or, worse, three or more scattered call-site patches that + would leave the next command built this way exposed by default). + Never prints `str(exc)` — the exception type only — for the same + reason `describe_yaml_error` never echoes `str(exc)` on a YAML error: + an unanticipated exception's text is not something this boundary can + vouch for as free of file content. + """ + + @functools.wraps(command) + def wrapped(args: argparse.Namespace) -> int: + try: + return command(args) + except Exception as exc: # noqa: BLE001 - the fail-closed boundary itself + print(f"error: could not run ({type(exc).__name__})", file=sys.stderr) + return 2 + + return wrapped + + +def scan_file(path: Path, check: CheckConfig, net: ModelNet | None) -> list[str]: + """Thin file-path wrapper over both nets, unioned. + + Reads `path` from the working tree and parses it as YAML before handing + the result to the content-level nets. This is deliberately the *only* + place that happens: both nets take already-parsed document data, so a + caller that must check staged content instead of the working tree — + `pre-commit`, reading `git show ":"` — calls them directly rather + than going through this wrapper, and never round-trips a blob through a + temp file to get there. + + `net` is `None` when the repository declares `model_net = "none"`; the + heuristic net always runs, and a repository that declared no policy at + all never reaches here (it is refused). It has no + default, deliberately: a caller that simply forgot it would silently get + the heuristic net alone, which is the whole failure this parameter + exists to prevent. `pre_commit._scan_staged_config` takes it the same + way, and that already paid for itself — a test stub with the old + signature failed loudly rather than quietly scanning with one net. The two + result lists are unioned through a set, because both nets can name the + same leaf — a field marked `secret` whose key is also called `password` + is the ordinary case, not a corner one — and a finding printed twice + reads as two problems. + + Raises `CheckError` for anything that means the check could not run: + the path cannot be read, the content is not valid YAML, or the parsed + document is not a mapping. + """ + try: + text = path.read_text() + except OSError as exc: + raise CheckError(f"{path}: cannot read: {exc}") from exc + + try: + document = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise CheckError(f"{path}: invalid YAML: {describe_yaml_error(exc)}") from exc + + try: + found = set(find_plaintext_credentials(document, check)) + if net is not None: + found |= set(find_declared_credentials(document, net, check)) + except DocumentError as exc: + raise CheckError(f"{path}: {exc}") from exc + return sorted(found) + + +def describe_yaml_error(exc: yaml.YAMLError) -> str: + """A `YAMLError` message built only from PyYAML's own description and + the error position, with every repr-quoted fragment in it redacted — + never from `str(exc)`. + + Public (not `_`-prefixed) specifically so `commands.pre_commit` can + reuse it for the same reason: a staged blob it parses can hold a + credential just as easily as a file on disk can, so a YAML parse + failure there needs the exact same redaction, not a second, + independently-written copy of it. + + `str(exc)` on a `MarkedYAMLError` calls `Mark.__str__`, which calls + `Mark.get_snippet()` and emits the *whole source line* around the + error — reason enough on its own never to use it here, in the one file + in the whole system expected to hold a credential. + + But `exc.problem` is not unconditionally safe either, which an earlier + version of this docstring claimed and which a review disproved: several + PyYAML scanner/parser messages interpolate one raw character or a + user-chosen name via `%r` — "found character %r that cannot start any + token", "found unknown escape character %r", an anchor/alias/tag-handle + name in a duplicate/undefined-alias error. A value like `"hunter\2ok"` + yields `found unknown escape character '2'` — one character of a real + credential; `repr()` flips to double quotes whenever that character is + itself an apostrophe (`"'"`, not `'''`), which is why `_QUOTED_FRAGMENT` + matches both delimiter forms rather than only `'...'`. + + This removes every document character PyYAML is *currently known* to + interpolate via `%r`, by shape (any repr-quoted run) rather than by + enumerating today's message text — that is a mechanism with a stated + boundary, not a guarantee that no PyYAML wording could ever leak a + character some other way. `tests/test_heuristic.py`'s parameterized + redaction test is what would catch a future wording change that + escapes this pattern; this docstring does not promise it can't happen. + **`config.py` is not the contrast this comment used to draw.** It said + `load_config` safely echoed tomllib's message in full, because + `.stackward.toml` holds only path and key *names*. Both halves were + wrong, and both have since been fixed in the code. `tomllib` does quote + document text — `tomllib.loads("[a]\nx=1\n[a]\n")` raises `Cannot + declare ('a',) twice (at line 3, column 3)`, naming the key back — and + "the file holds no value" is exactly the assumption a file that failed + to parse has already broken, which is the one thing an error path + reporting on such a file must not lean on. + + What the two modules actually do, so neither is reverted on the + strength of a sentence about the other: `config.toml_position` matches + the `(at line L, column C)` coordinate out of the message and emits + *only* that, which it can afford because nearly every tomllib message + is a fixed description plus that coordinate. PyYAML's `problem` carries + most of the information instead, so this function keeps it and strips + every repr-quoted run out of it. Two bounded treatments of one rule — + a parser's message is never passed through as it came — and a licence, + anywhere, to pass one through in full is not among them. + """ + if isinstance(exc, yaml.MarkedYAMLError) and exc.problem is not None: + problem = _QUOTED_FRAGMENT.sub("", exc.problem) + if exc.problem_mark is not None: + return ( + f"{problem} (line {exc.problem_mark.line + 1}, " + f"column {exc.problem_mark.column + 1})" + ) + return problem + return "invalid YAML syntax" + + +def _load_check_policy() -> CheckConfig: + """The `[check]` policy for the repository containing the current + working directory, read from the **working tree**. + + Working-tree by design, not by oversight — see the module docstring. + This is the one deliberate difference from `pre_commit`'s helper of the + same name, which reads the identical file out of the git index; a reader + comparing the two should see two answers to two different questions, not + a copy that was missed. + + Absence is a refusal (`MissingPolicyError`), never the built-in + defaults: `CheckConfig()` carries `model_net = "none"`, so returning it + here would silently drop the stronger net in exactly the repositories + that had not yet said anything about it. + """ + config_path = find_repo_config() + if config_path is None: + raise MissingPolicyError( + missing_policy_message( + where="found at or above the current directory", + remedy=( + "then re-run this command from inside the repository " + "it belongs to." + ), + ) + ) + return load_config(config_path).check + + +@fail_closed +def cmd_check_config(args: argparse.Namespace) -> int: + """Entry point for `stackward check-config FILE...`. + + Findings print as `: plaintext credential at ''`, sorted, + one per line — file and config path only, never the value. They print + even when *another* file in the same invocation errors: a real finding + on `good.yaml` must never be hidden behind a parse error on + `broken.yaml` that happens to sort after it, so the exit code alone + carries the could-not-run signal. A could-not-run error on any file + still wins the exit code over findings elsewhere: it exits 2, since + exit 1 means specifically "a credential was found", which an + invocation that did not fully run cannot claim to know. + + Anything a per-file scan raises other than `CheckError` — a file that + is not valid UTF-8, or an unanticipated bug elsewhere in this tool — + is also mapped to the could-not-run exit code rather than left to + propagate: Python's default exit code for an uncaught exception is 1, + which here would misreport "a credential was found" for a file this + command never actually finished evaluating. The message never includes + `str(exc)`, for the same reason `describe_yaml_error` avoids it: an + unanticipated exception's text is not something this module can vouch + for as free of file content. + + That per-file guard only covers the scan loop. Policy loading is + guarded separately: `ConfigError` (a `.stackward.toml` that will not + parse) and `CheckError` (via `MissingPolicyError`, no `.stackward.toml` + at all) both print and exit 2 before any file is opened, and anything + *else* it raises is caught by the `@fail_closed` decorator wrapping this + function, for the same "never exit 1 for a reason other than a real + finding" guarantee. Nothing scans under default policy: a repository + with no declared policy is refused, not scanned with the weaker net. + + The model net is loaded once, before the loop, and a failure to load it + exits 2 without scanning anything. That ordering is the point: a stale + artifact means this command does not know what the repository declared, + so reporting the *other* net's findings and exiting 1 would announce a + complete answer it does not have. + """ + try: + check = _load_check_policy() + except (ConfigError, CheckError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + try: + net = load_model_net(check) + except ModelNetError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + findings: list[tuple[str, str]] = [] + errored = False + for file_arg in args.files: + try: + paths = scan_file(Path(file_arg), check, net) + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + errored = True + continue + except Exception as exc: # noqa: BLE001 - fail closed on anything unanticipated + print( + f"error: {file_arg}: could not check ({type(exc).__name__})", + file=sys.stderr, + ) + errored = True + continue + findings.extend((file_arg, path) for path in paths) + + if findings: + for file_arg, path in sorted(findings): + print(f"{file_arg}: plaintext credential at '{path}'") + + if errored: + return 2 + return 1 if findings else 0 diff --git a/src/stackward/commands/check_passphrase.py b/src/stackward/commands/check_passphrase.py new file mode 100644 index 0000000..dbb9e20 --- /dev/null +++ b/src/stackward/commands/check_passphrase.py @@ -0,0 +1,429 @@ +"""`check-passphrase`: prove a passphrase actually decrypts a stack. + +**Why this exists.** `pulumi config` (the list form) renders every secret as +`[secret]` **without decrypting**, and exits 0 under any passphrase at all, +including a flatly wrong one — `set_secrets.py`'s own `_pulumi_config_get` +docstring makes the same point about why drift detection cannot use it +either. There is no `pulumi` subcommand that answers "is this passphrase +right" directly. The only way to actually prove one is to reproduce the +decryption Pulumi itself would perform against the stack's own exported +state, which is what this module does. It is a verifier, not an alternative +convenience wrapper around some other `pulumi` command — if an easier +`pulumi` invocation could answer this question, this file would not exist. + +**Where the ciphertext comes from.** `pulumi stack export --stack ` +prints the stack's deployment state as JSON. For a stack using Pulumi's +built-in passphrase-based secrets provider (the default for a self-managed, +non-Service backend), that document carries, at +`deployment.secrets_providers.state.salt`, a string of the form + + v1::v1:: + +which decodes to an 8-byte salt, a 12-byte AES-GCM nonce, and a ciphertext +whose last 16 bytes are the GCM authentication tag. Decrypting that +ciphertext (no associated data) under the key PBKDF2 derives from the +candidate passphrase and that salt yields the literal plaintext `b"pulumi"` +if and only if the passphrase is the one the stack was created with. That +literal string is a canary Pulumi itself writes for exactly this purpose — +this module does not invent the check, it reproduces it. + +Every field name and byte length above (`v1:`-prefixed, standard/padded +base64, 8-byte salt, 12-byte nonce, no AAD, PBKDF2-HMAC-SHA256 at 1,000,000 +iterations, a 32-byte key, plaintext `b"pulumi"`) was confirmed empirically +against a real `pulumi stack export` from a throwaway local-backend stack +created for this purpose, decrypted successfully with the passphrase that +created it and rejected with a different one — not taken on faith from +memory or from reading Pulumi's source. `tests/test_check_passphrase.py` +freezes that exact salt string as a fixture for the same reason: a round +trip against this module's own encryption would prove only self-consistency, +never that the format matches Pulumi's. + +**Why PBKDF2 here and Argon2id in `crypto.py`.** This is deliberate, not an +oversight — see the parameter definitions below for the reason, and +`crypto.py`'s own module docstring, which names this module for the same +reason from the other side. Do not unify them: this module is reproducing a +fixed, externally-imposed format it does not control, and has no freedom to +pick a stronger KDF even though one exists elsewhere in this codebase. + +**Fail-closed, not three-valued-but-secretly-two.** A stack is reported as +exactly one of `accepted`, `rejected`, or an error — never conflated. A +malformed salt string, a `pulumi stack export` that fails or times out, a +document missing `deployment`, `secrets_providers`, `state`, or `salt` (for +instance because the stack uses a cloud KMS-based secrets provider instead +of a passphrase, which carries no `salt` field at all), or output that is +not valid JSON are all errors — this module cannot tell whether the +passphrase would have worked, and reporting "rejected" for any of them would +be a false negative wearing the shape of a real answer. This is what "fail +closed" concretely means for this command, not merely a slogan: every one of +those conditions raises `CheckPassphraseError`, which the per-stack loop in +`cmd_check_passphrase` reports as `error`, never as `accepted` or `rejected`. + +**The passphrase is never a command-line argument.** It is read as one line +from `sys.stdin` by default — the expected shape is a pipe, e.g. a value +drawn from a password manager or a candidate list — falling back to an +echo-free `getpass.getpass` prompt when `sys.stdin` is a terminal (nothing +piped in, a human at a keyboard). This is the mirror image of +`session.py`'s `_read_store_password`, not a reuse of its exact mechanism, +and deliberately so: `session.py` must never read `sys.stdin` because that +descriptor belongs to the child process it is about to run (`pulumi up`, +etc.) and reading even one byte of it here would be consumed before the +child ever saw it. This command spawns no interactive child — `pulumi stack +export` never reads from its own stdin — so `sys.stdin` is this command's to +use, and checking `sys.stdin.isatty()` directly (rather than `session.py`'s +`/dev/tty`-opened-independently check) is the correct question to ask here: +it is the exact fallback condition the task names, and it decides "was +something piped in" using the same descriptor that will actually be read. + +**The fingerprint identifies a candidate, not the store.** It is the first +16 hex characters of SHA-256 over the exact UTF-8 bytes handed to PBKDF2 — +not NFC-normalised, unlike `crypto.py`'s stored-password handling, because +there is nothing here to keep byte-for-byte reproducible across sessions; +the fingerprint only has to agree with what was actually derived *this run*, +so it is computed from the identical bytes rather than a canonicalised +form that could disagree with them. + +**Exit codes are 0 or 2, never 1** — `check-config`'s docstring and +`set_secrets.py`'s both reserve exit code 1 exclusively for "a credential +was found," a claim this command does not make. 0 means every named stack +accepted; 2 means at least one did not (rejected, or could not be checked). +""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import getpass +import hashlib +import json +import shutil +import subprocess +import sys +from typing import Any + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from .check_config import fail_closed + +# Pulumi's own KDF, reproduced exactly -- NOT `crypto.py`'s Argon2id. +# +# This is not an inconsistency to "fix": `crypto.py`'s store interoperates +# with nothing, so it is free to use the modern memory-hard KDF. This module +# is reproducing *Pulumi's* envelope format in order to verify a stack's own +# ciphertext, which is a fixed, externally-imposed scheme this module has no +# freedom to change -- PBKDF2-HMAC-SHA256 at 1,000,000 iterations, deriving +# a 32-byte AES-256 key, confirmed against a real `pulumi stack export` (see +# the module docstring). Changing either number here would make this module +# derive a key Pulumi itself does not, and every real stack would reject +# even its own correct passphrase. +PBKDF2_ITERATIONS = 1_000_000 +KEY_BYTES = 32 + +# Byte lengths Pulumi's own encoding fixes, confirmed against a real export +# (see the module docstring) rather than assumed: +# - the salt is `make([]byte, 8)` on the Go side; +# - the nonce is AES-GCM's own standard 12-byte size; +# - the ciphertext carries a 16-byte GCM tag appended, so it can never be +# shorter than that. +# These are enforced explicitly, before any decryption is attempted, because +# `cryptography`'s `AESGCM.decrypt` raises `InvalidTag` -- not a `ValueError` +# -- for a too-short ciphertext or for a nonce of an unexpected-but-still- +# valid length. Without these checks a malformed salt string would be +# misreported as "rejected" (a wrong passphrase) instead of the error it +# actually is, which is exactly what fail-closed forbids: see the module +# docstring's "Fail-closed" section. +_SALT_BYTES = 8 +_NONCE_BYTES = 12 +_MIN_CIPHERTEXT_BYTES = 16 + +# The literal plaintext Pulumi encrypts as its own passphrase canary. +PULUMI_TEST_PLAINTEXT = b"pulumi" + +# How long a single `pulumi stack export` is allowed to run before this +# module gives up on that one stack and reports it as an error -- a module +# level constant (not a function default) so a test can lower it with +# `monkeypatch.setattr`, matching `set_secrets.DEFAULT_TIMEOUT_SECONDS`. +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +class CheckPassphraseError(Exception): + """A stack could not be checked at all: a malformed salt string, a + `pulumi stack export` that failed, timed out, or returned something + that is not parseable JSON, or an exported document missing + `deployment`, `secrets_providers`, `state`, or `salt`. + + Never means "the passphrase was wrong" -- that is `check_salt` returning + `False`, a distinct outcome the per-stack loop never conflates with this + one. Never carries the passphrase or any part of it: every message here + names a stack, a byte length, a `pulumi` exit code, or a JSON field -- + never a credential value. + """ + + +def fingerprint(passphrase: str) -> str: + """First 16 hex characters of SHA-256 over `passphrase`'s UTF-8 bytes -- + the same bytes `check_salt` hands to PBKDF2, not an NFC-normalised or + otherwise adjusted form (contrast `crypto.py`'s stored passwords, which + are normalised because they must stay reproducible across sessions on + different platforms; this value only has to match what was actually + derived in this one run). + + Lets two candidate passphrases be told apart in a log safely: the + fingerprint reveals nothing about the passphrase itself (SHA-256 is not + invertible, and this is a truncated fingerprint of a hash, never the + hash used as a key or a proof of knowledge), but two different + passphrases fingerprint differently with overwhelming probability, and + the same passphrase always fingerprints the same way. + """ + return hashlib.sha256(passphrase.encode("utf-8")).hexdigest()[:16] + + +def _derive_key(passphrase: bytes, salt: bytes) -> bytes: + """PBKDF2-HMAC-SHA256, Pulumi's own parameters -- see `PBKDF2_ITERATIONS` + for why these numbers and not `crypto.py`'s Argon2id ones.""" + return PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=KEY_BYTES, + salt=salt, + iterations=PBKDF2_ITERATIONS, + ).derive(passphrase) + + +def _parse_salt(salt_field: str) -> tuple[bytes, bytes, bytes]: + """Decode `v1::v1::` into + `(salt, nonce, ciphertext)`, or raise `CheckPassphraseError` naming what + is wrong -- never a raw `ValueError` or `binascii.Error`, and never a + guess at bytes that were not actually there. + + Base64 never contains `:`, so a plain `str.split(":")` producing exactly + five parts is a safe, unambiguous parse of this format -- it is also + exactly how Pulumi's own two-part construction (`v1:` joined with + `v1::`) round-trips through one string. + + The byte-length checks after decoding are not optional hardening: see + `_SALT_BYTES`'s comment for why skipping them would let a malformed + salt masquerade as a wrong passphrase instead of the error it is. + """ + parts = salt_field.split(":") + if len(parts) != 5 or parts[0] != "v1" or parts[2] != "v1": + raise CheckPassphraseError( + "malformed secrets provider salt (expected " + "'v1::v1::')" + ) + try: + salt = base64.b64decode(parts[1], validate=True) + nonce = base64.b64decode(parts[3], validate=True) + ciphertext = base64.b64decode(parts[4], validate=True) + except (binascii.Error, ValueError) as exc: + raise CheckPassphraseError( + "malformed secrets provider salt: invalid base64" + ) from exc + + if len(salt) != _SALT_BYTES: + raise CheckPassphraseError( + f"malformed secrets provider salt: expected a {_SALT_BYTES}-byte " + f"salt, got {len(salt)}" + ) + if len(nonce) != _NONCE_BYTES: + raise CheckPassphraseError( + f"malformed secrets provider salt: expected a {_NONCE_BYTES}-byte " + f"nonce, got {len(nonce)}" + ) + if len(ciphertext) < _MIN_CIPHERTEXT_BYTES: + raise CheckPassphraseError( + "malformed secrets provider salt: ciphertext shorter than the " + f"{_MIN_CIPHERTEXT_BYTES}-byte GCM tag ({len(ciphertext)} bytes)" + ) + return salt, nonce, ciphertext + + +def check_salt(salt_field: str, passphrase: str) -> bool: + """`True` if `passphrase` opens `salt_field` (Pulumi's own encoded + secrets-provider state), `False` if it is well-formed but a *different* + passphrase opened it. + + Raises `CheckPassphraseError` when `salt_field` itself is not something + this function can attempt to open at all -- never returns `False` for + that case, which is what would make a malformed export indistinguishable + from a genuinely wrong passphrase in the per-stack report. + """ + if not isinstance(salt_field, str): + raise CheckPassphraseError("secrets provider salt must be a string") + salt, nonce, ciphertext = _parse_salt(salt_field) + key = _derive_key(passphrase.encode("utf-8"), salt) + try: + plaintext = AESGCM(key).decrypt(nonce, ciphertext, None) + except InvalidTag: + return False + return plaintext == PULUMI_TEST_PLAINTEXT + + +def _read_passphrase() -> str: + """The candidate passphrase: one line from `sys.stdin` by default, or an + echo-free `getpass` prompt when `sys.stdin` is a terminal. + + See the module docstring's "The passphrase is never a command-line + argument" section for why the branch condition is `sys.stdin.isatty()` + itself, rather than `session.py`'s independent `/dev/tty`-opened check: + this command, unlike `session.py`, is free to read `sys.stdin`, so the + literal question -- was something piped into *this* descriptor -- is the + right one to ask. + + Raises `CheckPassphraseError` when stdin is not a terminal and reaches + EOF with nothing on it at all, rather than silently proceeding with an + empty string a caller almost certainly did not intend. An explicit empty + line (as opposed to no line at all) is not treated the same way: `""` is + a passphrase a caller can deliberately test, however unlikely to open + anything. + """ + if sys.stdin.isatty(): + return getpass.getpass("passphrase to test: ") + line = sys.stdin.readline() + if not line: + raise CheckPassphraseError("no passphrase provided on stdin") + return line.rstrip("\r\n") + + +def _pulumi_stack_export(pulumi: str, stack: str, *, timeout: float) -> dict[str, Any]: + """`pulumi stack export --stack `, parsed as a JSON object. + + `stdin=subprocess.DEVNULL`: this process has already read the + passphrase from its own `sys.stdin` (or nothing, on the `getpass` + branch), and `pulumi` must never inherit either any leftover piped bytes + meant for this process, or -- on the interactive branch -- this + process's controlling terminal, which would let a `pulumi` that decided + to prompt for something hang against a human who is not expecting it. + + Output is captured and parsed as bytes, not decoded via `text=True`: + `json.loads` accepts bytes directly, and doing it this way turns + undecodable output into this function's own `CheckPassphraseError` + rather than a `UnicodeDecodeError` that `fail_closed` would report with + a generic message instead of a stack-specific one. + + Raises `CheckPassphraseError` for a timeout, a missing `pulumi` binary, + a non-zero exit, or output that does not parse as a JSON object -- + never includes `pulumi`'s own stdout or stderr in the message, which is + not this module's data to vouch for as free of anything sensitive. + """ + try: + completed = subprocess.run( + [pulumi, "stack", "export", "--stack", stack], + stdin=subprocess.DEVNULL, + timeout=timeout, + capture_output=True, + ) + except subprocess.TimeoutExpired as exc: + raise CheckPassphraseError("pulumi stack export timed out") from exc + except OSError as exc: + raise CheckPassphraseError(f"cannot run pulumi: {exc}") from exc + + if completed.returncode != 0: + raise CheckPassphraseError( + f"pulumi stack export exited {completed.returncode}" + ) + try: + document = json.loads(completed.stdout) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise CheckPassphraseError( + "pulumi stack export did not return valid JSON" + ) from exc + if not isinstance(document, dict): + raise CheckPassphraseError("exported stack state is not a JSON object") + return document + + +def _extract_salt(document: dict[str, Any]) -> str: + """`deployment.secrets_providers.state.salt` out of a parsed export, or + `CheckPassphraseError` naming exactly which level is missing or the + wrong shape. + + A stack using a non-passphrase secrets provider (a cloud KMS URL, or the + Pulumi Service's own managed encryption) has no `salt` field at all -- + see the module docstring's "Fail-closed" section for why that is an + error for this command, on the same footing as any other malformed or + incomplete export, and never a silent "rejected" or "accepted". + """ + deployment = document.get("deployment") + if not isinstance(deployment, dict): + raise CheckPassphraseError("exported state has no 'deployment' object") + providers = deployment.get("secrets_providers") + if not isinstance(providers, dict): + raise CheckPassphraseError( + "exported state has no 'secrets_providers' object -- this stack " + "may not use a passphrase-based secrets provider" + ) + state = providers.get("state") + if not isinstance(state, dict): + raise CheckPassphraseError( + "exported state has no 'secrets_providers.state' object" + ) + salt = state.get("salt") + if not isinstance(salt, str): + raise CheckPassphraseError( + "exported state has no 'secrets_providers.state.salt' string" + ) + return salt + + +def _check_stack(pulumi: str | None, stack: str, passphrase: str, *, timeout: float) -> bool: + """`True`/`False` for `stack`, or `CheckPassphraseError` naming why it + could not be checked at all -- a missing `pulumi` binary included, on + the same footing as every other could-not-run condition this function + can raise.""" + if pulumi is None: + raise CheckPassphraseError("pulumi executable not found on PATH") + document = _pulumi_stack_export(pulumi, stack, timeout=timeout) + salt_field = _extract_salt(document) + return check_salt(salt_field, passphrase) + + +@fail_closed +def cmd_check_passphrase(args: argparse.Namespace) -> int: + """Entry point for `stackward check-passphrase STACK...`. + + Prints the passphrase's fingerprint once, then one `accepted` / + `rejected` / `error: ` line per named stack -- a could-not-check + stack does not abort the run, so a real answer on one stack is never + hidden behind a problem on another (the same reasoning + `cmd_check_config` applies to its own per-file loop). + + Exit 0 only if every named stack accepted; 2 otherwise, whether that is + because a stack rejected the passphrase or because a stack could not be + checked at all -- this command reserves exit 1 for nothing, since + `check-config` and `set_secrets` both already reserve it exclusively + for "a credential was found," a claim this command never makes. + """ + try: + passphrase = _read_passphrase() + except CheckPassphraseError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print(f"passphrase fingerprint: {fingerprint(passphrase)}") + + pulumi = shutil.which("pulumi") + all_accepted = True + for stack in args.stacks: + try: + accepted = _check_stack( + pulumi, stack, passphrase, timeout=DEFAULT_TIMEOUT_SECONDS + ) + except CheckPassphraseError as exc: + print(f"{stack}: error: {exc}", file=sys.stderr) + all_accepted = False + continue + except Exception as exc: # noqa: BLE001 - fail closed on anything unanticipated + print( + f"{stack}: error: could not check ({type(exc).__name__})", + file=sys.stderr, + ) + all_accepted = False + continue + print(f"{stack}: {'accepted' if accepted else 'rejected'}") + if not accepted: + all_accepted = False + + return 0 if all_accepted else 2 diff --git a/src/stackward/commands/credentials.py b/src/stackward/commands/credentials.py new file mode 100644 index 0000000..0b42598 --- /dev/null +++ b/src/stackward/commands/credentials.py @@ -0,0 +1,415 @@ +"""`credentials`: the only supported way to *write* to the encrypted store. + +**Why this module exists.** `store.py` implemented the store as a library and +`commands/session.py` implemented the three commands that *read* from it, and +between the two nothing ever owned the command line that fills it in. The +result shipped as a store that could be opened and never populated: a fresh +user's only working path was the `env` credential provider, which reads +credentials straight out of the process environment and so gives up the thing +the encrypted store exists for. Every function called below +(`store.init_store`, `store.set_credentials`, `store.resolve_credentials`, +`store.rotate_password`) was already written, reviewed and tested. This is +the surface over them, and nothing else. + +**Nothing here reimplements a mechanism `session.py` already owns.** The +master password is read by `session._read_store_password` — one place that +decides `STACKWARD_PASSWORD`, the `/dev/tty` check and the refusal when +neither is available. The profile is selected by `session._resolve_profile`, +so `--profile`, `STACKWARD_PROFILE`, the repository's `profile =` and +`default_profile` mean here exactly what they mean for `exec`. The names a +profile must carry come from `session.CREDENTIAL_NAMES` via +`session._require_credential_names`, so `credentials set` refuses the same +envelope `exec` would refuse — at the point it is written, rather than at +the point it is needed. A second copy of any of those would be a second +answer to a question the tool must only have one answer to. + +**A value is never an argument.** `credentials set` reads `KEY=VALUE` lines +from **stdin** (the format of the `.env` file this command exists to +replace, parsed by the same `set_secrets.parse_env_text` that reads one), or +prompts for each name without echo when stdin is a terminal. There is no +positional value and no `--value` flag: a command line is visible in `ps` +for the life of the call and lands in shell history forever. Unlike +`session.py`, this module is free to read `sys.stdin` — it spawns no child +that would need those bytes. + +**Nothing here prints a credential value, on any path.** `list` prints +profile names; `show` prints credential *names* out of a decrypted envelope +and never the values beside them; `set` prints a count. Prompts are +`getpass`, so nothing typed is echoed either. + +**A wrong master password is caught by the store's verifier**, not by +anything here: `set_credentials` and `rotate_password` both open the +known-plaintext envelope before they write, so a mistyped password refuses +with `PasswordError` rather than sealing something the rest of the store +cannot open. That is the failure mode the verifier was built for, and this +module's job is to report it, not to re-detect it. + +**Rotation is all-or-nothing, and this module does not soften that.** +`store.rotate_password` rebuilds the whole document in memory and writes it +once; any failure leaves the store exactly as it was. The refusal is +surfaced verbatim — a partial rotation reported as success would be the +worst outcome available, and so would a total failure reported as one. +""" + +from __future__ import annotations + +import argparse +import getpass +import os +import sys + +from .. import store +from ..store import StoreError +from . import session +from .check_config import fail_closed +from .session import ENV_STORE_PASSWORD +from .set_secrets import SetSecretsError, parse_env_text + +# Prompts, kept together so that the wording of the two-step "type it twice" +# read is visible in one place. Each is passed to +# `session._read_store_password`, which is what decides whether a prompt +# happens at all -- see this module's docstring. +NEW_PASSWORD_PROMPT = "new credential store password: " +CONFIRM_PASSWORD_PROMPT = "confirm new credential store password: " +OLD_PASSWORD_PROMPT = "current credential store password: " + + +class CredentialsError(Exception): + """Something specific to this module stopped a `credentials` subcommand: + input that is not `KEY=VALUE`, a confirmation that did not match, an + empty value for a name that must have one. + + Never carries a credential value -- every message here names a profile, a + credential name, a line number or an environment variable.""" + + +# The errors these entry points recognise and report as one line rather than +# letting `fail_closed` render them as "could not run ()". `StoreError` +# covers `ProfileError` and `PasswordError`, which subclass it; +# `session.SessionError` arrives from the shared profile-selection and +# password-reading helpers. +_KNOWN_ERRORS = (StoreError, session.SessionError, CredentialsError) + + +def _report(exc: Exception) -> int: + """Print `exc` as `error: ...` on stderr and return 2 -- the "could not + run" code every refusal in this module and in `session.py` uses. Never 1: + that code is reserved, across this whole tool, for "a credential was + found" by the gate.""" + print(f"error: {exc}", file=sys.stderr) + return 2 + + +def _read_new_password() -> str: + """A password that is about to be *set*, read twice and compared. + + Both reads go through `session._read_store_password`, so a store password + is obtained in exactly one way in this codebase whatever it is wanted + for; only the wording differs. When `STACKWARD_PASSWORD` is set, that + function answers both reads from it without prompting and the comparison + is trivially satisfied -- which is correct for the non-interactive path + and is why `_refuse_unrotatable` exists to catch the one case where it + silently means "nothing would change". + + The confirmation is not ceremony: a typo in a password that seals a store + is not recoverable later by any means, because there is nothing else that + can open what it sealed. + """ + first = session._read_store_password(NEW_PASSWORD_PROMPT) + second = session._read_store_password(CONFIRM_PASSWORD_PROMPT) + if first != second: + raise CredentialsError( + "the two passwords do not match; nothing has been changed" + ) + return first + + +def _prompt_for_values() -> dict[str, str]: + """Ask for each required credential, without echo. + + `getpass`, not `input`: these are the values the whole store exists to + keep off disk and out of scrollback. Exactly the names `exec`/`shell` + inject are asked for; an envelope may hold more than that (see + `session._require_credential_names`), but a name this tool does not + consume is not one it should be inventing a prompt for -- pipe them in + instead. + + A value is taken exactly as typed. The stdin path strips surrounding + whitespace (see `set_secrets.parse_env_text`, whose `.env` semantics this + command inherits on purpose); nothing is stripped here, because there is + no file convention to match and no way to quote an intentional space at + a prompt. + """ + return {name: getpass.getpass(f"{name}: ") for name in session.CREDENTIAL_NAMES} + + +def _read_values() -> dict[str, str]: + """The credentials to seal: `KEY=VALUE` lines from stdin, or prompts when + stdin is a terminal. + + The branch is on `sys.stdin.isatty()` -- the literal question "was + something piped into *this* descriptor", which is the right one to ask + because this command, unlike `session.py`, is free to consume stdin. + (`session.py` asks a different question, `/dev/tty`, for the different + reason stated there: its stdin belongs to a child it is about to run.) + + `parse_env_text` raises in `set_secrets`'s vocabulary, which is re-raised + here in this module's. Forwarding its message is safe, and deliberately + so: it names an origin and a line number and never the line's own text. + """ + if sys.stdin.isatty(): + return _prompt_for_values() + + text = sys.stdin.read() + if not text.strip(): + raise CredentialsError( + "no credentials on stdin: pipe KEY=VALUE lines in " + "(for example `stackward credentials set --profile NAME < .env`), " + "or run this from an interactive terminal to be prompted" + ) + try: + return parse_env_text(text, "stdin") + except SetSecretsError as exc: + raise CredentialsError(str(exc)) from exc + + +def _require_values(values: dict[str, str], profile: str) -> None: + """Refuse an envelope `exec` would refuse, before it is written. + + Two checks, in the order that produces the more useful message. + `session._require_credential_names` is the authority on *which* names + must be present -- reused rather than restated, so that the set cannot + drift between the command that writes an envelope and the command that + reads it. + + A name present with an **empty** value is refused too, which that + function does not do: it asks about presence, since an envelope is + resolved by a provider that may legitimately have nothing for a name. At + the moment of sealing there is no such ambiguity -- `AWS_ACCESS_KEY_ID=` + in a piped `.env` is a placeholder line, not a credential, and sealing it + produces a store that opens fine and then fails to authenticate with + nothing pointing back here. Only the required names are checked: an extra + name carried along in the same file is not this command's business. + """ + session._require_credential_names(values, profile) + empty = sorted( + name for name in session.CREDENTIAL_NAMES if not values[name] + ) + if empty: + raise CredentialsError( + f"profile {profile!r}: no value given for {', '.join(empty)}; " + "an empty credential would seal cleanly and then fail to " + "authenticate" + ) + + +def _refuse_unrotatable(old: str, new: str) -> None: + """Refuse a rotation that would change nothing, naming the actual cause. + + The cause is almost always `STACKWARD_PASSWORD`: `_read_store_password` + answers from it without prompting, so with it set both the old and the + new password are the same string and the user was never asked anything. + Reporting that as a bare "the passwords are the same" would describe a + sameness they did not type and cannot explain, so the variable is named + and the fix with it. + """ + if old != new: + return + if ENV_STORE_PASSWORD in os.environ: + raise CredentialsError( + f"{ENV_STORE_PASSWORD} is set, so the current and the new password " + f"were both read from it and rotation would change nothing: unset " + f"{ENV_STORE_PASSWORD} and run this from an interactive terminal" + ) + raise CredentialsError( + "the new password is the same as the current one; nothing to rotate" + ) + + +@fail_closed +def cmd_credentials_init(_args: argparse.Namespace) -> int: + """`stackward credentials init`: create the store. + + Refuses to overwrite an existing one -- `store.init_store` enforces that, + because overwriting would discard every sealed envelope irrecoverably in + response to a command a person could plausibly run twice. + + Says which password was used when it came from the environment. That is + the supported non-interactive path, but a store created from an ambient + variable the user has forgotten about is a store they cannot open + tomorrow, and silence is what makes that possible. + """ + from_env = ENV_STORE_PASSWORD in os.environ + try: + password = _read_new_password() + store.init_store(password) + except _KNOWN_ERRORS as exc: + return _report(exc) + + print(f"credential store created at {store.credentials_path()}") + if from_env: + print(f"(sealed with the password in {ENV_STORE_PASSWORD})") + print("next: stackward credentials set --profile ") + return 0 + + +@fail_closed +def cmd_credentials_set(args: argparse.Namespace) -> int: + """`stackward credentials set [--profile NAME]`: seal a profile's + credentials. + + **Replaces the profile's whole envelope**, and does not merge into it: + `store.set_credentials` writes what it is given. That is why every + required name must be supplied on every run -- a "set one name" that + silently dropped the other two would be far worse than a refusal. + + **It also seals every *extra* name it is given, on purpose.** `< .env` + is the documented way to fill the store and a real `.env` carries more + than the three names `exec`/`shell` inject; refusing the extras would + make the documented workflow fail on the ordinary file it was written + for, and would contradict `_require_values` and `_prompt_for_values`, + which both already say an envelope may legitimately hold more than this + tool consumes. What was missing was not a refusal but a sentence: + `credentials show` now names the sealed-but-not-injected ones, which is + where a person looking at their own store would notice them. + + The password is asked for last, so that malformed input, a missing name + or an empty value is reported before anyone types it. + """ + try: + profile = session._resolve_profile(args.profile) + values = _read_values() + _require_values(values, profile.name) + password = session._read_store_password() + store.set_credentials(profile.name, values, password) + except _KNOWN_ERRORS as exc: + return _report(exc) + + print(f"sealed {len(values)} credential(s) for profile {profile.name!r}") + print(f"check with: stackward credentials show --profile {profile.name}") + return 0 + + +@fail_closed +def cmd_credentials_list(_args: argparse.Namespace) -> int: + """`stackward credentials list`: the profiles that have an envelope. + + Opens nothing and asks for no password -- `store.store_profiles` reads + the document's keys. A profile that appears here has credentials sealed + for it; one that does not appear may still exist in `config` and be + perfectly usable with `login`, which never reads this file. + + The reverse case is real too, and is why this listing and `show` can + disagree: an envelope whose `[profile.]` table has been deleted + from `config` is still in this file (deliberately -- see + `store.rotate_password`, which re-seals it rather than destroying it) + and is listed here, while `show` refuses it, because selecting a profile + means finding its backend and there is no longer one to find. That is + the honest answer in both places: the envelope exists, and nothing can + currently use it. + """ + try: + profiles = store.store_profiles() + except _KNOWN_ERRORS as exc: + return _report(exc) + + if not profiles: + print( + "no profile has credentials in the store yet: " + "stackward credentials set --profile ", + file=sys.stderr, + ) + return 0 + for name in profiles: + print(name) + return 0 + + +@fail_closed +def cmd_credentials_show(args: argparse.Namespace) -> int: + """`stackward credentials show [--profile NAME]`: which credential names + a profile carries. + + **Names only, never values.** This is the command that answers "did that + `set` land, and is anything missing" without anyone having to run `exec` + against a real backend to find out. It decrypts, because the names live + inside the envelope and there is nowhere else to read them from. + + Exit code 0 even when a required name is absent, with the shortfall + noted on stderr: the question this command was asked -- what does this + profile carry -- was answered correctly, and the store is not in a state + anything needs to refuse. `exec` is what refuses an incomplete envelope, + and it already does. + + Both notes go to stderr, so stdout stays the parseable list of names. + The second one -- names sealed that nothing injects -- exists because + this is the only place the asymmetry is visible: `set` seals what it is + given and `exec` injects three, so `set < .env` on a `.env` holding + anything else seals it with no word said anywhere. + """ + try: + profile = session._resolve_profile(args.profile) + password = session._read_store_password() + credentials = store.resolve_credentials(profile.name, password) + except _KNOWN_ERRORS as exc: + return _report(exc) + + names = sorted(credentials) + print(f"profile {profile.name!r} carries {len(names)} credential(s):") + for name in names: + print(f" {name}") + + missing = [name for name in session.CREDENTIAL_NAMES if name not in credentials] + if missing: + print( + f"missing, and required by exec/shell: {', '.join(missing)}", + file=sys.stderr, + ) + + # The other half of the same question, and the one nothing else in the + # tool says out loud: `set` seals every name it is given, `exec`/`shell` + # inject only `CREDENTIAL_NAMES`. `< .env` is the documented way to fill + # the store, and a `.env` that carries anything beyond those three seals + # the extras silently. That is intended (see `cmd_credentials_set`), and + # this is where someone would notice it -- `show` is the command that + # answers "what is in there", so an unexplained fourth name in its own + # listing is exactly what needs a sentence beside it. + extra = [name for name in names if name not in session.CREDENTIAL_NAMES] + if extra: + print( + f"sealed, but never injected by exec/shell: {', '.join(extra)} " + "(`credentials set` seals every name it is given; only " + f"{', '.join(session.CREDENTIAL_NAMES)} are injected)", + file=sys.stderr, + ) + return 0 + + +@fail_closed +def cmd_credentials_rotate(_args: argparse.Namespace) -> int: + """`stackward credentials rotate`: re-seal every envelope under a new + password. + + Every envelope in the credentials file, not every profile in `config`: + `store.rotate_password` re-seals what is actually there, so an envelope + whose `[profile.]` table was removed is carried across rather than + quietly destroyed. + + All-or-nothing. If any envelope fails to open, `store.rotate_password` + raises and writes nothing, and this reports that refusal as-is -- the + store still opens under the current password, which is what the message + must not obscure. + """ + try: + old = session._read_store_password(OLD_PASSWORD_PROMPT) + new = _read_new_password() + _refuse_unrotatable(old, new) + store.rotate_password(old, new) + except _KNOWN_ERRORS as exc: + return _report(exc) + + # No "remember to update STACKWARD_PASSWORD" note here: reaching this + # line means the variable was unset, because `_refuse_unrotatable` above + # cannot be passed while it is set. + print("every envelope in the credential store was re-sealed") + return 0 diff --git a/src/stackward/commands/install_hooks.py b/src/stackward/commands/install_hooks.py new file mode 100644 index 0000000..a021b8a --- /dev/null +++ b/src/stackward/commands/install_hooks.py @@ -0,0 +1,468 @@ +"""`hooks install`: write the `pre-commit` gate into this repository's git +hooks directory. + +Registered in `cli.build_parser` as `stackward hooks install`, taking no +arguments -- like `pre-commit` itself, it always operates on the repository +containing the current working directory. + +**The hook body has no logic.** It locates the `stackward` executable and +execs `stackward pre-commit` -- nothing else. Git does not version hooks, so +whatever text this command writes into a hook file sits there, unchanged, +until someone runs this command again; a hook body that duplicated any of +`commands.pre_commit`'s actual rules would silently drift from that module +the first time either one changed, in every clone that had installed it +before the drift. Keeping the hook body to "find the binary, exec it" means +every fix and every new rule reaches an already-installed hook automatically, +through the same `stackward` upgrade that would have shipped it anyway -- +nothing about the hook file itself has to change, or even be reinstalled. + +**Never hardcode `.git/hooks`.** `core.hooksPath` is routinely redirected -- +by husky, by the `pre-commit` framework, by a org-wide global config -- and a +directory git does not consult is a *silent* no-op: nothing errors, the +command reports success, and the next commit with a plaintext credential in +it sails straight through a gate everyone believes is installed. `_hooks_dir` +resolves the real location with `git rev-parse --git-path hooks`, exactly +once, and every other function in this module treats that resolved `Path` as +the hooks directory rather than reconstructing or assuming it. + +**Refuse a tracked hooks directory outright.** Some repositories commit their +hook *scripts* to a tracked directory (`.githooks/`, say) and point +`core.hooksPath` at it precisely so every clone gets the same hooks without a +separate install step. Writing this tool's hook into a directory like that +would commit a file that `exec`s a `stackward` binary the next clone may not +have installed at all -- turning a shared convenience into a hook that blocks +every commit in every clone until someone notices and reverts it. `_is_tracked` +checks this with `git ls-files --error-unmatch ` (a tracked-directory +pathspec matches if *any* file under it is tracked; git's ordinary, +unredirected `.git/hooks` never matches, because paths under `.git/` are not +part of the repository's tracked namespace at all) and `cmd_install_hooks` +refuses before writing anything -- printing the reason and pointing at +chaining from the tracked hook instead of overwriting it. + +**Absolute path, with a `PATH` fallback baked into the hook itself.** The +obvious one-liner, `exec stackward pre-commit`, depends on `stackward` being +on `PATH` *at the moment git invokes the hook* -- and several everyday commit +paths (GUI clients, IDEs, cron) run git with a minimal environment that does +not include the shell profile where `PATH` normally gets extended. `exec` +against a name that is not found there exits 127, and a hook that exits +non-zero blocks the commit -- exactly the experience that teaches people to +reach for `git commit --no-verify` and never look back. `_running_executable` +embeds the absolute path this command was itself invoked with (see its own +docstring for why that differs between a frozen build and a console-script +install), and the hook body it writes still falls back to a `PATH` lookup if +that embedded path is not executable when the hook actually runs -- covering +the case where `stackward` moved, or this hook was installed from one clone's +install layout and is now running against another's. + +**A fixed marker line, backup rather than refuse or overwrite.** Some other +tool, or a human, may already have written a hook at the same path (`husky`, +`pre-commit`, or a hand-written script). This command must not silently +destroy that -- but it also must not refuse forever, since re-running this +command is exactly how the hook body picks up a `stackward` upgrade. The +answer is the fixed line `_MARKER`: a hook that already carries it was +written by this command and is safe to overwrite in place (this is what +makes running the command twice leave exactly one hook, with no growing pile +of backups); a hook that lacks it is somebody else's and is moved aside to +`.backup.` -- never overwritten, never dropped, and +never a reason to refuse the install outright. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +from ..config import CONFIG_FILENAME, find_repo_config +from .check_config import MINIMAL_POLICY, fail_closed + +_HOOK_NAME = "pre-commit" + +# Exact line a hook this command wrote always contains, and the sole thing +# that tells "ours, safe to overwrite in place" apart from "somebody else's, +# back it up" -- see the module docstring. Never change this text without +# also handling hooks written by a prior version that still carry the old +# one; nothing in this codebase (yet) needs that migration. +_MARKER = "# managed by: stackward hooks install" + + +class InstallError(Exception): + """Installation cannot proceed for a reason that is safe to print + verbatim: git is missing, the current directory is not inside a git + working tree, or a git command genuinely failed. Never raised for a + plaintext credential -- this module never reads staged or working-tree + *content* at all, only git's own metadata about paths.""" + + +def _git(args: list[str]) -> subprocess.CompletedProcess[str]: + """Run `git ` in the current working directory, under `LC_ALL=C`, + and hand the completed process back for the caller to interpret. + + **Every** git invocation in this module goes through here, including the + one whose exit code is not simply pass/fail (`_is_tracked`). That is the + point of it existing separately from `_run_git`: `_is_tracked` has to + read git's own stderr *text* to tell "this path is not tracked" apart + from "git could not answer", and it previously called `subprocess.run` + directly to do so -- a bypass that already produced one bug, and that + hid this one. Git's diagnostics are gettext-marked, so under any + non-English locale those strings are translated, `_is_tracked` matches + neither, and `hooks install` raises and exits 2 in *every* repository -- + not merely a repository with a redirected `core.hooksPath`. The command + that installs the gate stops working, for a reason that has nothing to + do with the repository it is run in. + + The environment is *merged*, never replaced, exactly as + `commands.pre_commit._run_git` merges it: `git` sets `GIT_DIR`, + `GIT_INDEX_FILE` and friends for a hook process, and a bare + `env={"LC_ALL": "C"}` would drop them and point this command at a + different repository than the one it was invoked for. + + Raises `InstallError` only for the `git` executable being missing -- + every other interpretation of the result belongs to the caller. + """ + try: + return subprocess.run( + ["git", *args], + capture_output=True, + text=True, + check=False, + env={**os.environ, "LC_ALL": "C"}, + ) + except OSError as exc: + raise InstallError(f"cannot run git {' '.join(args)}: {exc}") from exc + + +def _run_git(args: list[str]) -> str: + """Run `git ` in the current working directory and return its + stdout, stripped of surrounding whitespace. + + Raises `InstallError` for anything that means installation cannot + proceed: the `git` executable missing, or the invoked subcommand + failing for any git-reported reason (most notably, the current + directory not being inside a git working tree at all).""" + proc = _git(args) + if proc.returncode != 0: + stderr = proc.stderr.strip() + raise InstallError(f"git {' '.join(args)} failed: {stderr}") + return proc.stdout.strip() + + +def _hooks_dir() -> Path: + """The repository's actual hooks directory -- `git rev-parse --git-path + hooks`, resolved to an absolute path, never `.git/hooks` hardcoded. See + the module docstring for why a redirected `core.hooksPath` makes that + hardcoding a silent no-op rather than a loud failure.""" + return Path(_run_git(["rev-parse", "--git-path", "hooks"])).resolve() + + +def _is_tracked(path: Path) -> bool: + """Whether any file under `path` (already an absolute `Path`) is + tracked by git, tested exactly as the brief specifies: `git ls-files + --error-unmatch ` -- a directory pathspec matches if git tracks + anything underneath it. Passing the pre-resolved absolute path, rather + than a relative one, keeps this correct regardless of what the current + working directory happens to be. The same command answers for a *file* + path, where it degenerates to "is this in the index" -- which is what + `_warn_when_no_policy` asks it about `.stackward.toml`, so that both + callers get their answer from one place rather than from two spellings + of `git ls-files` that could come to disagree. + + Git's own `.git/hooks`, unredirected, never matches: paths under + `.git/` are not part of the tracked namespace at all, so this reliably + distinguishes that default location from a `core.hooksPath` + deliberately pointed at a tracked directory. + + A non-zero exit is deliberately *not* read as "not tracked" on its + own -- only two specific, recognised git failures are: the ordinary + in-repository case (`error: pathspec '...' did not match any + file(s) known to git`, exit 1) and a `core.hooksPath` that resolves + outside the repository entirely (`fatal: '...' is outside repository + at '...'`, exit 128 -- reachable because `_hooks_dir` resolves + symlinks, which can make the resolved path diverge from git's own + notion of the repository root). Both are matched on git's own + reported text, not the exit code alone, because exit 128 in + particular is shared with a broad class of unrelated fatal errors + (a corrupt index, a permissions problem) where whether the directory + is tracked is genuinely unanswered. Treating *any* of those as "safe + to write here" would be exactly the silent bypass of a mandated + refusal this function exists to prevent, and would make this the one + place in the module that inverts `_run_git`'s own rule of raising on + any non-zero exit rather than guessing -- so an unrecognised failure + raises `InstallError` here too, mapped by the caller to the same + could-not-determine exit code as every other git failure in this + command. + + Both recognised strings are gettext-marked in git's own source, so the + match below is only meaningful under a fixed locale. That is why this + goes through `_git`, which sets `LC_ALL=C` -- see its docstring; run + under a translated locale without it, *every* invocation in *every* + repository falls through to the raise below and `hooks install` exits + 2.""" + proc = _git(["ls-files", "--error-unmatch", str(path)]) + if proc.returncode == 0: + return True + stderr = proc.stderr + if "did not match any file" in stderr or "is outside repository" in stderr: + return False + raise InstallError( + f"could not determine whether {path} is tracked by git: {stderr.strip()}" + ) + + +def _running_executable() -> str: + """Absolute path to the running `stackward`, embedded verbatim as the + hook body's primary way of finding the binary (the `PATH` lookup in + `_hook_body` is only the runtime fallback). + + A frozen PyInstaller build's `sys.executable` *is* the compiled + `stackward` binary -- the same fact `cli.cmd_doctor` already reports + via `getattr(sys, "frozen", False)`. For an ordinary source or + console-script install, `sys.executable` is the Python interpreter + instead, which a bare `exec` cannot usefully invoke on its own; there, + `sys.argv[0]` is normally the console-script file itself -- an + executable file with its own shebang -- and resolving it to an + absolute path is what actually names the command that is running. + + `sys.argv[0]` is not always that, though: invoked as `python -m + stackward`, it resolves to `.../stackward/__main__.py`, a plain + source file with no shebang and no execute bit -- embedding it would + make the hook silently fall through to its own `PATH` fallback on + every single run, which is exactly the dependency this whole scheme + exists to avoid relying on (see the module docstring). Checked with + `os.access(..., os.X_OK)` rather than assumed, and resolved with + `shutil.which("stackward")` when it fails: on a normal install that + finds the very console script `sys.argv[0]` would have pointed to + when invoked the usual way. + """ + if getattr(sys, "frozen", False): + return sys.executable + argv0 = Path(sys.argv[0]).resolve() + if argv0.is_file() and os.access(argv0, os.X_OK): + return str(argv0) + found = shutil.which("stackward") + return found if found is not None else str(argv0) + + +def _hook_body(executable_path: str) -> str: + """The full text of the hook file: the marker, an edit-me-not notice, + and nothing that decides anything about a commit. `executable_path` is + embedded as a single-quoted, shell-escaped literal (safe even if it + contains a space) rather than interpolated into a double-quoted + string, which would let a `$`, backtick, or backslash in the path be + reinterpreted by the shell instead of taken literally. + """ + quoted = "'" + executable_path.replace("'", "'\\''") + "'" + return ( + "#!/bin/sh\n" + f"{_MARKER}\n" + "# Installed by `stackward hooks install` -- do not edit this file by\n" + "# hand; re-run that command instead (it is safe to run again, and\n" + "# picks up a newer stackward automatically). Everything this hook\n" + "# actually decides lives in `stackward pre-commit`, not here -- a\n" + "# rule added or fixed there reaches every clone through a normal\n" + "# stackward upgrade, with no change to this file at all.\n" + "set -eu\n" + "\n" + f"STACKWARD={quoted}\n" + 'if [ ! -x "$STACKWARD" ]; then\n' + ' STACKWARD="$(command -v stackward 2>/dev/null || true)"\n' + "fi\n" + 'if [ -z "$STACKWARD" ]; then\n' + ' echo "stackward: could not find the stackward executable" >&2\n' + ' echo " (checked the path recorded at install time, then PATH)" >&2\n' + " exit 1\n" + "fi\n" + "\n" + 'exec "$STACKWARD" pre-commit\n' + ) + + +def _is_stackward_hook(path: Path) -> bool: + """Whether the file at `path` is a hook this command wrote -- i.e. + carries `_MARKER` on a line of its own. Never raises: an unreadable or + binary existing file simply is not recognised as ours, which is the + correct, safe answer either way -- it gets backed up rather than + mistaken for a hook this command can overwrite in place.""" + try: + content = path.read_text() + except OSError: + return False + return any(line.strip() == _MARKER for line in content.splitlines()) + + +def _backup_path(hook_path: Path) -> Path: + """`.backup.`, microsecond-resolution so two + installs in the same repository within the same second cannot collide + and silently overwrite one backup with another.""" + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + return hook_path.with_name(f"{hook_path.name}.backup.{stamp}") + + +def _install_hook(hooks_dir: Path, executable_path: str) -> Path | None: + """Write the `pre-commit` hook into `hooks_dir`, creating the directory + if it does not yet exist (a `core.hooksPath` redirected to a directory + nothing has populated yet, most notably). + + Returns the path an existing *foreign* hook was backed up to, or + `None` when there was nothing to back up -- either no hook was present, + or the one present already carried `_MARKER` and was simply overwritten + in place. That distinction is what makes installing twice leave exactly + one hook and create no second backup: the second run finds its own + marker on the first run's hook and overwrites it directly, rather than + backing it up as if it were foreign. + + Always `chmod 0o755` after writing, explicitly -- never assumed from a + default `open()` mode, which a restrictive umask could leave non- + executable, and never inherited from a prior file at the same path. + """ + hooks_dir.mkdir(parents=True, exist_ok=True) + hook_path = hooks_dir / _HOOK_NAME + + backup_path: Path | None = None + if hook_path.exists() or hook_path.is_symlink(): + if not _is_stackward_hook(hook_path): + backup_path = _backup_path(hook_path) + hook_path.rename(backup_path) + + hook_path.write_text(_hook_body(executable_path)) + hook_path.chmod(0o755) + return backup_path + + +@fail_closed +def cmd_install_hooks(_args: argparse.Namespace) -> int: + """Entry point for `stackward hooks install`. + + Exit codes: **0** installed (fresh, updated in place, or an existing + foreign hook backed up first); **1** refused because the resolved hooks + directory is tracked by git (see the module docstring for why writing + there is unsafe); **2** could not even determine where to install, or + whether the resolved hooks directory is tracked -- `git` missing, the + current directory not inside a git working tree, or an unrecognised + `git ls-files` failure (see `_is_tracked`). + Reusing `check_config.fail_closed` here for the same reason it wraps + `cmd_check_config` and `cmd_pre_commit`: an unanticipated exception + must not fall through to Python's own default exit code of 1, which + this tool reserves for a specific, different meaning everywhere else it + appears. + + A repository with no policy file gets a **warning** after the hook is + written, never a refusal, and the exit code stays 0. Installing the hook + before writing the policy is a legitimate order to do things in, and + exit 1 and 2 here mean specific things about the *install* that this is + not one of -- but without the warning, a repository that installed the + gate and never declared a policy discovers that at somebody's first + blocked commit (`pre-commit` refuses a repository that declares none) + rather than at the moment it could still be fixed in one line. + """ + try: + hooks_dir = _hooks_dir() + tracked = _is_tracked(hooks_dir) + except InstallError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + if tracked: + print(f"error: hooks directory is tracked by git: {hooks_dir}", file=sys.stderr) + print( + "Installing here would commit a hook that execs a stackward binary " + "other clones may not have, blocking their commits. Chain from the " + "tracked hook instead: have it invoke `stackward pre-commit` itself " + "(with the same PATH fallback this command would have used) rather " + "than letting this command overwrite it.", + file=sys.stderr, + ) + return 1 + + executable = _running_executable() + hook_path = hooks_dir / _HOOK_NAME + backup_path = _install_hook(hooks_dir, executable) + if backup_path is not None: + print(f"Existing hook backed up to {backup_path}") + print(f"Installed pre-commit hook -> {hook_path}") + _warn_when_no_policy() + return 0 + + +def _warn_when_no_policy() -> None: + """Warn on stderr when the hook just installed will not find a policy. + + **The question is what `pre-commit` will see, not what is on disk.** + That gate reads its `[check]` policy from the *index* -- deliberately, + since a policy read from the working tree could relax the rules for a + commit that does not itself carry the relaxation -- so a + `.stackward.toml` that exists but has never been staged is, to the hook + being installed here, no policy at all. Asking `find_repo_config` alone + reads the working tree and answers a different question than the one + this warning is for: the repository got no warning and then blocked + every commit. That state is a *more* likely one right after `hooks + install` than a wholly absent policy, since writing the file and + installing the hook is one sitting and staging it is a separate act. + + Three outcomes, and the third is the reason the other two are worth + distinguishing: + + * Nothing in the working tree: say so, and name the minimal file. + * Present but not in the index: say *that*, and name `git add` -- the + remedy is one word, and "you have no policy" would be visibly false + to someone looking at the file. + * In the index: silence, whatever the file's content. A + `.stackward.toml` that does not parse is present as far as this is + concerned -- reporting it is the gate's job, at the point it actually + reads it, and duplicating that judgement here would give one + repository two different verdicts on the same file. + + One bounded gap, stated so it is a decision and not an oversight: a + policy that is in the index but deleted from the working tree gets the + "declares none" message, though the hook would in fact find it. Closing + it means a second copy of `pre_commit._index_config_path`'s + nearest-first candidate search, and two copies of that would be a worse + defect than the wrong wording on a state nobody reaches by accident. + + Deliberately *after* the hook is written and deliberately not an error: + see `cmd_install_hooks`'s own docstring. That is also why a git failure + here is silence rather than a raise -- this function runs after the + install has already succeeded, inside a `@fail_closed` command, so an + escaping `InstallError` would report exit 2 for a hook that is on disk + and working. Nothing is lost: the gate itself reports a policy it + cannot find, in better words, at the moment it matters. + + On stderr, not stdout, for the same reason `store.warn_if_permissive` + puts its warning there -- this command's stdout says where the hook + landed, and a caller reading that must not have to filter advisory text + out of it. + """ + path = find_repo_config() + if path is None: + print( + f"warning: this repository declares no {CONFIG_FILENAME}, and " + "`stackward pre-commit` refuses a repository that declares none " + "-- the hook is installed, but every commit will be blocked " + "until one exists.", + file=sys.stderr, + ) + print( + f"Create {CONFIG_FILENAME} at the repository root with at least:\n" + + "\n".join(f" {line}" for line in MINIMAL_POLICY.splitlines()), + file=sys.stderr, + ) + return + + try: + if _is_tracked(path): + return + except InstallError: + return + + print( + f"warning: {path} exists but is not staged, and `stackward " + "pre-commit` reads its policy from the index rather than the " + "working tree -- the hook is installed, but every commit will be " + "blocked until the policy is staged too.", + file=sys.stderr, + ) + print(f"Stage it: git add {CONFIG_FILENAME}", file=sys.stderr) diff --git a/src/stackward/commands/pre_commit.py b/src/stackward/commands/pre_commit.py new file mode 100644 index 0000000..cffa4ea --- /dev/null +++ b/src/stackward/commands/pre_commit.py @@ -0,0 +1,517 @@ +"""`pre-commit`: the gate, run as a git hook body over *staged* content. + +Registered in `cli.build_parser` as `stackward pre-commit`, taking no +arguments -- it always operates on the current repository's index, exactly +as a git hook would invoke it. Installing this as the actual hook body is +Task 5's job (`commands.install_hooks`); this module only implements what +runs once invoked. + +This is the layer that actually *prevents* a credential from being +published: it runs before the commit object exists, so a block here means +the credential never enters history at all. A CI-side check (elsewhere in +this project's plan) can only ever report one that is already public. +Consequently every rule below leans the same way when in doubt: refuse +rather than guess, because the cost of a false positive here is a rerun, +and the cost of a false negative is permanent. + +**Staged, never working-tree -- policy included.** Every path this module +reads goes through `git show ":"` (see `_read_staged_blob`), which is +stage 0 of the index as it stands right now -- not `Path.read_text()`. An +unstaged fix sitting only in the working tree must not let an already-staged +credential through, and a working-tree file that merely *looks* clean must +not be mistaken for what will actually be committed. + +`.stackward.toml` is read the same way (`_index_config_path`, +`_load_check_policy`), and it took a review to notice it had not been. +Reading content from the index while reading the *rules* from the working +tree is a fail-open with a working exploit: stage a credential, then edit +the working-tree `.stackward.toml` to add an `allowed_references` entry for +it -- staging nothing -- and this command exits 0. The credential is +committed under a relaxation that is not itself being committed, and the +next clone of the repository contains the leak but not the excuse. Three +knobs weaken from that direction (`allowed_references` adds a suppression, +`model_net = "none"` drops a whole net, and `sensitive_parents` *replaces* +rather than extends, so the worktree can shrink what the index declared); +`nets.model.verify_sources` cannot close any of them, because it compares an +index blob against a recorded blob and a worktree-only edit touches neither, +and under `model_net = "none"` it does not run at all. Reading policy from +the index closes all three at once: a relaxation only takes effect in the +same commit that carries it. + +**Content-level, not file-level.** `_scan_staged_config` calls +`nets.heuristic.find_plaintext_credentials` and +`nets.model.find_declared_credentials` directly with an already-parsed +document, the same content-level entry points +`commands.check_config.scan_file` calls for a file on disk. Neither this +module nor that one ever round-trips a blob through a temporary file to +reuse the other's file-path wrapper -- doing that here specifically would +write staged (possibly credential-bearing) content to disk outside the +index, and would reintroduce the working-tree-vs-index confusion this +module exists to avoid. + +**Both nets run here, not only the heuristic one.** This is the layer that +prevents publication; `check-config`, invoked by hand, reports a leak that +may already be committed. A model net running only on manual invocation +would be absent at the one moment it counts, so the stronger of the two nets +would not be protecting anything. The two are unioned through a set, so a +leaf both name is reported once. + +**The declared-secrets artifact is read from the index too**, by +`nets.model.load_model_net` -- the same rule as every other path this module +reads, and for the same reason: an unstaged regeneration must not vouch for +the stale artifact that is actually about to be committed. A missing, +malformed or stale artifact under `model_net = "artifact"` exits 2, never 1; +`model_net = "none"` skips that net without reading anything at all. + +**No credential resolution.** This module imports `yaml`, `..config` and +`..nets.heuristic` -- never `cryptography` and never any credential +provider. The gate decides whether a value looks like a plaintext +credential; it never fetches, decrypts, or connects anywhere to do it. + +**Path discovery from any working directory.** `git diff --cached +--name-only` and `git show ":"` both resolve paths relative to the +repository's top level regardless of the caller's current working +directory (verified empirically: a path printed by the first command, +handed unchanged to the second, resolves correctly whether invoked from +the repo root or a subdirectory several levels down). `_index_config_path` +reproduces `config.find_repo_config`'s upward walk in those same +repository-relative terms, using `git rev-parse --show-prefix` for where +the caller is standing -- so a `.stackward.toml` in a subdirectory is found +from below it and ignored from beside it, exactly as the working-tree walk +would. Between the two, this command needs no special handling for "the hook +ran from a subdirectory": there is no such thing, only "the hook ran +somewhere inside the repository." +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import PurePosixPath + +import yaml + +from ..config import CONFIG_FILENAME, CheckConfig, ConfigError, load_config_text +from ..nets.heuristic import DocumentError, find_plaintext_credentials +from ..nets.model import ( + ModelNet, + ModelNetError, + find_declared_credentials, + load_model_net, +) +from .check_config import ( + CheckError, + MissingPolicyError, + describe_yaml_error, + fail_closed, + missing_policy_message, +) + +_STATE_EXPORT_NAME = "state.json" +_STATE_EXPORT_SUFFIX = ".stack-export.json" + +_STACK_CONFIG_PREFIX = "Pulumi." +_STACK_CONFIG_SUFFIX = ".yaml" + + +def _run_git(args: list[str]) -> bytes: + """Run `git ` from the current working directory and return its + stdout as bytes. + + Raises `CheckError` for anything that means this check cannot proceed: + the `git` executable missing, the current directory not inside a work + tree, or the invoked subcommand failing for any git-reported reason + (including `git show` on a path with no stage-0 entry -- an unmerged + path). The message includes git's own stderr text verbatim: the only + invocations this module makes are `diff --cached --name-only`, + `rev-parse --show-prefix`, `ls-files` and `show ":"`, and a + *failing* run of any of them never gets far enough to have printed blob + content -- a non-zero exit here means git could not produce output at + all, so its diagnostic is always about a path or a ref, never about what + a blob contains. + + Runs under `LC_ALL=C`. Everything below branches on `returncode` alone + and never on git's message text, so this is hardening rather than a live + fix here -- but git's diagnostics are gettext-marked, and a message + quoted into a `CheckError` should read the same in a bug report as it did + on the machine that hit it. The environment is *merged*, never replaced: + git sets `GIT_INDEX_FILE`, `GIT_DIR` and friends for a hook process, and + dropping them would make this command read a different index than the + commit it is gating. + """ + env = {**os.environ, "LC_ALL": "C"} + try: + proc = subprocess.run( + ["git", *args], capture_output=True, check=False, env=env + ) + except OSError as exc: + raise CheckError(f"cannot run git {' '.join(args)}: {exc}") from exc + if proc.returncode != 0: + stderr = proc.stderr.decode("utf-8", errors="replace").strip() + raise CheckError(f"git {' '.join(args)} failed: {stderr}") + return proc.stdout + + +def _staged_paths(diff_filter: str) -> list[str]: + """Staged paths matching `diff_filter`, exactly as git names them -- + relative to the repository's top level regardless of the caller's own + working directory (see the module docstring). + + Always `-z`, split on NUL: without it, git quotes and C-escapes any + path containing a space, a newline, or a non-ASCII byte, which would + corrupt exactly the paths this command exists to protect. + + Called with `"ACMRT"` for the files actually being committed, and + separately with `"U"` to find anything unmerged. + + `R` matters: a rename plus an edit stages as `R`, and a filter that + omitted it would let that change bypass every check below. `T` + (type-change) matters for the same reason, discovered in review of + the brief's own literal `"ACMR"`: converting an *already-tracked* + `Pulumi..yaml` into a symlink stages as `T`, not `A`/`M`/`R` -- + without `T`, that file would vanish from this listing exactly the way + an unmerged path does, and the credential scan below would never see + it at all. A *new* symlink stages as `A`, which `"ACMRT"` already + covered even before `T` was added; `T` closes the gap for a symlink + replacing a file git was already tracking as ordinary content. + + Deliberately never called combined with `"U"` (`"ACMRTU"`): an + unmerged path must be caught by the dedicated check in + `cmd_pre_commit` and refused before anything else runs, not folded + into the "files to scan" list, where finding it there could be + mistaken for having already decided what to do with it. + """ + raw = _run_git( + ["diff", "--cached", "--name-only", "-z", f"--diff-filter={diff_filter}"] + ) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"staged path list is not valid UTF-8: {exc}") from exc + return [path for path in text.split("\0") if path] + + +def _is_state_export(path: str) -> bool: + """`state.json`, or a name ending `.stack-export.json` -- matched by + basename, so one nested under any directory still counts. Either shape + is a full Pulumi state export: every resource input the stack has, + credentials included, in plaintext, by design rather than by mistake. + """ + name = PurePosixPath(path).name + return name == _STATE_EXPORT_NAME or name.endswith(_STATE_EXPORT_SUFFIX) + + +def _is_pulumi_stack_config(path: str) -> bool: + """`Pulumi..yaml`, matched by basename on the `Pulumi.` prefix + and `.yaml` suffix rather than a restrictive character class -- + `` may itself contain dots (`Pulumi.prod.eu.yaml` is a legal + stack name). + + The empty-stack case is excluded on purpose. `Pulumi.yaml` -- the + *project* file, which holds no stack config at all -- also satisfies a + naive `name.startswith("Pulumi.") and name.endswith(".yaml")`, because + the two literals overlap on the single "." they share: all 11 + characters of `"Pulumi.yaml"` are covered by `"Pulumi."` (the first 7) + and by `".yaml"` (the last 5) at the same time, with nothing left over + for a stack name to occupy. Requiring a non-empty slice between the two + literals is what tells `Pulumi.yaml` apart from `Pulumi.dev.yaml`. + """ + name = PurePosixPath(path).name + if not (name.startswith(_STACK_CONFIG_PREFIX) and name.endswith(_STACK_CONFIG_SUFFIX)): + return False + stack = name[len(_STACK_CONFIG_PREFIX) : -len(_STACK_CONFIG_SUFFIX)] + return bool(stack) + + +def _read_staged_blob(path: str) -> str: + """The staged (index) content of `path`, decoded as UTF-8 -- never the + working tree. `git show ":"` reads stage 0 of the index exactly + as it stands right now, so a working-tree edit made after `git add` + (or never staged at all) cannot hide a credential that is already + staged, and cannot be mistaken for one that is not. + + Raises `CheckError` if git cannot produce the blob at all -- most + notably a path with no stage-0 entry, i.e. unmerged, though + `cmd_pre_commit`'s dedicated check should already have refused before + this is ever reached for such a path -- or if the bytes it does + produce are not valid UTF-8. + """ + raw = _run_git(["show", f":{path}"]) + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"{path}: staged content is not valid UTF-8: {exc}") from exc + + +def _scan_staged_config( + path: str, check: CheckConfig, net: ModelNet | None +) -> list[str]: + """Content-level scan of `path`'s *staged* blob, by both nets. + + Mirrors `check_config.scan_file` exactly, except the source of text is + `_read_staged_blob` (the index) rather than `Path.read_text()` (the + working tree): both parse with `yaml.safe_load`, hand the resulting + document straight to the two content-level nets, union the results + through a set (a leaf both name is one problem, not two), and wrap + every way that can fail in `CheckError` -- unreadable/undecodable + content, invalid YAML (via `describe_yaml_error`, which redacts what + PyYAML would otherwise interpolate into its own message), or a + document that is not a mapping. + + `net` is `None` when the repository declares `model_net = "none"`; the + heuristic net always runs. There is no third case where it is `None` + because no policy was found -- that is a refusal now, not a scan. + """ + text = _read_staged_blob(path) + try: + document = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise CheckError(f"{path}: invalid YAML: {describe_yaml_error(exc)}") from exc + try: + found = set(find_plaintext_credentials(document, check)) + if net is not None: + found |= set(find_declared_credentials(document, net, check)) + except DocumentError as exc: + raise CheckError(f"{path}: {exc}") from exc + return sorted(found) + + +def _index_config_path() -> str | None: + """Repository-relative path of the nearest `.stackward.toml` **in the + index** at or above the current directory, or `None` if the index holds + none. + + The index-side twin of `config.find_repo_config`, and it walks in the + same direction for the same reason: a policy file beside the caller + beats one at the repository root. `git rev-parse --show-prefix` gives + the current directory as a repository-relative prefix -- git's own + answer, in git's own byte encoding, so no assumption is needed about + where the process's `cwd` sits relative to a symlinked work tree, and + (verified) it is not subject to `core.quotePath` mangling the way + `ls-files` output would be without `-z`. + + Candidates are matched with `:(literal,top)` pathspec magic. `top` is + load-bearing: `ls-files`' pathspecs resolve against the *caller's* + directory, not the repository root, so a hook invoked from a + subdirectory would otherwise look for `//.stackward.toml` + and find nothing. `literal` is belt-and-braces -- verified empirically + that git compares a pathspec's literal text against each path before + falling back to wildmatch, so an exact-path pathspec containing `[`, `*` + or `?` matches its own directory either way -- and it is kept because + the intent here is an exact path, and nothing downstream should have to + depend on that fallback ordering staying as it is. What actually makes + a metacharacter harmless is the line below: a candidate is accepted only + by exact string equality against this list, never by taking whatever git + happened to print. `--full-name` makes the answer repository-relative, + which is what `git show ":"` then wants, and `-z` keeps + `core.quotePath` from escaping a non-ASCII path into something no + candidate would equal. + """ + raw_prefix = _run_git(["rev-parse", "--show-prefix"]) + try: + prefix = raw_prefix.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"repository path is not valid UTF-8: {exc}") from exc + # Split rather than strip: a directory name may legally begin or end + # with a space, and `.strip()` would silently rename it. + parts = [part for part in prefix.rstrip("\n").split("/") if part] + + # Nearest first, ending at the repository root -- `find_repo_config`'s + # order, so the two helpers cannot disagree about which file wins. + candidates = [ + "/".join([*parts[:depth], CONFIG_FILENAME]) + for depth in range(len(parts), -1, -1) + ] + listed = _run_git( + [ + "ls-files", + "-z", + "--full-name", + "--", + *(f":(literal,top){candidate}" for candidate in candidates), + ] + ) + try: + text = listed.decode("utf-8") + except UnicodeDecodeError as exc: + raise CheckError(f"index path list is not valid UTF-8: {exc}") from exc + present = {path for path in text.split("\0") if path} + for candidate in candidates: + if candidate in present: + return candidate + return None + + +def _load_check_policy() -> CheckConfig: + """The `[check]` policy this commit will actually carry, read from the + **index** -- never from the working tree. + + Not the same three lines as `check_config`'s helper of the same name, + and the difference is the point rather than an oversight: `check-config` + answers "is this file on disk clean?" and so reads its policy from disk, + while this command answers "is what I am about to commit clean?" and so + reads both the content and the rules from the index. Reading the rules + from the working tree was a fail-open with a working exploit -- see the + module docstring for it and for the three knobs that weaken from that + direction. + + A policy file present in the working tree but never staged is therefore + *not* a policy as far as this command is concerned, which is the same + rule it already applies to every stack config it scans. Absence is a + refusal (`MissingPolicyError`), never the built-in defaults: those carry + `model_net = "none"`, so returning them here would drop the stronger net + in exactly the repositories that had never said anything about it. + """ + config_path = _index_config_path() + if config_path is None: + raise MissingPolicyError( + missing_policy_message( + where="in the index", + remedy=( + f"then stage it: git add {CONFIG_FILENAME} " + "(this command reads its policy from staged content, " + "exactly like the files it scans, so a relaxation cannot " + "take effect without being committed alongside what it " + "relaxes)" + ), + ) + ) + # Labelled the way a person would reproduce it: `git show ":"`. + return load_config_text(_read_staged_blob(config_path), f":{config_path}").check + + +@fail_closed +def cmd_pre_commit(_args: argparse.Namespace) -> int: + """Entry point for `stackward pre-commit` -- the hook body's whole job. + + Exit codes, never conflated (see the module docstring in + `check_config.py` for why this distinction matters to a later, + fail-closed refusal): **0** clean; **1** a credential was found by either + net, or a Pulumi state export was staged (refused outright, since a + state export is credentials by construction, not by finding); + **2** the check could not run at all -- an unmerged index entry, a git + command failing, staged content that is not decodable or not valid + YAML, a document that is not a mapping, a `.stackward.toml` that is + invalid *or absent from the index*, a missing or stale + declared-secrets artifact under `model_net = "artifact"`, or an + unanticipated exception. Python's own default for an uncaught + exception is exit 1, which here would misreport "a credential was + found" for a file this command never actually finished evaluating -- + every per-file scan below is wrapped accordingly, and the `@fail_closed` + decorator above is the outer boundary that catches anything else this + function does not: policy loading (`_load_check_policy`) and the + staged-file listing (`_staged_paths`) both run *outside* the per-file + loop, guarded only by `except (ConfigError, CheckError)` / `except + CheckError` respectively, so a different, unanticipated exception from + either -- an `OSError` from the `git` invocation layer that neither + converts, say -- needs `@fail_closed` to avoid escaping uncaught and + exiting 1 by Python's own default. + + Checks run in this fixed order, each a hard gate before the next: + + 1. Unmerged index entries. Not a content check at all -- a + precondition on whether the index can be trusted enough to read. + `--diff-filter=ACMRT`, used for everything below, silently + *excludes* unmerged paths (git reports them as bare status `U`, + which matches none of `A`, `C`, `M`, `R`, `T`), so without this + dedicated check an unmerged file would never reach either check + below and this command would report "clean" for a commit git + itself already refuses to allow. Checked for *any* unmerged path, + not only ones that look like a stack config or state export: git's + own refusal to commit ("Committing is not possible because you + have unmerged files") is likewise unconditional on which path is + unmerged, and this command can be invoked directly, bypassing that + refusal. + 2. Staged Pulumi state exports (`state.json`, `*.stack-export.json`) -- + refused outright, before the credential scan below even starts, and + deliberately *before* the policy is loaded: this refusal needs no + policy to make (a state export is credentials by construction, not + by finding), and exit 1 is the more specific answer than the exit 2 + a missing or unparseable policy would produce. A repository that has + not written a `.stackward.toml` yet still gets told it staged a + state export, rather than being told about its config first. + 2a. The `[check]` policy, loaded from the index (`_load_check_policy`). + A repository with none is refused here, exit 2, naming the minimal + file to create -- never scanned under `CheckConfig()`'s defaults. + 3. The declared-secrets artifact, loaded from the index. Loaded once, + before the scan loop, and a failure to load it exits 2 having + reported nothing: a stale artifact means this command does not know + what the repository declared, so printing the *other* net's findings + and exiting 1 would claim a complete answer it does not have. + 4. Both nets, unioned, over every staged `Pulumi..yaml`. + + Never prints a credential value: a finding is reported only by its + rendered config path, exactly like `check-config`. + """ + try: + unmerged = _staged_paths("U") + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + if unmerged: + for path in sorted(unmerged): + print(f"error: {path}: unresolved merge conflict in the index", file=sys.stderr) + print("error: resolve conflicts and re-stage before committing", file=sys.stderr) + return 2 + + try: + staged = _staged_paths("ACMRT") + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + state_exports = sorted(path for path in staged if _is_state_export(path)) + if state_exports: + print( + "State exports (state.json, *.stack-export.json) hold every " + "resource input a stack has, credentials included, in plaintext." + ) + for path in state_exports: + print(f"{path}: Pulumi state export staged -- refused") + print(f" fix: git restore --staged '{path}'") + print("git commit --no-verify bypasses this hook.") + return 1 + + try: + check = _load_check_policy() + except (ConfigError, CheckError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + try: + net = load_model_net(check) + except ModelNetError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + stack_configs = sorted(path for path in staged if _is_pulumi_stack_config(path)) + + findings: list[tuple[str, str]] = [] + errored = False + for path in stack_configs: + try: + found = _scan_staged_config(path, check, net) + except CheckError as exc: + print(f"error: {exc}", file=sys.stderr) + errored = True + continue + except Exception as exc: # noqa: BLE001 - fail closed on anything unanticipated + print( + f"error: {path}: could not check ({type(exc).__name__})", + file=sys.stderr, + ) + errored = True + continue + findings.extend((path, finding) for finding in found) + + if findings: + for file_path, finding_path in sorted(findings): + print(f"{file_path}: plaintext credential at '{finding_path}'") + print(f" fix: pulumi config set --secret --path '{finding_path}'") + print("git commit --no-verify bypasses this hook.") + + if errored: + return 2 + return 1 if findings else 0 diff --git a/src/stackward/commands/session.py b/src/stackward/commands/session.py new file mode 100644 index 0000000..3b454ac --- /dev/null +++ b/src/stackward/commands/session.py @@ -0,0 +1,715 @@ +"""`login`, `exec`, `shell`: point Pulumi at a profile's backend, and run +commands with that profile's credentials in the environment. + +**Why this module exists at all.** `pulumi login` persists only the backend +URL. `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `PULUMI_CONFIG_PASSPHRASE` +are read from the environment of *every subsequent* `pulumi` process -- `up`, +`preview`, `config get`, `stack ls`. An encrypted store only `stackward` can +read therefore breaks a plain `pulumi up` outright: `exec` is what makes the +store usable in practice, not an optional convenience layered on top of it. + +**Secrets exist only in the child's environment -- and only the right ones.** +`exec` and `shell` decrypt a profile, build one `dict` holding the caller's +own environment plus four names, and hand it to `subprocess.run` as `env=`. +Nothing here writes a credential to disk, logs one, or lets one reach `argv`. +But "the caller's own environment" is not handed through unfiltered: +`STACKWARD_PASSWORD` -- the *store* password, which unlocks every profile, +not just the one being run with -- is scrubbed from every child's +environment, `login`'s included, by `_environ_without_store_password`. A +review of this module found the gap concretely: in the single-command form +`STACKWARD_PASSWORD=x stackward exec -- pulumi up`, the user's intent is +unambiguously "stackward only", and without scrubbing it, arbitrary +caller-supplied code would inherit a secret wider in scope than the one it +was actually invoked to receive. Every error path in this module names a +profile, a key, a file or a redacted backend URL, and never a credential +value. + +**Two AWS names are removed from the child, and never supplied.** +`AWS_SESSION_TOKEN` and `AWS_PROFILE` (`SCRUBBED_NAMES`) are dropped by +`exec`/`shell` -- not because they are secrets this tool owns, but because +either one *next to* a freshly injected long-lived key pair is a live +misconfiguration whose failure names neither of them. See `SCRUBBED_NAMES` +for the full argument, including why `login` keeps both. + +**`shell` inherits, it does not replace.** Both `exec` and `shell` overlay +exactly four names onto a *copy* of the caller's own environment +(`PATH`, `HOME`, `TERM` and everything else the child would otherwise need +survive untouched); they never construct an environment from the four names +alone. `tests/test_session.py` asserts this by diffing parent and child +environments rather than by counting keys in the child -- and, since the +diff alone proved less than it appeared to, by seeding all three credential +names into the *parent* environment with different values, so that each of +the four is asserted by value and not merely by presence. Without that +seeding the diff was blind to the substitution that matters most: +`env.setdefault(name, ...)` in place of `env[name] = ...` passed the entire +suite, and hands a developer's own exported `AWS_ACCESS_KEY_ID` to a child +pointed at a different profile's backend. + +**The backend guard applies to `exec` and `shell`, never to `login`.** +`login` is the command that *sets* Pulumi's persisted backend; gating it on +matching that same state would make switching profiles impossible. The guard +instead sits in front of the two commands that run someone else's code with a +profile's credentials live in its environment: before doing that, this module +compares the resolved profile's backend URL against Pulumi's own persisted +`current` backend (see `_current_backend`) and refuses on any mismatch, naming +both. Without it, a second profile is merely a convenience -- a `pulumi` +invocation that does not go through `stackward exec` (a stray script, a +forgotten shell alias, a plugin) would still silently use whichever backend +the last `login` pointed at. + +**Reading Pulumi's current backend never touches the network.** +`pulumi whoami`, `pulumi about` and `pulumi whoami --json` were all tried +against a real (unreachable) S3 backend during development of this module, +and every one of them attempts to contact the backend itself -- exactly the +network dependency a pre-flight safety check must not have (a guard that can +hang or fail on network conditions unrelated to the mismatch it exists to +catch is not a guard). `pulumi login ` was also observed, empirically, +to write the URL verbatim to `/credentials.json`'s `current` +field with no network access at all for a `file://` backend. This module +therefore reads that field directly. `PULUMI_HOME` (default `~/.pulumi`) is +Pulumi's own documented override, not knowledge about any particular +deployment of it. `PULUMI_BACKEND_URL`, when already present in *this* +process's own environment, is checked first and wins over the persisted +file: Pulumi's own CLI honours that variable over a stored login (`exec` +relies on exactly this to override `login`'s persisted state for a single +child), so a stray `PULUMI_BACKEND_URL` a user has exported into their shell +is what a bare `pulumi` command would actually use -- and is therefore what +the guard must compare against, not the file underneath it. + +**A backend URL is never put in argv, and is redacted wherever it is +printed.** Most schemes (`s3://`, `gs://`, `azblob://`, `file://`) carry no +secret, but `pulumi login --help` documents `postgres://user:password@host/db` +as a supported backend form, and Global Constraint 3 names `argv` visibility +explicitly. `login` therefore sets `PULUMI_BACKEND_URL` in `pulumi login`'s +environment and invokes it with **no** positional URL argument, rather than +`pulumi login ` -- verified, empirically, that a bare `pulumi login` +with only `PULUMI_BACKEND_URL` set logs in identically for a `file://` +backend, with no network access. The backend guard's mismatch message still +names both URLs (that is the point of it), but through `_redact_url`, which +replaces `user:pass@` with `@` wherever it appears in a printed +backend URL. + +**The store password is never read from `sys.stdin`.** `exec` decrypts a +profile before handing the *child's* stdin, stdout and stderr through +untouched -- a `stackward shell` session and a `stackward exec -- pulumi up` +that itself prompts for confirmation both depend on that descriptor being +free. `_read_store_password` checks `STACKWARD_PASSWORD` first (the +non-interactive path: CI has no controlling terminal to prompt against) and +otherwise prompts with `getpass.getpass`, which itself is verified, before +ever being called, to be able to open `/dev/tty` directly -- if it cannot, +this module raises rather than letting `getpass` fall back to its own +`sys.stdin` read, which would both consume bytes meant for the child and +echo them to the screen. `login` needs none of this: it resolves a backend +URL from `config` (plaintext, unencrypted) and never opens the credentials +store at all, which is also why a profile can be logged into before its +credentials have ever been set (see `store.py`'s own note on this). + +**Exit status is translated, not passed through raw.** `subprocess` +represents a child killed by a signal as a *negative* return code; handing +that negative number to `sys.exit` gets reduced modulo 256 at the OS boundary +and no longer identifies the signal. `_exit_status` converts it to the +`128 + signal` form a POSIX shell's own `$?` would report, which is the one +representation `sys.exit` can carry through intact. + +**A `Ctrl-C` while the child is running prints no traceback.** CPython +re-raises `SIGINT` as `KeyboardInterrupt`, which `fail_closed` does not catch +(it inherits from `BaseException`, not `Exception`) -- an uncaught one +already exits 130 by Python's own default, so the exit status was never +wrong, but reaching the top of the process uncaught also prints a traceback, +which reads as a crash on `stackward exec -- pulumi up`'s single most common +interrupt path. `_run_child` catches it around the `subprocess.run` call and +returns `_exit_status(-signal.SIGINT)` -- the same 130, with nothing printed. +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import re +import shutil +import signal +import subprocess +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING +from urllib.parse import quote, urlencode + +from ..config import ConfigError, find_repo_config, load_config +from ..store import ( + Profile, + StoreError, + load_store_config, + select_profile, +) +from .check_config import fail_closed + +if TYPE_CHECKING: + # Never imported at runtime -- see `_build_credential_store`'s own + # docstring on why provider modules stay off this module's own top + # level. `TYPE_CHECKING` is always `False` when this file actually runs, + # so this line never touches `sys.modules`. + from ..providers import CredentialStore + +# Read once, never printed: the non-interactive escape hatch for `exec`/ +# `shell` in an environment with no controlling terminal (CI, most notably). +# Never accepted as a CLI argument -- only ever an environment variable, per +# the same rule that keeps every other secret out of argv and out of `ps`. +ENV_STORE_PASSWORD = "STACKWARD_PASSWORD" + +# The three names `resolve_credentials` must return for a profile before +# `exec`/`shell` can run anything -- the exact vocabulary `pulumi` itself +# reads from a child process's environment (see the module docstring). Named +# here, not in `store.py`: the store deliberately does not know which +# variables a backend needs, so this is the one place in the codebase that +# does. +AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID" +AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY" +PULUMI_CONFIG_PASSPHRASE = "PULUMI_CONFIG_PASSPHRASE" +PULUMI_BACKEND_URL = "PULUMI_BACKEND_URL" + +CREDENTIAL_NAMES = (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, PULUMI_CONFIG_PASSPHRASE) + +# Removed from the child's environment by `exec`/`shell`, and never put +# back. Neither is ever *injected*: the store holds no value for either, and +# `CREDENTIAL_NAMES` above stays the complete list of what is supplied. +# +# They are scrubbed rather than left alone because of what they do *beside* +# a freshly injected long-lived key pair, which is exactly the situation +# `exec` creates. `AWS_SESSION_TOKEN` is only valid for the temporary key +# pair it was issued with: next to this profile's own keys it is not merely +# stale, it makes every request fail to authenticate, and the error names +# neither the token nor the mismatch -- it reads as "your credentials are +# wrong", about credentials that are fine. `AWS_PROFILE` names a section of +# the caller's own shared credentials file, a separate source of keys and +# region entirely; a child that reads it has been told two different things +# about which account it is talking to, and which one wins depends on the +# SDK and the code path rather than on anything this tool said. Both are +# ordinary things to have exported (an assumed role, a shell with a profile +# selected), so this is the common case and not a rare one. +# +# `login` deliberately does not scrub them: it injects no credentials, so +# neither name can contradict one there, and reaching a backend through the +# caller's own ambient AWS configuration is a working setup that `login` +# has no business breaking. +SCRUBBED_NAMES = ("AWS_SESSION_TOKEN", "AWS_PROFILE") + + +class SessionError(Exception): + """Something specific to this module stopped `login`/`exec`/`shell` from + proceeding: no store password was available, a decrypted profile was + missing a required credential name, a backend could not be determined, or + the resolved backend does not match Pulumi's current one. + + Never carries a credential value -- every message here names a profile, a + key name, a file path or a backend URL, never a secret.""" + + +# Errors this module's own `cmd_*` entry points recognise and report with a +# one-line message rather than falling through to `fail_closed`'s generic +# "could not run ()". `StoreError` alone already covers `ProfileError` +# and `PasswordError`, both of which subclass it. +_KNOWN_ERRORS = (StoreError, ConfigError, SessionError) + + +def _repo_profile() -> str | None: + """The repository's own `profile =`, from the nearest `.stackward.toml` + above the current working directory, or `None` when there is none. + + Wires the precedence tier `select_profile` has always accepted but that + nothing supplied until this module existed: without this, a repository + declaring its own backend -- the normal case, per `store.select_profile`'s + own docstring -- would silently fall through to `default_profile` instead. + """ + path = find_repo_config() + if path is None: + return None + return load_config(path).profile + + +def _resolve_profile(explicit: str | None, *, directory: Path | None = None) -> Profile: + """`select_profile`, with the repository tier wired in. + + `_repo_profile()` is skipped entirely when `explicit` is given: an + explicit `--profile` is the highest-precedence source and answers the + question completely on its own, so a `.stackward.toml` that happens to be + unparsable elsewhere in the repository must not stop it from working. + Every other source (`STACKWARD_PROFILE`, `default_profile`) still leaves + `.stackward.toml` parsed and validated -- see `config.py`'s own "invalid + is never treated as absent" rule, which this preserves for every case + where the repository tier could actually be consulted. + """ + config = load_store_config(directory) + repo_profile = None if explicit is not None else _repo_profile() + return select_profile(explicit, config=config, repo_profile=repo_profile) + + +def _compose_backend_url(profile: Profile) -> str: + """The profile's backend URL, ready to hand to `pulumi login`/inject as + `PULUMI_BACKEND_URL`. + + `backend_url` passes through **byte-for-byte** -- never normalised, + re-encoded or reordered -- so that whatever a user (or a future backend + scheme) wrote is exactly what `pulumi` receives. Composing the component + form into a URL is this module's job precisely because Task 6's store + deliberately left it undone (see `store.Profile`'s own docstring): the + query-parameter spelling below is a fact about Pulumi's S3-compatible + backend, not about a profile. + + The composed form is `s3://[/][?region=...&endpoint=... + &s3ForcePathStyle=true]`. `s3ForcePathStyle` is only ever added alongside + `endpoint`: virtual-hosted-style addressing (the default without a custom + endpoint) does not work against most S3-compatible services, which is + exactly the case an `endpoint` in the profile signals. An empty string for + `prefix`, `region` or `endpoint` is treated the same as absent, since + there is no way to distinguish "the operator wrote an empty prefix on + purpose" from "the key was left blank" and the safe reading for an + optional component is to omit it. + + Never called with a profile that has neither form: `store._build_profile` + already refuses to construct one. The check below exists anyway for a + `Profile` built by hand (as a test can), so this function fails closed + rather than raising an unrelated `AttributeError`. + """ + if profile.backend_url is not None: + return profile.backend_url + if not profile.bucket: + raise SessionError(f"profile {profile.name!r} has no backend configured") + + url = f"s3://{quote(profile.bucket, safe='')}" + if profile.prefix: + url += "/" + quote(profile.prefix, safe="/") + + params: dict[str, str] = {} + if profile.region: + params["region"] = profile.region + if profile.endpoint: + params["endpoint"] = profile.endpoint + params["s3ForcePathStyle"] = "true" + if params: + url += "?" + urlencode(params) + return url + + +def _pulumi_home() -> Path: + """`PULUMI_HOME`, or its documented default. Not environment-specific + knowledge -- this is Pulumi's own override, the same way `XDG_CONFIG_HOME` + is the platform's.""" + home = os.environ.get("PULUMI_HOME") + return Path(home) if home else Path.home() / ".pulumi" + + +# Matches the userinfo segment of a URL -- `scheme://user[:pass]@` -- so it +# can be replaced wherever a backend URL is printed. A regex over the raw +# string rather than a full URL parse: `backend_url` is passed through +# verbatim and is never validated as a well-formed URL elsewhere in this +# codebase, and this needs to redact whatever shape actually shows up, not +# only the shapes a stricter parser would accept. `[^/@]*` is what confines +# the match to the first `@` -- the userinfo segment cannot itself contain an +# unencoded `/` or `@` -- so a later `@` in a path or query string is left +# alone. +_USERINFO = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@]*@") + + +def _redact_url(url: str) -> str: + """`url`, with any `user[:pass]@` userinfo replaced by `@`. + + Most backend schemes (`s3://`, `gs://`, `azblob://`, `file://`) never + carry a secret in the URL itself, but `pulumi login --help` documents + `postgres://user:password@host/db` as a supported backend form that + does. This is the one function everywhere a backend URL is printed + passes through, so that scheme's password is never one of the values + that ends up on screen -- see the module docstring. + """ + return _USERINFO.sub(r"\1@", url, count=1) + + +def _current_backend() -> str | None: + """What a bare `pulumi` invocation -- one not run through this module's + own `exec`/`shell`, and so not handed `PULUMI_BACKEND_URL` by them -- + would use as its backend right now. + + Checks *this* process's own `PULUMI_BACKEND_URL` first: Pulumi's CLI + honours that variable over a persisted login, so a value a user has + already exported into their shell is what a stray `pulumi` command would + actually use, and is exactly the drift the guard exists to catch (see + the module docstring). Only when that is unset does this fall back to + `/credentials.json`'s `current` field -- read directly, + never by shelling out to `pulumi`, which (see the module docstring) + contacts the backend itself even for a read-only status query. + + Returns `None` only for the legitimate "nothing else says otherwise" + state: no `PULUMI_BACKEND_URL`, and either no credentials file or one + with no `current` entry yet. Any other failure to read or parse the file + raises `SessionError` -- an unreadable or malformed credentials file must + not be silently treated as "nothing to guard against", which is exactly + the fail-open behaviour the backend guard exists to prevent. + """ + from_env = os.environ.get(PULUMI_BACKEND_URL) + if from_env: + return from_env + + path = _pulumi_home() / "credentials.json" + try: + raw = path.read_bytes() + except FileNotFoundError: + return None + except OSError as exc: + raise SessionError(f"cannot read {path}: {exc}") from exc + + try: + data = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SessionError( + f"{path}: not a valid pulumi credentials file ({type(exc).__name__})" + ) from exc + if not isinstance(data, dict): + raise SessionError(f"{path}: not a valid pulumi credentials file") + + current = data.get("current") + if current is None: + return None + if not isinstance(current, str): + raise SessionError(f"{path}: 'current' field is not a string") + return current + + +def _check_backend_guard(resolved_url: str) -> None: + """Refuse when Pulumi's persisted backend does not match `resolved_url`, + naming both (redacted -- see `_redact_url`). Never logged into anything + at all is not a mismatch -- it is an absence, and there is nothing yet + for `resolved_url` to conflict with; `stackward login` (or a first + `exec`/`shell`) is how it gets set.""" + current = _current_backend() + if current is not None and current != resolved_url: + raise SessionError( + "backend mismatch: this profile resolves to backend " + f"{_redact_url(resolved_url)!r}, but pulumi is currently logged " + f"in to {_redact_url(current)!r}. Run `stackward login` for " + "this profile first." + ) + + +def _tty_available() -> bool: + """Whether `/dev/tty` can be opened directly, independent of `sys.stdin`. + + A dedicated, monkeypatchable seam rather than inlining the `os.open` call + in `_read_store_password`: it is what lets that function's "no interactive + terminal" branch be exercised without a real detached process, and it is + the exact question `getpass.getpass` itself answers first -- checking it + ourselves means we can refuse *before* `getpass` would otherwise fall back + to reading (and echoing) `sys.stdin`, which -- for `exec`/`shell` -- is the + child's stdin, not a spare input source of our own to consume. + """ + try: + fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) + except OSError: + return False + os.close(fd) + return True + + +DEFAULT_PASSWORD_PROMPT = "stackward credential store password: " + + +def _read_store_password(prompt: str = DEFAULT_PASSWORD_PROMPT) -> str: + """`STACKWARD_PASSWORD` if set, otherwise an interactive prompt. + + Never reads `sys.stdin`. The environment variable exists for the + non-interactive case `exec`/`shell` must support (a CI job running + `stackward exec -- pulumi up` has no controlling terminal to prompt + against); the prompt exists for everyone else, and refuses outright, + rather than degrading to a visible, stdin-consuming fallback, when + `_tty_available` says `/dev/tty` cannot be opened. + + `prompt` only changes the wording. It exists so that + `commands.credentials` -- which asks for a password it is about to + *set*, and asks twice to confirm it -- can say which password it wants + without introducing a second way of obtaining one: the environment + variable, the `/dev/tty` check and the refusal are all decided here, in + one place, for every command that needs the store password. A caller + passing its own wording gets none of that logic to reimplement, and + cannot accidentally skip the parts of it that are load-bearing. + """ + from_env = os.environ.get(ENV_STORE_PASSWORD) + if from_env is not None: + return from_env + if not _tty_available(): + raise SessionError( + f"no store password available: set {ENV_STORE_PASSWORD}, or run " + "this command from an interactive terminal" + ) + return getpass.getpass(prompt) + + +def _require_credential_names(credentials: Mapping[str, str], profile: str) -> None: + """Refuse, naming the missing key names (never a value), when a + decrypted profile does not carry every name `exec`/`shell` must inject. + An envelope holding extra names beyond the three required ones is + accepted as-is; only those three are ever injected -- see `_child_env`.""" + missing = [name for name in CREDENTIAL_NAMES if name not in credentials] + if missing: + raise SessionError( + f"profile {profile!r} is missing required credential(s): " + f"{', '.join(missing)}" + ) + + +def _environ_without_store_password() -> dict[str, str]: + """A copy of the caller's own environment with `STACKWARD_PASSWORD` + removed. + + Shared by `_child_env` (`exec`/`shell`) and `login`'s own child + environment. The store password unlocks *every* profile in the store, + while what either command hands to a child is scoped to one profile + (`exec`/`shell`) or to nothing secret at all (`login`) -- inheriting it + into arbitrary caller-supplied code, or even into `pulumi` itself, widens + that scope for no reason any of the three commands need. A copy, never + `os.environ` itself: mutating the real mapping here would affect every + later call in this same process. + """ + env = dict(os.environ) + env.pop(ENV_STORE_PASSWORD, None) + return env + + +def _child_env(credentials: Mapping[str, str], backend_url: str) -> dict[str, str]: + """The caller's own environment, minus the store password and the two + `SCRUBBED_NAMES`, plus exactly the four session names. + + The removals happen here rather than in `_environ_without_store_password` + on purpose: that helper is shared with `login`, and `login` must keep + them (see `SCRUBBED_NAMES` for why the hazard is specific to a child + that has just been handed a different set of keys). + """ + env = _environ_without_store_password() + for name in SCRUBBED_NAMES: + env.pop(name, None) + for name in CREDENTIAL_NAMES: + env[name] = credentials[name] + env[PULUMI_BACKEND_URL] = backend_url + return env + + +def _build_credential_store( + profile: Profile, *, directory: Path | None +) -> "CredentialStore": + """The `CredentialStore` `profile.credentials`'s `provider` selects + (`"file"` when the table or the key is absent, preserving today's + behaviour unchanged). + + Provider modules are imported here, function-locally -- never at this + module's own top level -- for the reason `providers/__init__.py`'s + module docstring states: `cli.py` imports this module unconditionally, + so a module-scope import here would put a provider on every + `stackward` invocation's import graph, `check-config` and `pre-commit` + included, which is exactly what the gate-path invariant forbids. A + caller that never calls this function -- which is every gate-path + command -- never triggers any of these imports. + + `"file"` reads the store password now, at the same point `_prepare_env` + always has -- after the backend guard, never before it (see + `test_prepare_env_checks_the_backend_guard_before_reading_a_password`). + Only `"file"` needs one; `"env"` and `"command"` never prompt. + """ + provider = profile.credentials.get("provider", "file") + if provider == "file": + from ..providers.file import FileCredentialStore + + return FileCredentialStore(_read_store_password(), directory=directory) + if provider == "env": + from ..providers.env import EnvCredentialStore + + return EnvCredentialStore(CREDENTIAL_NAMES) + if provider == "command": + from ..providers.command import CommandCredentialStore + + command = profile.credentials.get("command") + if not isinstance(command, list) or not command or not all( + isinstance(item, str) for item in command + ): + raise SessionError( + f"profile {profile.name!r}: credentials provider 'command' needs " + "a non-empty 'command' list of strings" + ) + return CommandCredentialStore(command) + raise SessionError( + f"profile {profile.name!r}: unknown credentials provider {provider!r}" + ) + + +def _prepare_env( + explicit_profile: str | None, *, directory: Path | None = None +) -> dict[str, str]: + """Resolve the profile, enforce the backend guard, resolve its + credentials through whichever `CredentialStore` its `provider` selects, + and return the exact environment `exec`/`shell` must run their child in. + + Raises `StoreError`, `ConfigError` or `SessionError` and returns nothing + partial: every step that can fail runs before `_child_env` builds + anything, so a caller either gets a complete environment or an exception, + never a dict missing one of the four names. + """ + profile = _resolve_profile(explicit_profile, directory=directory) + url = _compose_backend_url(profile) + _check_backend_guard(url) + credential_store = _build_credential_store(profile, directory=directory) + + from ..providers import ProviderError # local: see `_build_credential_store` + + try: + credentials = credential_store.resolve(profile.name) + except ProviderError as exc: + # `file` never raises this -- `store.StoreError` propagates + # unchanged, per `providers.file`'s own docstring -- so this branch + # is reachable only for a provider (`command`, or a future one) that + # actually failed, never for `file`'s existing, already-tested + # error paths. + raise SessionError(str(exc)) from exc + _require_credential_names(credentials, profile.name) + return _child_env(credentials, url) + + +def _exit_status(returncode: int) -> int: + """A `subprocess` return code, translated to what a POSIX shell's `$?` + would report for the same child. + + A normal POSIX exit is already 0-255 (the kernel only ever reports 8 bits + of it) and passes through unchanged. A negative `returncode` is how + `subprocess` represents a POSIX child killed by a signal; `sys.exit` + cannot carry that negative number through intact (it is reduced modulo + 256 at the OS boundary, and no longer identifies the signal), so it is + translated to `128 + signal` -- the one representation that survives. + + This function does not special-case Windows, where a terminated + process's `returncode` can be a large *positive* `STATUS_*` value outside + 0-255: that value is returned unchanged, and is subject to whatever + `sys.exit` itself does with a number that large on that platform, the + same as it would be for any exit code this tool ever returns. + """ + if returncode < 0: + return 128 - returncode + return returncode + + +def _run_child(command: list[str], env: dict[str, str] | None = None) -> int: + """Run `command`, inheriting this process's stdin/stdout/stderr, and + return its translated exit status. + + `env=None` means "inherit the caller's environment unchanged", the same + meaning `subprocess.run` itself gives it. No current caller in this + module actually relies on that default -- `login` passes an explicit + environment too now, with `STACKWARD_PASSWORD` scrubbed even though + `pulumi login` never needs it, for the same reason `exec`/`shell` do -- + but it is kept as `_run_child`'s own general-purpose meaning rather than + coupled to what today's callers happen to do. + + A missing executable is reported by name (never by the full command line, + which could hold an argument value, though never a credential -- those + never reach argv at all) and mapped to exit code 2, the same "could not + run" code every other refusal in this module uses. + + A `Ctrl-C` reaching this process while the child runs surfaces here as + `KeyboardInterrupt` (CPython's own re-raising of `SIGINT`) rather than as + a return code `subprocess` reports -- `fail_closed` does not catch it + (`BaseException`, not `Exception`), so left alone it would propagate to + the top of the process and print a traceback before Python's own default + handling exits 130. Caught here and translated the same way a + signal-killed child is, for the same exit status with nothing printed. + """ + try: + completed = subprocess.run(command, env=env) + except OSError as exc: + print(f"error: cannot run {command[0]!r}: {exc}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + return _exit_status(-signal.SIGINT) + return _exit_status(completed.returncode) + + +def _report(exc: Exception) -> int: + """Print `exc`'s message as `error: ...` on stderr and return exit code + 2 -- the "could not run" code every pre-flight refusal in `cmd_login`, + `cmd_exec` and `cmd_shell` uses. `exc` must be one of `_KNOWN_ERRORS`: + none of those ever carry a credential value in their message, which is + not a property of `Exception` in general -- an unanticipated exception + is `fail_closed`'s job, not this function's.""" + print(f"error: {exc}", file=sys.stderr) + return 2 + + +@fail_closed +def cmd_login(args: argparse.Namespace) -> int: + """Entry point for `stackward login`. + + Resolves the profile's backend URL and runs `pulumi login` with it set + as `PULUMI_BACKEND_URL` in the child's environment -- never as a + positional argument. `pulumi login --help` documents + `postgres://user:password@host/db` as a supported backend form, and an + argv value is visible in `ps` for the life of the call; verified, + empirically, that a bare `pulumi login` (no positional URL) honours + `PULUMI_BACKEND_URL` identically to the positional form, for a `file://` + backend with no network access. Never opens the credentials store -- a + profile can be logged into before its credentials have been set at all + (`store.py`'s own R3) -- and never subject to the backend guard, which + exists to protect the commands that run with a profile's credentials + live in the environment; `login` is the command that *sets* what the + guard checks against, so gating it on itself would make switching + profiles impossible. + """ + try: + profile = _resolve_profile(args.profile) + url = _compose_backend_url(profile) + except _KNOWN_ERRORS as exc: + return _report(exc) + + pulumi = shutil.which("pulumi") + if pulumi is None: + print("error: pulumi executable not found on PATH", file=sys.stderr) + return 2 + env = _environ_without_store_password() + env[PULUMI_BACKEND_URL] = url + return _run_child([pulumi, "login"], env) + + +@fail_closed +def cmd_exec(args: argparse.Namespace) -> int: + """Entry point for `stackward exec -- `. + + An empty command (nothing after `--`, or no `--` at all) is a usage + error, reported and returned as exit code 2 before anything is resolved + or decrypted -- the same code every other pre-flight refusal in this + module uses. + """ + if not args.argv: + print( + "error: exec requires a command: stackward exec -- ", + file=sys.stderr, + ) + return 2 + + try: + env = _prepare_env(args.profile) + except _KNOWN_ERRORS as exc: + return _report(exc) + + return _run_child(args.argv, env) + + +@fail_closed +def cmd_shell(args: argparse.Namespace) -> int: + """Entry point for `stackward shell`: the same as `exec`, with the + user's `$SHELL` as the command.""" + shell_path = os.environ.get("SHELL") + if not shell_path: + print("error: $SHELL is not set", file=sys.stderr) + return 2 + + try: + env = _prepare_env(args.profile) + except _KNOWN_ERRORS as exc: + return _report(exc) + + return _run_child([shell_path], env) diff --git a/src/stackward/commands/set_secrets.py b/src/stackward/commands/set_secrets.py new file mode 100644 index 0000000..5b5b34f --- /dev/null +++ b/src/stackward/commands/set_secrets.py @@ -0,0 +1,858 @@ +"""`set-secrets`: publish a stack's declared secrets from local sources into +Pulumi's config. + +**The manifest maps a config path to a *logical name*; where the value comes +from is a separate concern.** `.stackward.toml` declares, per project +directory, a `[secrets."".secret]` table of ` = ` pairs — this module resolves each name to a value (highest precedence +first: the process environment, then `[secrets.source].files` in file order, +later files overriding earlier ones) and publishes it. Nothing here decides +*how* a name becomes a value beyond that local, file-and-environment scheme: +`LocalSecretSource.resolve` is written to the one-method shape a future +`SecretSource` interface (Task 11's `dotenv`/`env` providers) will register, +so that interface adapts this module rather than rewriting it. + +**Both `[secrets.""].secret` and `.plaintext` are published; `unmanaged` +is not.** The three sibling tables share the same `path = name` shape, but +they are not interchangeable: the split between `secret` and `plaintext` *is* +the declaration of whether a value is a credential, and a declared table that +this command silently never acts on would make that declaration meaningless +— a manifest author adding a `plaintext` entry with nothing telling them it +does nothing is exactly the failure mode this project treats as worse than +either "reject it" or "act on it." `secret` entries are published with +`pulumi config set --secret --path `; `plaintext` entries with +`pulumi config set --plaintext --path ` — same resolution, same +skip-if-unset semantics, same delivery on stdin, in `_pulumi_config_set`'s one +code path (see below). `unmanaged` stays inert: it exists to record a path +deliberately *not* managed by this tool, together with a reason — a +"documentation-only" table by design, not an oversight. + +**A value never reaches `argv`.** `pulumi config set --secret|--plaintext +--path ` is invoked with the resolved value on **stdin**, never as a +positional argument — a command line is visible in `ps` and in shell history +for the life of the call, the same concern Task 7's `login` already had to +solve for a backend URL. Stdin is used for `plaintext` too, even though its +values are not confidential: keeping one delivery mechanism for both is +simpler than forking a second one for the entries that do not strictly need +it. `pulumi config get --path ` (drift detection's read) has no such +argument at all, so it carries no value on either channel. + +**A name whose value is unset is skipped, never cleared.** This module has no +code path that ever removes a Pulumi config value; it only ever calls +`config set` for a name it was actually given a non-empty value for. A value +that resolves to the empty string is treated identically to one that did not +resolve at all — an environment variable exported as `""` is not a value +worth publishing, and publishing it would silently clear whatever secret was +there before, which is exactly the destructive surprise the brief calls out. + +**`[required]` promotes "skipped" to "failed".** A path named there — under +`[secrets."".required]`'s `paths` list, the **one** canonical shape this +module accepts; see `_parse_required` for why a second, alternate spelling is +refused rather than tolerated — must resolve to a non-empty value (as either +a `secret` or a `plaintext` entry) or the run reports a non-zero exit. It +does not, however, abort the loop: a timeout, a missing `pulumi` binary, and +an unresolved required path are all the same class of per-entry failure (see +`_publish_entries`), and every one of them lets every other declared entry +still get its own chance to be set. A rotation that stops partway and reports +success would be worse than one that finishes and reports which entries did +not land — the summary printed by `cmd_set_secrets` exists to say exactly +that, by name, every time. + +**Drift detection never prints a value, and silence is a real, load-bearing +state — not merely "nothing to report".** `[[secrets."".drift_pairs]]` +declares a `bootstrap` and a `managed` logical name; the check compares the +*local* value of `bootstrap` (resolved the same way any other entry is) +against the *published* value at whichever config path `managed` names in +`secret` **or** `plaintext` (both are published now, per Ruling 1 — the +inversion is over their disjoint union; see `_check_drift`), read with +`pulumi config get`, which decrypts — the list form, `pulumi config`, renders +every secret as `[secret]` without decrypting and exits 0 under any +passphrase, so it cannot answer this question at all. Three states are +silent on purpose, not merely "no branch happened to fire": the local +`bootstrap` value not resolving (nothing to compare against), `managed`'s +published value not being readable (a timeout, a non-zero exit, a missing +`pulumi`), and the two values actually agreeing. Only an *unreadable* name — +`managed` naming zero or more than one published entry, which is a manifest +problem rather than a state-of-the-world one — warns, since that one is +worth a human's attention regardless of what the values turn out to be. +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from .. import paths +from ..config import Config, ConfigError, find_repo_config, load_config +from ..store import warn_if_permissive +from .check_config import fail_closed + +if TYPE_CHECKING: + # Never imported at runtime -- see `_build_secret_source`'s own + # docstring, and `providers/__init__.py`'s, on why provider modules stay + # off this module's own top level. + from ..providers import SecretSource + +# How long any single `pulumi` invocation (a `config set` or a `config get`) +# is allowed to run before this module gives up on it and reports that one +# entry as failed. A module-level variable, not a function default, so a test +# can lower it (`monkeypatch.setattr`) to exercise the timeout path without +# actually waiting anywhere near this long. +DEFAULT_TIMEOUT_SECONDS = 30.0 + +# The two quote characters `_parse_env_file` strips one matched pair of, and +# nothing else — this is a `KEY=VALUE` file, not a shell, and no other +# escaping is attempted. +_QUOTE_CHARS = ('"', "'") + +_EXPORT_PREFIX = "export " + + +class SetSecretsError(Exception): + """Something about the manifest, the source files, or an invocation of + `pulumi` stops `set-secrets` from proceeding for reasons distinct from + "the value was not there" (that is a skip, not an error — see the module + docstring). + + Never carries a credential value: every message here names a config path, + a logical name, a file, or a `pulumi` exit code, never anything read out + of an envelope or an environment variable. + """ + + +@dataclass(frozen=True) +class DriftPair: + """One declared `[[secrets."".drift_pairs]]` entry: two *logical + names*, never config paths themselves. `managed`'s config path is found + by inverting `ProjectManifest.secret` **and** `.plaintext` together (both + are published — Ruling 1) at check time (see `_check_drift`), not stored + here — the manifest is the one place that mapping lives, and keeping a + second copy of it in a `DriftPair` would let the two disagree silently if + `secret`/`plaintext` ever changed shape. + """ + + bootstrap: str + managed: str + + +@dataclass(frozen=True) +class ProjectManifest: + """One `[secrets.""]` table, parsed to only what this module acts + on: `secret` and `plaintext` (each config path -> logical name; both are + published, see the module docstring), `required` (a subset of `secret`'s + and `plaintext`'s paths that must resolve), and `drift_pairs`. + `unmanaged` is never parsed by this module at all — `config.py` validates + only that the *project* table itself is a table and defers every key + inside it, `unmanaged` included, to here; this module simply chooses not + to look at it, since it is documentation-only, by design. + """ + + secret: dict[str, str] = field(default_factory=dict) + plaintext: dict[str, str] = field(default_factory=dict) + required: frozenset[str] = frozenset() + drift_pairs: tuple[DriftPair, ...] = () + + +@dataclass(frozen=True) +class EntryOutcome: + """What happened to one `secret` entry, for both the per-line report and + the closing summary. `reason` is `None` exactly when `status` is `"set"` + or `"would_set"` — every other status names why, in words that never + include a value.""" + + path: str + name: str + status: str # "set" | "would_set" | "skipped" | "failed" + reason: str | None = None + + +class LocalSecretSource: + """Resolves a logical name to a value, or `None` — the environment first, + then each parsed source file, later files overriding earlier ones. + + This is deliberately the one-method shape ("a name in, a value or `None` + out") that Task 11's `SecretSource` interface will register `dotenv` and + `env` implementations against. Building resolution this way now means + that interface *adapts* this class — wraps it, or replaces its + construction — rather than needing to rewrite the call sites in + `_publish_entries`/`_check_drift` that already only ever call + `.resolve(name)`. + + Presence, not truthiness, decides precedence: an environment variable + exported as `""` still wins over a file that also defines the same name, + because that is what "the process environment first" means. It is + `_publish_entries` and `_check_drift` — not this class — that decide an + empty resolved value is unusable; a resolver returning `None` and one + returning `""` are different facts, and only the caller (which knows + whether the empty case is a skip or a "nothing to compare" for drift) + should be the one to treat them the same. + """ + + def __init__( + self, environ: Mapping[str, str], file_values: list[dict[str, str]] + ) -> None: + self._environ = environ + # In the order declared in `[secrets.source].files`; `resolve` walks + # this in reverse so a later file overrides an earlier one. + self._file_values = file_values + + def resolve(self, name: str) -> str | None: + if name in self._environ: + return self._environ[name] + for values in reversed(self._file_values): + if name in values: + return values[name] + return None + + +# --------------------------------------------------------------------------- +# Manifest parsing — `config.py` validates only that `secrets.` is a +# table; everything below this line is this module's own semantics. +# --------------------------------------------------------------------------- + + +def _parse_path_name_table(raw: Any, where: str) -> dict[str, str]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise SetSecretsError(f"{where} must be a table") + result: dict[str, str] = {} + for path, name in raw.items(): + if not isinstance(name, str): + raise SetSecretsError(f"{where}.{path!r} must map to a string logical name") + result[path] = name + return result + + +# The only key `[secrets."".required]` recognises. A second, alternate +# spelling of "these paths are required" (a bare `required = [...]` array, or +# a table whose own keys are the required paths) is deliberately not accepted +# alongside this one: a manifest format with two shapes for the same thing +# means a typo in one can silently read as the *other* shape's "nothing +# required" instead of as an error — an ambiguity a fail-closed check must +# not have. See `_parse_required`. +_REQUIRED_KEYS = frozenset({"paths"}) + + +def _parse_required(raw: Any, where: str) -> frozenset[str]: + """The **one** canonical `[secrets."".required]` shape: a table with + exactly one key, `paths`, holding a list of config-path strings — + + [secrets."".required] + paths = ["a.b", "c.d"] + + Absent entirely is a normal state (nothing required, an empty + `frozenset`). Present but any other shape — a bare `required = [...]` + array, a table whose own keys are the paths, an unrecognised key inside + `[required]` — is a hard `SetSecretsError` naming `where`, never silently + reinterpreted as one of the shapes this module used to also accept: this + is `config.py`'s own "invalid is never treated as absent" rule, and its + own "an unknown key is named and rejected" rule, both carried over here + for the same reason they hold there. + """ + if raw is None: + return frozenset() + if not isinstance(raw, dict): + raise SetSecretsError( + f"{where} must be a table with a 'paths' list, e.g. " + f'[{where}]\npaths = ["a.b"]' + ) + unknown = set(raw) - _REQUIRED_KEYS + if unknown: + raise SetSecretsError(f"unknown key {where}.{sorted(unknown)[0]}") + raw_paths = raw.get("paths", []) + if not isinstance(raw_paths, list) or not all(isinstance(item, str) for item in raw_paths): + raise SetSecretsError(f"{where}.paths must be a list of path strings") + return frozenset(raw_paths) + + +def _parse_drift_pairs(raw: Any, where: str) -> tuple[DriftPair, ...]: + if raw is None: + return () + if not isinstance(raw, list): + raise SetSecretsError(f"{where} must be a list of tables") + pairs: list[DriftPair] = [] + for index, entry in enumerate(raw): + if not isinstance(entry, dict): + raise SetSecretsError(f"{where}[{index}] must be a table") + bootstrap = entry.get("bootstrap") + managed = entry.get("managed") + if not isinstance(bootstrap, str) or not isinstance(managed, str): + raise SetSecretsError( + f"{where}[{index}] needs string 'bootstrap' and 'managed' entries" + ) + pairs.append(DriftPair(bootstrap=bootstrap, managed=managed)) + return tuple(pairs) + + +def parse_project_manifest(raw: dict[str, Any], project: str) -> ProjectManifest: + """Build a `ProjectManifest` from `config.secrets[project]`. + + Every config path this module will actually publish or forward to + `pulumi` — every key in `secret` and in `plaintext`, since both are + published — is validated with `paths.parse` here, at load time; this is + the one path grammar the whole tool shares (see `paths.py`), and using it + as the validator is what "do not invent string handling" means in + practice. `unmanaged` keys are never parsed this way: this module never + forwards them to `pulumi`, and they may legitimately hold a wildcard + shape (`".*"`) that is not a `--path` at all — see the module + docstring. + """ + secret = _parse_path_name_table(raw.get("secret"), f"secrets.{project!r}.secret") + plaintext = _parse_path_name_table(raw.get("plaintext"), f"secrets.{project!r}.plaintext") + for table_name, table in (("secret", secret), ("plaintext", plaintext)): + for path in table: + try: + paths.parse(path) + except ValueError as exc: + raise SetSecretsError( + f"secrets.{project!r}.{table_name}: invalid config path {path!r}: {exc}" + ) from exc + + # The split between `secret` and `plaintext` *is* the declaration of + # whether a value is a credential (see the module docstring) -- a path in + # both tables is a contradiction in that declaration, not a preference to + # resolve one way or the other. Left unchecked, `_publish_entries` + # publishes `secret` first and `plaintext` second, so the `--plaintext` + # write would win and a value its author explicitly declared a + # credential would end up unencrypted in Pulumi's config -- silently, in + # the more dangerous direction. Refused here instead, at load time, + # naming the offending path. + overlap = set(secret) & set(plaintext) + if overlap: + raise SetSecretsError( + f"secrets.{project!r}: {sorted(overlap)[0]!r} is declared under both " + "'secret' and 'plaintext' -- a path must be exactly one" + ) + + required = _parse_required(raw.get("required"), f"secrets.{project!r}.required") + unknown = required - set(secret) - set(plaintext) + if unknown: + raise SetSecretsError( + f"secrets.{project!r}.required.paths names {sorted(unknown)[0]!r}, which " + "is not declared under 'secret' or 'plaintext'" + ) + + drift_pairs = _parse_drift_pairs( + raw.get("drift_pairs"), f"secrets.{project!r}.drift_pairs" + ) + + return ProjectManifest( + secret=secret, plaintext=plaintext, required=required, drift_pairs=drift_pairs + ) + + +def select_project(config: Config, repo_root: Path, cwd: Path) -> str: + """Which `[secrets.""]` table this invocation publishes. + + A single declared project (besides the reserved `source` table) is used + unconditionally — "a single-project repository uses one table" per the + brief, with no need to make that table's name match anything about + `cwd`. With more than one, the project is the repository-root-relative + directory of `cwd` itself; `find_repo_config` always searches upward + *from* `cwd`, so `cwd` is guaranteed to be at or below `repo_root` and + `os.path.relpath` never has to represent an escape above it. + """ + candidates = sorted(name for name in config.secrets if name != "source") + if not candidates: + raise SetSecretsError("no [secrets.\"\"] table declared in .stackward.toml") + if len(candidates) == 1: + return candidates[0] + + relative = os.path.relpath(cwd.resolve(), repo_root.resolve()) + project = "." if relative == "." else relative.replace(os.sep, "/") + if project in candidates: + return project + raise SetSecretsError( + f"cannot tell which project's secrets to publish from {cwd}; declared " + f"projects: {', '.join(candidates)}" + ) + + +# --------------------------------------------------------------------------- +# Value sources: the process environment, and `[secrets.source].files` +# --------------------------------------------------------------------------- + + +def substitute_stack(filename: str, stack: str | None) -> str: + """`{stack}` in `filename`, replaced with `stack` — or a `SetSecretsError` + naming the file when the manifest needs a stack this invocation was never + given one for. A plain `.replace`, not `str.format`: a source filename is + not a template a manifest author is expected to write other `{...}` + placeholders into, and `.format` would raise its own confusing error on + one that happened to contain an unrelated brace. + """ + if "{stack}" not in filename: + return filename + if stack is None: + raise SetSecretsError( + f"secrets.source.files entry {filename!r} needs {{stack}} substituted, " + "but no --stack was given" + ) + return filename.replace("{stack}", stack) + + +def resolve_source_files(config: Config, repo_root: Path, stack: str | None) -> list[Path]: + """`[secrets.source].files`, each `{stack}`-substituted and made absolute + against `repo_root` when not already absolute. A missing `[secrets.source]` + table, or one with no `files`, resolves to an empty list — value + resolution then falls back to the process environment alone, which is a + normal, working configuration, not an error. + + Deliberately does **not** re-check that `[secrets.source]` is a table. + `_build_secret_source` is this function's only caller and refuses a + non-table `source` before it ever gets here, so a second copy of that + check could never execute — and a refusal that cannot execute is worse + than none: it reads as a live guard, so the next reader trusts it and + stops looking for the real one. Said here rather than left as an absence, + because an absent guard is exactly what gets helpfully re-added. + """ + source = config.secrets.get("source", {}) + raw_files = source.get("files", []) + if not isinstance(raw_files, list) or not all(isinstance(f, str) for f in raw_files): + raise SetSecretsError("secrets.source.files must be a list of strings") + + resolved: list[Path] = [] + for raw in raw_files: + name = substitute_stack(raw, stack) + candidate = Path(name) + resolved.append(candidate if candidate.is_absolute() else repo_root / candidate) + return resolved + + +_SECRET_SOURCE_PROVIDERS = frozenset({"dotenv", "env", "command"}) + + +def _build_secret_source( + config: Config, repo_root: Path, stack: str | None +) -> "SecretSource": + """The `SecretSource` `[secrets.source].provider` selects (`"dotenv"` + when the table or the key is absent, preserving today's environment- + then-files behaviour unchanged). + + Provider modules are imported here, function-locally -- never at this + module's own top level -- for the same reason `session. + _build_credential_store` does it the same way: `cli.py` imports this + module unconditionally, so a module-scope import here would put a + provider on every `stackward` invocation's import graph, `check-config` + and `pre-commit` included -- see `providers/__init__.py`'s module + docstring. A caller that never calls this function -- every gate-path + command -- never triggers any of these imports. + """ + source = config.secrets.get("source", {}) + if not isinstance(source, dict): + raise SetSecretsError("secrets.source must be a table") + + provider = source.get("provider", "dotenv") + if not isinstance(provider, str): + raise SetSecretsError("secrets.source.provider must be a string") + + if provider == "dotenv": + from ..providers.dotenv import DotenvSecretSource + + files = resolve_source_files(config, repo_root, stack) + return DotenvSecretSource(os.environ, files) + if provider == "env": + from ..providers.env import EnvSecretSource + + return EnvSecretSource(os.environ) + if provider == "command": + from ..providers.command import CommandSecretSource + + command = source.get("command") + if not isinstance(command, list) or not command or not all( + isinstance(item, str) for item in command + ): + raise SetSecretsError( + "secrets.source.command must be a non-empty list of strings" + ) + return CommandSecretSource(command) + raise SetSecretsError( + f"secrets.source.provider must be one of {sorted(_SECRET_SOURCE_PROVIDERS)!r}, " + f"got {provider!r}" + ) + + +def parse_env_text(text: str, origin: str) -> dict[str, str]: + """Parse `KEY=VALUE` text: blank lines and `#` comments are ignored, a + leading `export ` is stripped from the key side, and exactly one matched + pair of surrounding quotes is stripped from the value. + + `origin` names where the text came from, so a malformed line can be + reported as `:` — a filesystem path for + `parse_env_file` below, and `"stdin"` for `commands.credentials`, which + reads the same format off a pipe (a `.env` file being sealed into the + store is the migration this format exists to serve at both ends). + + A non-blank, non-comment line with no `=` is a `SetSecretsError` naming + the origin and line number, **never the line's own text** — this is + fail-closed on a genuinely malformed bootstrap file rather than silently + skipping a line that was probably meant to define something, and the + message stays safe to forward to a caller with a different vocabulary + (see `commands.credentials`, which re-raises it) precisely because it + carries no content. + + Whitespace around a value is stripped, and quoting is how a value that + genuinely ends in a space survives — the same rule a shell applies to + `KEY=value` and the same one every `.env` reader applies, which is what + makes "pipe the file you already have" mean the same thing here as it + did there. + """ + values: dict[str, str] = {} + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith(_EXPORT_PREFIX): + line = line[len(_EXPORT_PREFIX) :] + if "=" not in line: + raise SetSecretsError(f"{origin}:{line_number}: not a KEY=VALUE line") + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in _QUOTE_CHARS: + value = value[1:-1] + if not key: + raise SetSecretsError(f"{origin}:{line_number}: empty key") + values[key] = value + return values + + +def parse_env_file(path: Path) -> dict[str, str]: + """`parse_env_text` over one file's contents. + + Warns on stderr (via `store.warn_if_permissive`, the same message shape + used for the credential store's own files) when `path` is more permissive + than `0o600` — this file can hold a bootstrap secret in plaintext on disk, + so a mode that lets another local user read it is worth flagging, even + though refusing to read it outright would only lock a user out of their + own secrets after the exposure already happened. + + The parsing itself is in `parse_env_text` because two commands need it + over two different sources: this one reads a declared source file, and + `commands.credentials` reads the same format off stdin. A second + implementation of "what does a `.env` line mean" is how the two come to + disagree about a quoted value on the day it matters. + """ + warn_if_permissive(path, 0o600) + try: + text = path.read_text() + except OSError as exc: + raise SetSecretsError(f"{path}: cannot read: {exc}") from exc + return parse_env_text(text, str(path)) + + +# --------------------------------------------------------------------------- +# Talking to `pulumi` +# --------------------------------------------------------------------------- + + +# The two publishable manifest tables, and the `pulumi config set` flag each +# one is published with. `_publish_entries` never calls `_pulumi_config_set` +# with a `mode` outside this mapping's keys. +_MODE_FLAGS = {"secret": "--secret", "plaintext": "--plaintext"} + + +def _pulumi_config_set( + pulumi: str, + path: str, + value: str, + *, + mode: str, + stack: str | None, + timeout: float, +) -> None: + """`pulumi config set --secret|--plaintext --path `, `value` on + **stdin** — never as a positional argument, which would put it in `argv` + and therefore in `ps` output and shell history for the life of the call. + `mode` (`"secret"` or `"plaintext"`, via `_MODE_FLAGS`) picks the flag; + the two are mutually exclusive on one invocation, and a `secret` entry is + never sent with `--plaintext` or vice versa — each entry carries its own + table's mode all the way from `manifest.secret`/`manifest.plaintext` + through to this call. + + Raises `subprocess.TimeoutExpired` and `OSError` (a missing `pulumi` + binary, among other things) unchanged, for `_publish_entries` to + translate into a per-entry failure without this function needing to know + that vocabulary. A non-zero exit is instead raised here as + `SetSecretsError` naming only the exit code — `pulumi`'s own stdout and + stderr are captured and never included in any message or re-printed: + they are not this module's to vouch for as free of the very value it + just piped to this process on stdin. + """ + args = [pulumi, "config", "set", _MODE_FLAGS[mode], "--path", path] + if stack: + args += ["--stack", stack] + completed = subprocess.run(args, input=value.encode(), timeout=timeout, capture_output=True) + if completed.returncode != 0: + raise SetSecretsError(f"pulumi exited {completed.returncode}") + + +def _pulumi_config_get( + pulumi: str, path: str, *, stack: str | None, timeout: float +) -> str | None: + """The published, decrypted value at `path`, or `None` when it could not + be read — a non-zero exit, a timeout, or a missing `pulumi` binary all + collapse to the same `None` here, on purpose: drift detection treats + every one of those as "nothing to compare", silently, per the module + docstring. Never raises, and never prints its own stdout — the decrypted + value is returned to the caller and nowhere else. + + `pulumi config` (the list form) is not usable for this: it renders every + secret as `[secret]` **without** decrypting, and exits 0 under any + passphrase at all — a caller could not tell "the values agree" from "this + command told me nothing." Only `pulumi config get --path ` + decrypts, which is why this function -- and not the list form -- is what + drift detection is built on. + """ + args = [pulumi, "config", "get", "--path", path] + if stack: + args += ["--stack", stack] + try: + completed = subprocess.run(args, timeout=timeout, capture_output=True, text=True) + except (subprocess.TimeoutExpired, OSError): + return None + if completed.returncode != 0: + return None + # `pulumi config get` terminates its output with exactly one trailing + # newline; stripping it is what makes the comparison in `_check_drift` + # compare the actual value rather than failing on every equal pair. + # `removesuffix`, not `rstrip`, is what "exactly one" requires: `rstrip` + # would also eat every newline of a published value that legitimately + # ends in one (or more), silently changing what is being compared and + # firing a spurious warning over a difference that was never real. + return completed.stdout.removesuffix("\n") + + +# --------------------------------------------------------------------------- +# Publishing and drift detection +# --------------------------------------------------------------------------- + + +def _publish_entries( + manifest: ProjectManifest, + source: "SecretSource", + *, + pulumi: str | None, + stack: str | None, + dry_run: bool, + timeout: float, +) -> list[EntryOutcome]: + """Walk every `secret` entry (in declared order), then every `plaintext` + entry (in declared order), resolving and then (unless `dry_run`) + publishing each one with its own table's mode — one `EntryOutcome` per + entry, regardless of whether it succeeded. + + Every failure mode here — an unresolved required path, a provider that + could not resolve a name at all, a `pulumi` timeout, a missing `pulumi` + binary, a non-zero `pulumi` exit — is recorded on its own `EntryOutcome` + and the loop continues to the next entry rather than raising: a + half-finished rotation that stops on the first failure and exits + non-zero has already left every entry after it unset, and reports that + failure no differently than one that finishes and simply fails to + report which entries did not land. The order entries are declared within + each table (a `dict`, so insertion order — the order the TOML was written + in) is preserved throughout, since it is what makes "the failing entry + was in the middle" a meaningful, reproducible scenario to test. + + A `source.resolve(name)` that raises `ProviderError` is a `failed` + outcome, never folded into `skipped` — an unresolved name and a provider + that could not answer are different facts (see `providers/__init__.py`), + and treating the second as the first would silently skip a secret the + operator believes their remote store actually holds. + """ + from ..providers import ProviderError # local: see `_build_secret_source` + + entries = [(path, name, "secret") for path, name in manifest.secret.items()] + entries += [(path, name, "plaintext") for path, name in manifest.plaintext.items()] + + outcomes: list[EntryOutcome] = [] + for path, name, mode in entries: + try: + value = source.resolve(name) + except ProviderError as exc: + outcomes.append(EntryOutcome(path, name, "failed", f"provider error: {exc}")) + continue + if not value: + if path in manifest.required: + outcomes.append(EntryOutcome(path, name, "failed", "required but unresolved")) + else: + outcomes.append(EntryOutcome(path, name, "skipped", "unresolved")) + continue + + if dry_run: + outcomes.append(EntryOutcome(path, name, "would_set", None)) + continue + + if pulumi is None: + outcomes.append( + EntryOutcome(path, name, "failed", "pulumi executable not found on PATH") + ) + continue + + try: + _pulumi_config_set(pulumi, path, value, mode=mode, stack=stack, timeout=timeout) + except subprocess.TimeoutExpired: + outcomes.append(EntryOutcome(path, name, "failed", "pulumi timed out")) + except OSError as exc: + outcomes.append(EntryOutcome(path, name, "failed", f"cannot run pulumi: {exc}")) + except SetSecretsError as exc: + outcomes.append(EntryOutcome(path, name, "failed", str(exc))) + else: + outcomes.append(EntryOutcome(path, name, "set", None)) + return outcomes + + +def _config_path_for_name(entries: dict[str, str], name: str) -> str | None: + """The one config path in `entries` mapping to `name`, or `None` when + zero or more than one do — `managed` in a drift pair must name exactly + one *published* entry for `pulumi config get` to have anything to read. + `entries` is `_check_drift`'s merge of `secret` and `plaintext` — both + are published now (Ruling 1), so `managed` may legitimately name either + one, and inverting only `secret` would wrongly warn on a `managed` name + that lives in `plaintext`.""" + matches = [path for path, mapped in entries.items() if mapped == name] + return matches[0] if len(matches) == 1 else None + + +def _check_drift( + manifest: ProjectManifest, + source: "SecretSource", + *, + pulumi: str | None, + stack: str | None, + timeout: float, +) -> None: + """Warn, on stderr, for each declared drift pair whose local `bootstrap` + value and published `managed` value disagree — see the module docstring + for exactly which states are silent on purpose. Never called under + `--dry-run`: drift detection reads `pulumi config get`, which is a real + invocation, and dry-run's contract is to run nothing at all. + + A provider that raises resolving `bootstrap` is reported as a warning, + the same channel every other drift issue uses, rather than folded into + the silent "nothing to compare" state a `None`/`""` bootstrap value gets + — the three silent states this function's docstring names are all + legitimate absences, and a provider failure is not one of them (see + `providers/__init__.py`). It does not change this command's exit code: + drift detection has never affected the exit code (only `_publish_entries`' + outcomes do), and a provider failure resolving `bootstrap` here is no + exception to that. + """ + if pulumi is None: + return + from ..providers import ProviderError # local: see `_build_secret_source` + + # `secret` and `plaintext` are guaranteed disjoint by + # `parse_project_manifest`, so this merge cannot silently drop or shadow + # an entry from either side -- `managed` may name a path in either table, + # since both are published (Ruling 1). Named distinctly from the loop's + # own `published` local below (`pulumi config get`'s decrypted result, + # rebound on every iteration): the two used to share a name, which meant + # the *second* and every later drift pair called `_config_path_for_name` + # with a `str` left over from the previous iteration instead of this + # dict, raising `AttributeError` rather than evaluating the pair at all. + published_entries = {**manifest.secret, **manifest.plaintext} + for pair in manifest.drift_pairs: + try: + bootstrap_value = source.resolve(pair.bootstrap) + except ProviderError as exc: + print( + f"warning: drift pair {pair.bootstrap!r}/{pair.managed!r}: " + f"could not resolve {pair.bootstrap!r}: {exc}", + file=sys.stderr, + ) + continue + if not bootstrap_value: + continue + + managed_path = _config_path_for_name(published_entries, pair.managed) + if managed_path is None: + print( + f"warning: drift pair {pair.bootstrap!r}/{pair.managed!r}: " + f"{pair.managed!r} does not name exactly one declared secret", + file=sys.stderr, + ) + continue + + published = _pulumi_config_get(pulumi, managed_path, stack=stack, timeout=timeout) + if published is None: + continue + + if published != bootstrap_value: + print( + f"warning: drift: {pair.bootstrap!r} (bootstrap) and " + f"{pair.managed!r} (managed, at {managed_path!r}) differ", + file=sys.stderr, + ) + + +def _print_outcome(outcome: EntryOutcome) -> None: + if outcome.status == "set": + print(f"set {outcome.path!r} ({outcome.name})") + elif outcome.status == "would_set": + print(f"would set {outcome.path!r} ({outcome.name})") + elif outcome.status == "skipped": + print(f"skip {outcome.path!r} ({outcome.name}): {outcome.reason}") + else: + print(f"error: {outcome.path!r} ({outcome.name}): {outcome.reason}", file=sys.stderr) + + +@fail_closed +def cmd_set_secrets(args: argparse.Namespace) -> int: + """Entry point for `stackward set-secrets`. + + Exit 0 when every declared entry was set or validly skipped; exit 2 when + anything was not set that should have been (a required-but-unresolved + path, a `pulumi` timeout, a missing `pulumi` binary, a non-zero `pulumi` + exit) or the manifest/config could not be loaded at all. Never exit 1 — + this tool reserves that code for `check-config`'s "a credential was + found," a different claim this command never makes. + """ + try: + config_path = find_repo_config() + if config_path is None: + raise SetSecretsError("no .stackward.toml found above the current directory") + config = load_config(config_path) + repo_root = config_path.parent + project = select_project(config, repo_root, Path.cwd()) + manifest = parse_project_manifest(config.secrets[project], project) + source = _build_secret_source(config, repo_root, args.stack) + except (ConfigError, SetSecretsError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + pulumi = None if args.dry_run else shutil.which("pulumi") + + outcomes = _publish_entries( + manifest, + source, + pulumi=pulumi, + stack=args.stack, + dry_run=args.dry_run, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + for outcome in outcomes: + _print_outcome(outcome) + + if not args.dry_run: + _check_drift( + manifest, source, pulumi=pulumi, stack=args.stack, timeout=DEFAULT_TIMEOUT_SECONDS + ) + + failed = [outcome for outcome in outcomes if outcome.status == "failed"] + set_count = sum(1 for outcome in outcomes if outcome.status in ("set", "would_set")) + skipped_count = sum(1 for outcome in outcomes if outcome.status == "skipped") + print(f"summary: {set_count} set, {skipped_count} skipped, {len(failed)} failed") + if failed: + named = ", ".join(f"{outcome.path!r} ({outcome.name})" for outcome in failed) + print(f"not set: {named}", file=sys.stderr) + return 2 + return 0 diff --git a/src/stackward/commands/sync_declared.py b/src/stackward/commands/sync_declared.py new file mode 100644 index 0000000..f9fe8e9 --- /dev/null +++ b/src/stackward/commands/sync_declared.py @@ -0,0 +1,356 @@ +"""`sync-declared-secrets`: write `.stackward/declared-secrets.json`. + +Registered in `cli.build_parser` as `stackward sync-declared-secrets`. It +imports the repository's pydantic models, walks their `SECRET` marks into the +graph `nets.model` reads, records the git blob id of everything that +contributed a mark, and writes the artifact atomically. + +**It is not on the gate path.** `check-config` and `pre-commit` never call +this — they read the committed artifact. This command runs when a developer +changes a model, and it is the only part of the system that imports +repository code at all. It resolves no credentials, opens no store and +contacts nothing. + +**It runs the walk under the repository's interpreter.** A frozen `stackward` +bundles its own interpreter and cannot import the consuming repository's +classes, so the walk runs as a subprocess under `[python]` from +`.stackward.toml` and only JSON crosses back (`bootstrap.regen`). That +separation is what keeps pydantic a *test-only* dependency of `stackward` +itself, which Global Constraint 1 requires. + +**Everything that could make the result incomplete is a refusal.** An +annotation that might hold a model but cannot be walked, a contributing file +that git does not track, an out-of-repo contributor with no tracked lockfile +to pin it: each raises rather than producing an artifact that looks complete +and is not. A gate whose declarations quietly cover less than the repository +declared is the one failure mode the whole model-net design exists to +prevent. + +**The output is byte-deterministic.** Sorted keys, fixed indentation, one +trailing newline, and every list sorted. The sound closure for staleness is +regenerating in CI and failing on any diff; a generator whose output churned +for unchanged input would make that check noise, and a noisy check gets +switched off. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from ..bootstrap import generator_source +from ..config import Config, ConfigError, find_repo_config, load_config +from ..nets.model import ( + ARTIFACT_PATH, + ARTIFACT_VERSION, + ModelNetError, + index_blob_ids, + parse_net, + repo_toplevel, +) +from ..store import atomic_write +from .check_config import fail_closed + +# Standard Python dependency lockfiles, checked at the repository root. These +# are ecosystem filenames, not knowledge about any particular organisation or +# vendor — `pre_commit` already recognises Pulumi's own `Pulumi..yaml` +# on the same basis. *Every* one that is tracked is recorded, not the first +# match: a repository can carry two, and pinning one while ignoring the other +# would leave a real dependency change invisible. +LOCKFILE_NAMES = ( + "uv.lock", + "poetry.lock", + "pdm.lock", + "Pipfile.lock", + "requirements.lock", + "requirements.txt", +) + +# The artifact is not read from the working tree, so mode is about who may +# look at a file naming a repository's config field names — not a credential, +# and committed anyway. +_ARTIFACT_MODE = 0o644 + +_GENERATION_TIMEOUT_SECONDS = 300 + + +class SyncError(Exception): + """Generation could not produce a complete artifact. Never partial: the + caller writes nothing when this is raised.""" + + +def _load_repo_config() -> tuple[Path, Config]: + """The repository's `.stackward.toml`, which this command requires. + + Unlike the gate, absence is *not* a normal state here: there is nothing to + generate without `check.stack_models`, and defaulting would write an empty + artifact that then passes every freshness check while declaring nothing. + """ + path = find_repo_config() + if path is None: + raise SyncError( + "no .stackward.toml found; this command needs check.stack_models " + "and the python interpreter to walk them with" + ) + try: + return path, load_config(path) + except ConfigError as exc: + raise SyncError(str(exc)) from exc + + +def _interpreter(config: Config, config_path: Path) -> str: + """The repository's own interpreter, resolved against the config's + directory so a relative `python = ".venv/bin/python"` means the same + thing from any working directory inside the repository.""" + if not config.python: + raise SyncError( + f"{config_path}: no 'python' key; this command needs the " + "repository's own interpreter to import its models" + ) + candidate = Path(config.python) + if not candidate.is_absolute(): + candidate = config_path.parent / candidate + if not candidate.is_file(): + raise SyncError(f"{config_path}: python interpreter not found: {candidate}") + return str(candidate) + + +def run_generator(interpreter: str, request: dict[str, object]) -> dict[str, object]: + """Run the generator under `interpreter` and return its result. + + The program's *text* goes in on stdin (`python -`), the request goes in + `argv`, and the result comes back through a temporary file named by the + request. Three channels rather than one because importing a repository's + modules runs that repository's import-time code: anything it prints would + corrupt a JSON document sharing stdout with it, and only in the + repositories that happen to print, which is the worst kind of + intermittent. + + The request carries module and class names only — never a credential — + so putting it in `argv` discloses nothing that `.stackward.toml` does not + already say in the clear. + """ + with tempfile.TemporaryDirectory(prefix="stackward-regen-") as workspace: + output = Path(workspace) / "graph.json" + payload = json.dumps({**request, "output": str(output)}) + try: + proc = subprocess.run( + [interpreter, "-", payload], + input=generator_source(), + capture_output=True, + text=True, + check=False, + timeout=_GENERATION_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + raise SyncError( + f"the model walk did not finish within " + f"{_GENERATION_TIMEOUT_SECONDS}s" + ) from exc + except OSError as exc: + raise SyncError(f"cannot run {interpreter}: {exc}") from exc + + if proc.returncode != 0: + detail = (proc.stderr or "").strip() or f"exit status {proc.returncode}" + # The generator prefixes its own refusals with "error: ", and so + # does the caller printing this. Strip one so the message does not + # read "error: error: ...". + raise SyncError(detail.removeprefix("error: ")) + try: + text = output.read_text(encoding="utf-8") + except OSError as exc: + raise SyncError(f"the model walk produced no result: {exc}") from exc + + try: + result = json.loads(text) + except ValueError as exc: + raise SyncError(f"the model walk produced invalid JSON: {exc}") from exc + if not isinstance(result, dict): + raise SyncError("the model walk produced a result that is not an object") + return result + + +def _relative_sources(root: Path, absolute: list[str]) -> list[str]: + """Contributing files as repository-relative POSIX paths. + + A file outside the repository never reaches here — the generator + classifies those as out-of-repo modules, pinned by lockfile instead — so + a path that will not relativise is a bug, not a supported case, and is + refused rather than dropped. + """ + relative: list[str] = [] + for item in absolute: + try: + relative.append(Path(item).resolve().relative_to(root).as_posix()) + except ValueError as exc: + raise SyncError(f"{item}: not inside the repository") from exc + return sorted(set(relative)) + + +def _lockfile_sources(root: Path) -> dict[str, str]: + """Every *tracked* dependency lockfile at the repository root, by blob id. + + Recorded only when something outside the repository contributed a mark — + a base model or a marking alias imported from a dependency. `git show` + cannot reach site-packages, so the lockfile that decides which version is + installed is the closest thing to that module's identity that the index + holds. A lockfile present on disk but untracked is not returned: it has no + blob id to compare against later. + """ + candidates = [name for name in LOCKFILE_NAMES if (root / name).is_file()] + if not candidates: + return {} + return index_blob_ids(root, candidates) + + +def build_artifact( + root: Path, result: dict[str, object], config_path: Path +) -> dict[str, object]: + """Assemble the artifact, refusing anything that would leave it unverifiable. + + `config_path` is `.stackward.toml`, and it is a source like any other: + `check.stack_models` supplies the artifact's `roots` and + `check.declared_paths_fn` decides which fields are marked at all, so it + decides part of the content as surely as a model file does. Leaving it + out meant a namespace could be added to the policy, staged, and committed + with the artifact untouched — the artifact then has no root for it, the + namespace is skipped, and the gate reports clean. + + Two refusals matter more than they look: + + - a contributing file that git does not track. Omitting it would mean its + blob id can never be compared, so a mark could change in it forever + without the freshness check noticing — the exact fail-open this artifact + exists to close. + - an out-of-repo contributor with no tracked lockfile to stand in for it. + There would be nothing at all pinning that module's identity. + + The artifact never records **itself**: its own blob id changes on every + regeneration, so a self-reference would make every artifact stale the + moment it was written. (A recorded hash of everything *except* the + `sources` entry would be self-consistent, but it would be a second + integrity scheme to specify, generate and verify, and a wrong one would + fail closed on correct artifacts — the exclusion is the cheaper trade.) + + That exclusion has a price, and it is paid in `nets.model.parse_net`: the + committed JSON's own bytes are checked by nothing else, so a hand edit or + a bad three-way merge in it meets no freshness check at all. Every + parse-time guard on the reading side is therefore load-bearing rather + than defensive, and must stay exhaustive — a shape the parser accepts is + a shape an edit can introduce and no other check will ever question. The + call to `parse_net` below is the same idea applied to the writing side: + the artifact this command produces is read back through the reader's own + parser before it is written. + """ + files = _relative_sources( + root, + [str(item) for item in result.get("files", [])] + [str(config_path)], + ) + external = sorted({str(item) for item in result.get("external", [])}) + + tracked = index_blob_ids(root, files) if files else {} + missing = sorted(set(files) - set(tracked)) + if missing: + raise SyncError( + f"{missing[0]} contributes a declared secret but is not tracked by " + "git, so it cannot be checked for staleness; add it to the index " + "and run this command again" + ) + + sources = dict(tracked) + if external: + lockfiles = _lockfile_sources(root) + if not lockfiles: + raise SyncError( + f"{external[0]} contributes a declared secret from outside the " + "repository, and no tracked dependency lockfile was found to " + f"pin it (looked for: {', '.join(LOCKFILE_NAMES)})" + ) + sources.update(lockfiles) + + sources.pop(ARTIFACT_PATH, None) + if not sources: + raise SyncError( + "no source file contributed a declared secret, so nothing could be " + "recorded to check for staleness" + ) + + artifact = { + "version": ARTIFACT_VERSION, + "roots": result.get("roots", {}), + "models": result.get("models", {}), + "sources": sources, + } + # Read it back with the matcher's own parser before writing it. The + # generator and the matcher are the two halves of one schema, and they run + # in different processes under different interpreters — so nothing else + # would notice them drifting apart until the next commit, in someone + # else's repository, as a refusal they did not cause. Checking here turns + # that into a failure at the moment it is introduced. + try: + parse_net(artifact) + except ModelNetError as exc: + raise SyncError(f"the generated artifact is not readable: {exc}") from exc + return artifact + + +def serialise(artifact: dict[str, object]) -> bytes: + """The artifact's committed bytes: sorted keys, two-space indent, one + trailing newline. + + Byte-determinism is the property CI leans on — regenerate, diff, fail on + any change. `sort_keys` is what supplies it, since dictionary insertion + order here follows the order classes happened to be walked in. + """ + return (json.dumps(artifact, sort_keys=True, indent=2) + "\n").encode("utf-8") + + +def sync(root: Path, config_path: Path, config: Config) -> Path: + """Generate and write the artifact. Returns the path written.""" + if not config.check.stack_models: + raise SyncError( + f"{config_path}: check.stack_models is empty; there is nothing " + 'to walk. Declare a model, or set check.model_net = "none"' + ) + + result = run_generator( + _interpreter(config, config_path), + { + "repo_root": str(root), + "stack_models": dict(config.check.stack_models), + "declared_paths_fn": config.check.declared_paths_fn, + }, + ) + artifact = build_artifact(root, result, config_path) + + target = root / ARTIFACT_PATH + target.parent.mkdir(parents=True, exist_ok=True) + atomic_write(target, serialise(artifact), _ARTIFACT_MODE) + return target + + +@fail_closed +def cmd_sync_declared(_args: argparse.Namespace) -> int: + """Entry point for `stackward sync-declared-secrets`. + + Exit codes match the rest of the tool's fail-closed convention: 0 on + success, 2 on any refusal. There is no exit 1 here — this command does + not look for credentials, so it can never mean "a credential was found", + and `@fail_closed` keeps an unanticipated exception from exiting 1 by + Python's own default. + """ + try: + config_path, config = _load_repo_config() + root = repo_toplevel() + written = sync(root, config_path, config) + except (SyncError, ModelNetError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print(f"wrote {written.relative_to(root).as_posix()}") + print("stage it with the change that caused it: git add " + ARTIFACT_PATH) + return 0 diff --git a/src/stackward/config.py b/src/stackward/config.py new file mode 100644 index 0000000..8ea03a3 --- /dev/null +++ b/src/stackward/config.py @@ -0,0 +1,543 @@ +"""Repository policy: `.stackward.toml`. + +Every later command needs to know, about *this* repository specifically: +which keys and parent categories count as sensitive, which pydantic classes +declare secrets for which stack, which secret manifest and value sources +apply, and the oldest `stackward` release this repository's policy is known +to work with. None of that is knowledge this tool ships with — it all +arrives through this one file, found by walking up from the working +directory to the repository root. That is the seam that keeps `stackward` +generic: swap the file, not the tool. + +Two states a caller must tell apart: **absent** (`find_repo_config` returns +`None`; there is no policy to apply, and it is on the caller to decide what +that means for the command at hand) and **present but invalid** +(`load_config` raises `ConfigError`). There is no third state where an +invalid file quietly falls back to defaults — a typo that silently disabled +part of a credential gate would be worse than one that refused to run at +all. + +What the gate commands decide for the absent case is: refuse. `check-config` +and `pre-commit` both exit 2 naming the minimal file to create, because +`CheckConfig()`'s own defaults include `model_net = "none"`, and defaulting +to that would make the weaker of the two nets a *silent fallback* rather +than the declared mode it is meant to be. This module still reports absence +rather than raising, because `cli.cmd_doctor` genuinely needs to run in a +repository that has no policy yet — reporting that fact is most of what it +is for. + +**Reading and parsing are separate.** `load_config_text` validates text; +`load_config` is the thin file reader in front of it. `commands.pre_commit` +needs the second half without the first, because its policy comes out of the +git index rather than the working tree. + +**`read_min_version` is deliberately narrower than either.** It answers only +"what floor does this file declare", for a caller that must get an answer +even when the rest of the file is something this binary cannot validate -- +which is exactly the case a floor is declared for. See it for why that is not +a second, laxer config loader. +""" + +from __future__ import annotations + +import re +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from . import __version__ + +CONFIG_FILENAME = ".stackward.toml" + +# Case-insensitive substrings that make a leaf's key suspect on their own, +# with no help from an ancestor. `[check].sensitive_keys` in the repo config +# EXTENDS this set — see `_extend`/`_replace` below for why keys extend but +# parents replace. +BUILTIN_SENSITIVE_KEYS: frozenset[str] = frozenset( + { + "password", + "passphrase", + "token", + "secret", + "apikey", + "api_key", + "jwt", + "credential", + "private_key", + "access_key_id", + "secret_access_key", + } +) + +# No parent key is sensitive by default. Unlike `sensitive_keys` above, this +# tool ships no built-in guess at what a "sensitive parent" category is +# called in any particular repository's config shape: any non-empty default +# would be a guess about someone's config shape, which is exactly the kind of +# environment-specific knowledge this module exists to keep out of a public, +# generic tool. A repo that wants whole categories of keys flagged declares +# `sensitive_parents` itself, which then REPLACES this (empty) default +# outright. +BUILTIN_SENSITIVE_PARENTS: frozenset[str] = frozenset() + +_MODEL_NET_VALUES = frozenset({"artifact", "none"}) +_TOP_LEVEL_KEYS = frozenset({"profile", "python", "min_version", "check", "secrets"}) +_CHECK_KEYS = frozenset( + { + "model_net", + "stack_models", + "declared_paths_fn", + "sensitive_keys", + "sensitive_parents", + "allowed_references", + } +) + + +class ConfigError(Exception): + """`.stackward.toml` is present but the current invocation cannot proceed + because of it — invalid content, or (via `MinVersionError`) a + `min_version` the installed `stackward` does not meet. + + Never raised for a missing file — that is `find_repo_config` returning + `None`, which is a normal state callers decide how to handle. The caller + must not treat an invalid or unsatisfied config the same as "no config" + and fall back to defaults. + """ + + +class MinVersionError(ConfigError): + """The installed `stackward` is older than this repository's `min_version`.""" + + +@dataclass(frozen=True) +class CheckConfig: + """The `[check]` table: what the credential-scanning nets look for.""" + + model_net: str = "none" + stack_models: dict[str, str] = field(default_factory=dict) + # `"module:function"`, or None for this tool's own marking convention. + # See `_require_module_class` for why the shape is validated here. + declared_paths_fn: str | None = None + sensitive_keys: frozenset[str] = BUILTIN_SENSITIVE_KEYS + sensitive_parents: frozenset[str] = BUILTIN_SENSITIVE_PARENTS + allowed_references: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class Config: + """A parsed, validated `.stackward.toml`.""" + + profile: str | None = None + python: str | None = None + min_version: str | None = None + check: CheckConfig = field(default_factory=CheckConfig) + # Raw `[secrets]` content: one entry per direct member, which is either + # the reserved "source" table or an arbitrary project-directory table. + # See `_build_secrets` for why this module validates no deeper than + # "a table of tables" here. + secrets: dict[str, dict[str, Any]] = field(default_factory=dict) + + +def find_repo_config(start: Path | None = None) -> Path | None: + """Nearest .stackward.toml at or above `start`, stopping at the repo root. + + Returns None rather than falling back to a default: a tool that guesses + which backend it is talking to is worse than one that refuses. + """ + current = (start or Path.cwd()).resolve() + for candidate in [current, *current.parents]: + config = candidate / CONFIG_FILENAME + if config.is_file(): + return config + if (candidate / ".git").exists(): + break + return None + + +def _parse_version(text: str) -> tuple[int, ...]: + """Parse a dotted version string into a tuple of ints for comparison. + + String comparison gets this wrong: `"0.10.0" > "0.9.0"` is False under + lexicographic ordering (`"1" < "9"` character by character) but True + numerically, which is the comparison a version floor actually needs. + """ + parts = text.split(".") + if not parts or any(not part.isdigit() for part in parts): + raise ValueError(f"not a numeric dotted version: {text!r}") + return tuple(int(part) for part in parts) + + +def _extend(builtin: frozenset[str], declared: list[str] | None) -> frozenset[str]: + """`sensitive_keys` EXTENDS the built-ins. + + A repo can only ever add to the set of key names that make a leaf + suspect. Silently letting a repo drop a built-in key would weaken the + gate without anyone having deliberately chosen that. + """ + if declared is None: + return builtin + return builtin | frozenset(declared) + + +def _replace(builtin: frozenset[str], declared: list[str] | None) -> frozenset[str]: + """`sensitive_parents` REPLACES the built-ins. + + Unlike a key name, a whole parent *category* may have to stay in + plaintext on purpose in some repository, and there is no "extend but + exclude one" operation available to opt a single built-in parent back + out. Replacing the list wholesale is the only way to do that, and it is + what a repo reaching for this knob wants. This asymmetry with `_extend` + above is deliberate, not an inconsistency — do not "fix" it into + matching behaviour. + """ + if declared is None: + return builtin + return frozenset(declared) + + +def _require_str(data: dict[str, Any], key: str, name: str) -> str | None: + if key not in data: + return None + value = data[key] + if not isinstance(value, str): + raise ConfigError(f"{name} must be a string") + return value + + +def _require_str_list(data: dict[str, Any], key: str, name: str) -> list[str] | None: + if key not in data: + return None + value = data[key] + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ConfigError(f"{name} must be a list of strings") + return value + + +def _require_module_class(value: str, name: str) -> None: + """Validate the `"module:Class"` shape the brief states explicitly for + `stack_models` values. + + Unlike `[secrets.*]`'s interior (left unspecified by the brief, and + deferred to Task 8 — see `_build_secrets`), this shape *is* specified + here, so it gets the same fail-closed treatment as `model_net`'s enum: + a malformed value (a dot instead of a colon, an empty module or class + name) is rejected at load time, naming the key, rather than loading + silently and surfacing later as a confusing `ImportError` deep inside + Task 10, far from the file that caused it. This only checks shape — + it never imports the module or resolves the class. + """ + parts = value.split(":") + if len(parts) != 2 or not parts[0] or not parts[1]: + raise ConfigError(f"{name} must be of the form 'module:Class', got {value!r}") + + +def _build_check(raw: Any) -> CheckConfig: + if not isinstance(raw, dict): + raise ConfigError("'check' must be a table") + + unknown = set(raw) - _CHECK_KEYS + if unknown: + raise ConfigError(f"unknown key 'check.{sorted(unknown)[0]}'") + + model_net = raw.get("model_net", "none") + if not isinstance(model_net, str) or model_net not in _MODEL_NET_VALUES: + raise ConfigError( + f"check.model_net must be one of {sorted(_MODEL_NET_VALUES)!r}, " + f"got {model_net!r}" + ) + + stack_models_raw = raw.get("stack_models", {}) + if not isinstance(stack_models_raw, dict): + raise ConfigError("check.stack_models must be a table of string to string") + stack_models: dict[str, str] = {} + for namespace, target in stack_models_raw.items(): + # TOML table keys are always strings; only the value needs checking. + if not isinstance(target, str): + raise ConfigError( + f"check.stack_models.{namespace!r} must be a string of the " + "form 'module:Class'" + ) + _require_module_class(target, f"check.stack_models.{namespace!r}") + stack_models[namespace] = target + + declared_paths_fn = _require_str( + raw, "declared_paths_fn", "check.declared_paths_fn" + ) + if declared_paths_fn is not None: + # The escape hatch for a repository whose marking convention is not + # this tool's `json_schema_extra={"secret": True}`. Same + # `"module:Attribute"` shape as `stack_models`, and validated here for + # the same reason: a malformed value would otherwise surface as a + # confusing ImportError inside the generator subprocess, far from the + # file that caused it. + _require_module_class(declared_paths_fn, "check.declared_paths_fn") + + sensitive_keys = _require_str_list(raw, "sensitive_keys", "check.sensitive_keys") + sensitive_parents = _require_str_list( + raw, "sensitive_parents", "check.sensitive_parents" + ) + allowed_references = _require_str_list( + raw, "allowed_references", "check.allowed_references" + ) + + return CheckConfig( + model_net=model_net, + stack_models=stack_models, + declared_paths_fn=declared_paths_fn, + sensitive_keys=_extend(BUILTIN_SENSITIVE_KEYS, sensitive_keys), + sensitive_parents=_replace(BUILTIN_SENSITIVE_PARENTS, sensitive_parents), + allowed_references=list(allowed_references or []), + ) + + +def _build_secrets(raw: Any) -> dict[str, dict[str, Any]]: + """Shape only: a table whose every direct member is itself a table. + + This is the boundary drawn in Task 2's brief: Task 8 owns the manifest's + *semantics* — what `secret`/`plaintext`/`unmanaged` mean inside a + project's table, what `[secrets.source]`'s own keys are — and this + module does not yet know what a valid entry looks like, so it does not + check for one. Unlike `[check]` above, whose keys this module enumerates + and rejects unknown ones for, `[secrets.*]` content is passed through + unexamined on purpose. That is not the same inconsistency either: it + holds because `[check]`'s keys are fully specified in this task's + brief and `[secrets.*]`'s are not — don't "fix" this one to match, and + don't loosen `[check]` to match this one. + """ + if not isinstance(raw, dict): + raise ConfigError("'secrets' must be a table") + + result: dict[str, dict[str, Any]] = {} + for name, value in raw.items(): + if not isinstance(value, dict): + raise ConfigError(f"secrets.{name!r} must be a table") + result[name] = value + return result + + +def _min_version_of(data: dict[str, Any]) -> str | None: + """The `min_version` a parsed document declares, validated, or `None`. + + One definition rather than two, because two callers need the identical + answer for different reasons: `_build_config` below, which is building a + whole `Config` and by then knows every other key is one it recognises; + and `read_min_version`, which needs the floor out of a document this + binary may be *unable* to validate the rest of. See `read_min_version` + for why that second case exists at all. + """ + min_version = _require_str(data, "min_version", "min_version") + if min_version is not None: + try: + _parse_version(min_version) + except ValueError as exc: + raise ConfigError(f"min_version: {exc}") from exc + return min_version + + +def _build_config(data: dict[str, Any]) -> Config: + unknown = set(data) - _TOP_LEVEL_KEYS + if unknown: + raise ConfigError(f"unknown key {sorted(unknown)[0]!r}") + + profile = _require_str(data, "profile", "profile") + python = _require_str(data, "python", "python") + min_version = _min_version_of(data) + + return Config( + profile=profile, + python=python, + min_version=min_version, + check=_build_check(data.get("check", {})), + secrets=_build_secrets(data.get("secrets", {})), + ) + + +# The `(at line L, column C)` coordinate `tomllib` appends to most of its +# messages, and the only part of such a message that is safe to quote back — +# see `toml_position`. +_TOML_POSITION = re.compile(r"\(at (?:line \d+, column \d+|end of document)\)") + + +def toml_position(exc: tomllib.TOMLDecodeError) -> str: + """Just the coordinate out of a `TOMLDecodeError`, as ` (at line L, + column C)`, or `""` when the message does not carry one. + + **`tomllib` echoes document text.** Most of its messages are a fixed + description plus a coordinate, but not all: `tomllib.loads("[a]\\nx=1\\n + [a]\\n")` raises `Cannot declare ('a',) twice`, naming the key back. So + a call site that interpolates `exc` is quoting whatever the parser + decided to quote, which is a property of the input file rather than a + decision this code made — and Global Constraint 4 forbids printing the + matching text. + + That was found first in `store.py`, whose `config` file can hold a + credential (a `backend_url` of the documented + `postgres://user:password@host/db` form), and the same defect was still + in `load_config_text` below, guarded only by the claim that + `.stackward.toml` holds path and key *names* and never a value. The + claim is a reasonable reading of what that file is *for*, and it is not + something this error path can check: the text it is reporting on is, by + definition, a file that failed to parse — half-pasted, mid-edit, or + written by someone who misunderstood it. "The file should not contain a + credential" is exactly the assumption a mistake violates, and the error + that catches the mistake is the last place that should read one back. + + One definition, in `config.py` rather than in `store.py`, because both + modules need it and only this direction of import is available: + `store.py` imports `crypto`, and `config.py` is on the gate path, where + Global Constraint 2 forbids reaching either. So `store` imports this, + and never the reverse. + + The coordinate is kept because it is what makes the error actionable and + it is structural, not quoted text. `TOMLDecodeError` exposes no + `lineno`/`colno` before 3.13 and this project supports 3.11, so it is + matched out of the message rather than read off the exception; a message + shape this does not recognise simply yields no coordinate, which loses + diagnostics and never discloses anything. + """ + match = _TOML_POSITION.search(str(exc)) + return f" {match.group(0)}" if match else "" + + +def load_config_text(text: str, origin: str) -> Config: + """Parse and validate `text` as `.stackward.toml` content. + + Parsing is separated from reading a file on purpose, and this is the + seam that makes it so. `commands.pre_commit` must load the policy the + *index* holds -- `git show ":.stackward.toml"` -- because a policy read + from the working tree could relax the gate for a commit that does not + itself carry the relaxation. Having only a path-taking loader forced + that command to read the working tree, which was a fail-open: the + content it scanned came from the index while the rules it scanned under + came from wherever the developer had most recently typed. There is no + round-trip through a temporary file here either, for the same reason + `pre_commit` never writes a staged blob to disk. + + `origin` is what the raised `ConfigError` names, so a caller can label + where the text came from in the terms a person would use to reproduce + it: a filesystem path for `load_config`, and `":.stackward.toml"` -- the + literal argument to `git show` -- for the index. + + Raises `ConfigError` naming the offending key on anything invalid: an + unknown key at any recognised level, a value of the wrong type, or an + unparseable `min_version`. Never falls back to defaults — a config that + fails to validate must not be treated as though it did not exist. + """ + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + # Position only, never the parser's message — see `toml_position`, + # and `store.load_store_config`, which is the same refusal for the + # same reason. + raise ConfigError(f"{origin}: invalid TOML{toml_position(exc)}") from exc + + try: + return _build_config(data) + except ConfigError as exc: + raise ConfigError(f"{origin}: {exc}") from exc + + +def _read_config_text(path: Path) -> str: + """`path`'s content as text, or `ConfigError`. + + An unreadable file and one that is not UTF-8 are both `ConfigError`, + never a silent fall back to defaults: TOML is defined as UTF-8, so bytes + that are not are a broken config file, not an absent one. + """ + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(f"{path}: cannot read: {exc}") from exc + except UnicodeDecodeError as exc: + raise ConfigError(f"{path}: cannot read: not valid UTF-8: {exc}") from exc + + +def load_config(path: Path) -> Config: + """Parse and validate the file at `path` as `.stackward.toml`. + + A thin reader in front of `load_config_text`, which does all the + validation; see there for what is raised and why the two are separate. + """ + return load_config_text(_read_config_text(path), str(path)) + + +def read_min_version(path: Path) -> str | None: + """The `min_version` the file at `path` declares — and *nothing else + about that file*. + + This exists because a full `load_config` cannot answer the question in + the one case the floor is most needed for. `_build_config` rejects an + unrecognised top-level key before it gets as far as building anything, + so a repository that declares both a floor this binary does not meet + **and** a key this binary does not know — which is precisely + forward-compatibility, the situation `min_version` exists to make + survivable — used to report `unknown key 'future_key'` and let the + command run, instead of saying "upgrade stackward". The unknown key is + a *consequence* of the unmet floor there, not an independent problem, + and reporting the consequence sends the reader to fix the wrong thing. + + So the floor is read on its own terms: parse the document, take + `min_version`, validate that one value, and form no opinion on any + other key. Everything else about the file is still checked, with better + words, by whichever command actually needs it — both gate commands + refuse outright on a policy they cannot parse or validate. + + Raises `ConfigError` for an unreadable file, invalid TOML, or a + `min_version` that is not a parseable version string. Its sole caller, + `cli._min_version_refusal`, prints none of those: it treats every one of + them as "there is no floor I can establish here" and lets the command + run. The messages are shaped anyway, rather than left as bare + exceptions, so that a second caller cannot inherit an error it would be + unable to report. + """ + text = _read_config_text(path) + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + # Position only, never the parser's message — see `toml_position`. + raise ConfigError(f"{path}: invalid TOML{toml_position(exc)}") from exc + try: + return _min_version_of(data) + except ConfigError as exc: + raise ConfigError(f"{path}: {exc}") from exc + + +def enforce_min_version( + config: Config | None, + command: str, + *, + installed: str = __version__, +) -> None: + """Raise `MinVersionError` when `installed` is older than `config.min_version`. + + Called from `cli.main` between argument parsing and dispatch, so the + floor applies to every command rather than to whichever ones remembered + to ask. Commands in `cli.VERSION_CHECK_EXEMPT` always run: blocking + `doctor` would make the upgrade instruction this raises unreachable from + the one command that diagnoses the problem. + + The instruction names `install.sh`, the installer this project actually + ships, rather than a `stackward self-update` subcommand -- there is no + such subcommand, and an error message whose only advice is a command + that does not exist is worse than no advice at all. + + `cli` is imported locally rather than at module level because `cli` + imports `find_repo_config` from this module — importing `cli` here at + module scope would make the two modules need each other to finish + executing. + """ + from .cli import VERSION_CHECK_EXEMPT # local: breaks an import cycle + + if config is None or config.min_version is None: + return + if command in VERSION_CHECK_EXEMPT: + return + if _parse_version(installed) < _parse_version(config.min_version): + raise MinVersionError( + f"this repository requires stackward >= {config.min_version} " + f"(installed: {installed}); upgrade by re-running the installer: " + "curl -fsSL " + "https://raw.githubusercontent.com/longthread/stackward/main/install.sh" + " | sh" + ) diff --git a/src/stackward/crypto.py b/src/stackward/crypto.py new file mode 100644 index 0000000..fb11d4f --- /dev/null +++ b/src/stackward/crypto.py @@ -0,0 +1,409 @@ +"""The sealed envelope: Argon2id + AES-256-GCM, and nothing else. + +This module knows how to turn bytes and a password into an envelope and back +again. It does not know what a profile is, where the store lives, or how a +password was obtained — `store.py` owns all of that. The split is deliberate: +the crypto here is the part that is hard to get right and easy to get subtly +wrong, so it is kept small enough to read in one sitting and testable without +touching a filesystem. + +**Why Argon2id and not PBKDF2.** This store interoperates with nothing. It is +read only by this tool, so it is free to use the modern memory-hard KDF rather +than a legacy one. `commands/check_passphrase.py` deliberately uses PBKDF2 with +1,000,000 iterations instead — not as an inconsistency, but because that code +must reproduce *Pulumi's* envelope to verify a stack passphrase, which is a +different job with a fixed, externally-imposed format. Do not unify them. + +**The envelope.** + + {"v": 1, + "kdf": {"name": "argon2id", "memory_kib": …, "iterations": …, "lanes": …}, + "salt": "", "nonce": "", "ct": ""} + +Two readings of the brief's "fields `v`, `kdf`, `salt`, `nonce`, `ct`, each +base64" are possible. The one taken here base64-encodes the three *byte-valued* +fields — the ones JSON cannot carry natively — and leaves `v` an integer and +`kdf` a structured object. Base64-encoding a version number defeats the purpose +of having one: the first thing a reader of a broken store needs is to see which +format version it is looking at, and `IjEi` does not tell them. `kdf` carries +its parameters rather than only a name so that a future change to the cost +parameters can still open envelopes written today; expressing that as a nested +object rather than as additional top-level fields keeps the envelope to the five +fields the brief names. + +**AAD.** Every seal binds an associated-data string, which `store.py` sets to +the profile name. AES-GCM authenticates it without encrypting it, so an envelope +lifted out of one profile's slot and dropped into another's fails to open even +under the correct password. That is a property of the tag, not of a check this +code performs, which is what makes it hard to accidentally remove. + +**Passwords are NFC-normalised before encoding.** A password containing a +non-ASCII character can be delivered as either a precomposed or a decomposed +sequence depending on the platform and input method — the same characters, +different UTF-8 bytes, a different derived key, and a store that opens on the +machine it was created on and nowhere else. Normalising costs one line and must +be decided before any store exists: adding it later would break every password +that worked without it. +""" + +from __future__ import annotations + +import base64 +import binascii +import json +import os +import unicodedata +from typing import Any + +from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + +ENVELOPE_VERSION = 1 +KDF_NAME = "argon2id" + +# RFC 9106's second recommended option (t=3, m=64 MiB, p=4), which measures at +# roughly 60 ms here — small enough to sit in front of an interactive command, +# large enough to make an offline guessing attack on a stolen file expensive. +# Written into every new envelope; envelopes already on disk are opened with +# whatever parameters they carry. +ARGON2ID_MEMORY_KIB = 65536 +ARGON2ID_ITERATIONS = 3 +ARGON2ID_LANES = 4 + +KEY_BYTES = 32 # AES-256 +SALT_BYTES = 16 # RFC 9106 §4 +NONCE_BYTES = 12 # the GCM nonce length everything agrees on + +# Sanity bounds on parameters read back from a file, which is untrusted input +# even when it is the user's own: the file is hand-editable and this code must +# not turn a typo into a crash or a hang. +# +# These are NOT a security control, and describing them as downgrade protection +# would be wrong: changing a parameter in the file changes the derived key, so +# the GCM tag fails regardless, and the exposure this store addresses (a stolen +# disk, backup or tarball) does not include an attacker with write access to a +# live config directory. +# +# What earns them their place is the `iterations` ceiling specifically. An +# absurd `memory_kib` is survivable without a bound — `cryptography` answers it +# with a `MemoryError`, which `_derive` below maps like any other library +# rejection. An absurd `iterations` raises nothing at all: Argon2id simply runs, +# for as long as it is asked to, so a mistyped digit becomes a command that +# never returns and cannot be told apart from a hung tool. There is no exception +# to catch for that one, which is why it has to be refused before the derivation +# starts rather than handled after it. +_MIN_MEMORY_KIB = 8192 +_MAX_MEMORY_KIB = 1048576 # 1 GiB +_MIN_ITERATIONS = 1 +_MAX_ITERATIONS = 16 +_MIN_LANES = 1 +_MAX_LANES = 16 +_MIN_SALT_BYTES = 8 # `cryptography`'s own floor +_MAX_SALT_BYTES = 1024 + + +class CryptoError(Exception): + """Base for anything this module refuses to do. Never carries a plaintext, + a password, or any part of either — callers print these.""" + + +class EnvelopeError(CryptoError): + """The envelope is not something this module can attempt to open: a missing + or misshapen field, an unknown version or KDF, a parameter outside the + sanity bounds, undecodable base64. + + Distinct from `DecryptionError` on purpose. This one means "the input is + not an envelope"; that one means "it is an envelope and it did not open", + which is the branch a caller reports as a wrong password.""" + + +class DecryptionError(CryptoError): + """The GCM tag did not verify: a wrong password, a wrong AAD (an envelope + moved between profiles), or a modified ciphertext — indistinguishable by + construction, and deliberately so. + + Always raised `from` the underlying `InvalidTag`, so a test can prove the + failure came from the tag rather than from a length check or a comparison + this module performed itself.""" + + +class EmptyPasswordError(CryptoError): + """No password was supplied. + + Argon2id derives a perfectly good 32-byte key from `b""`, so without this + check an empty password seals a real store that opens with no password at + all, silently and with no way to tell it apart from one that was protected. + Password *strength* is the prompting command's business; *emptiness* is not, + because `""` is indistinguishable from "nothing was supplied" — the same + reasoning `store.select_profile` applies to an empty `STACKWARD_PROFILE`.""" + + +class EncodingError(CryptoError): + """A password or associated-data string cannot be encoded as UTF-8: it + contains a lone surrogate. + + `os.environ` decodes a byte sequence that was never valid UTF-8 using + Python's `surrogateescape` error handler rather than raising, so a + provider that reads a store password (`STACKWARD_PASSWORD`) or any other + value out of the process environment can hand this module a string that + round-trips through `str` but cannot be re-encoded. Without this, + `_normalise`'s `str.encode("utf-8")` — and the identical `aad.encode + ("utf-8")` in `seal`/`unseal` — raise a bare `UnicodeEncodeError`, + letting it escape past every `except CryptoError` this codebase's + docstrings promise stays fail-closed. Found by review while auditing + Task 6's store ahead of Task 11 feeding it environment-sourced values; + confirmed empirically that the failure is here, in the raw encode of an + unescaped string, and not in `seal_json`'s `json.dumps(..., + ensure_ascii=True)` output, which escapes a lone surrogate to plain + ASCII (`\\udc80`) and therefore encodes without error.""" + + +def _normalise(password: str) -> bytes: + """NFC-normalise and encode, refusing a password that is not one. + + The check lives here rather than at each store entry point so that no call + path can seal or open anything with an empty password by forgetting to ask. + Whitespace-only is refused too, and the password is *not* stripped: trimming + it would silently change what the user typed into something else. + """ + if not password.strip(): + raise EmptyPasswordError("a password is required") + normalised = unicodedata.normalize("NFC", password) + try: + return normalised.encode("utf-8") + except UnicodeEncodeError as exc: + # See `EncodingError`: reachable for a password read out of the + # process environment that was never valid UTF-8 to begin with. + raise EncodingError( + "password contains a character that cannot be encoded as UTF-8" + ) from exc + + +def describe_value(value: Any) -> str: + """Name a value read out of the store without echoing it. + + Envelope fields are hand-editable, and this module's messages are printed. + A number or `None` in a header field cannot carry a credential, so it is + shown; anything else is reported by type only. A user who pasted a + credential into `v` or `kdf.name` by mistake would otherwise have it read + back at them by the error that caught the mistake — the same rule + `commands/check_config.describe_yaml_error` follows for the same reason. + + Public (not `_`-prefixed) specifically so `store.py` can reuse it for the + version field of the credentials document itself, rather than growing a + second copy of the same judgement that would have to be kept in step + with this one by hand. + """ + if value is None or isinstance(value, (int, float)): + return repr(value) + return f"<{type(value).__name__}>" + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def _unb64(value: Any, field: str) -> bytes: + if not isinstance(value, str): + raise EnvelopeError(f"envelope field {field!r} must be a base64 string") + try: + # `validate=True` so that stray characters are rejected rather than + # silently discarded, which would let two distinct texts decode to the + # same bytes and make the envelope's encoding non-canonical. + return base64.b64decode(value, validate=True) + except (binascii.Error, ValueError) as exc: + raise EnvelopeError(f"envelope field {field!r} is not valid base64") from exc + + +def _require_int(data: dict[str, Any], key: str, low: int, high: int) -> int: + value = data.get(key) + # `bool` is an `int` subclass; `True` would otherwise read as the integer 1. + if not isinstance(value, int) or isinstance(value, bool): + raise EnvelopeError(f"kdf parameter {key!r} must be an integer") + if not low <= value <= high: + raise EnvelopeError(f"kdf parameter {key!r} must be between {low} and {high}") + return value + + +def _derive(password: str, salt: bytes, params: dict[str, int]) -> bytes: + """Argon2id, with the parameters the envelope carries. + + Everything reaching here has already passed the sanity bounds above, so a + failure from `cryptography` means this module's own validation and the + library's disagree. Map it to `EnvelopeError` rather than letting a raw + `ValueError`, `OverflowError` or `MemoryError` escape: a caller catching + `CryptoError` must not have to enumerate the library's exception types to + stay fail-closed. + + `UnsupportedAlgorithm` is in that tuple for a case the others do not cover. + Argon2id needs an OpenSSL 3.2+ backend, and on an older one `cryptography` + raises it — inheriting straight from `Exception`, so it would otherwise sail + past every `except CryptoError` in `store.py` as a raw traceback on the one + class of machine where nothing about the store works. + + `_normalise` runs outside the `try` on purpose: an empty password is this + module refusing, not the library rejecting, and must not be recoded as an + envelope problem. + """ + key_material = _normalise(password) + try: + return Argon2id( + salt=salt, + length=KEY_BYTES, + iterations=params["iterations"], + lanes=params["lanes"], + memory_cost=params["memory_kib"], + ).derive(key_material) + except ( + ValueError, + OverflowError, + MemoryError, + TypeError, + UnsupportedAlgorithm, + ) as exc: + raise EnvelopeError( + f"key derivation rejected the envelope's parameters ({type(exc).__name__})" + ) from exc + + +def _encode_aad(aad: str) -> bytes: + """`aad.encode("utf-8")`, with a lone surrogate mapped to `EncodingError` + rather than a bare `UnicodeEncodeError` — see `EncodingError`. Every + caller in this codebase passes a profile name or a fixed literal, both + already restricted to safe charsets, but this module has no way to see + a future caller that does not, and the encode is one line either way.""" + try: + return aad.encode("utf-8") + except UnicodeEncodeError as exc: + raise EncodingError( + "associated data contains a character that cannot be encoded as UTF-8" + ) from exc + + +def seal(plaintext: bytes, password: str, aad: str) -> dict[str, Any]: + """Encrypt `plaintext` under `password`, binding `aad` into the tag. + + A fresh salt and a fresh nonce are drawn from `os.urandom` on every call and + never reused. That is what makes it safe to seal the same plaintext under + the same password repeatedly — as password rotation does across every + envelope in the store — without leaking that the plaintexts are equal, and + it is why nonce reuse (catastrophic for GCM) cannot arise here: no nonce is + ever derived from anything, so there is no scheme to get wrong. + """ + salt = os.urandom(SALT_BYTES) + nonce = os.urandom(NONCE_BYTES) + params = { + "memory_kib": ARGON2ID_MEMORY_KIB, + "iterations": ARGON2ID_ITERATIONS, + "lanes": ARGON2ID_LANES, + } + key = _derive(password, salt, params) + ciphertext = AESGCM(key).encrypt(nonce, plaintext, _encode_aad(aad)) + return { + "v": ENVELOPE_VERSION, + "kdf": {"name": KDF_NAME, **params}, + "salt": _b64(salt), + "nonce": _b64(nonce), + "ct": _b64(ciphertext), + } + + +def unseal(envelope: Any, password: str, aad: str) -> bytes: + """Open an envelope, or raise. + + Raises `EnvelopeError` when the input is not a well-formed envelope this + version understands, and `DecryptionError` when it is but the tag does not + verify. There is no third outcome: this function never returns bytes it did + not authenticate, so a wrong password cannot yield garbage for a caller to + misinterpret as a credential. + """ + if not isinstance(envelope, dict): + raise EnvelopeError("envelope must be a JSON object") + + version = envelope.get("v") + if version != ENVELOPE_VERSION: + raise EnvelopeError( + f"unsupported envelope version {describe_value(version)} " + f"(this build writes and reads {ENVELOPE_VERSION})" + ) + + kdf = envelope.get("kdf") + if not isinstance(kdf, dict): + raise EnvelopeError("envelope field 'kdf' must be a JSON object") + if kdf.get("name") != KDF_NAME: + raise EnvelopeError( + f"unsupported kdf {describe_value(kdf.get('name'))} (expected {KDF_NAME!r})" + ) + params = { + "memory_kib": _require_int(kdf, "memory_kib", _MIN_MEMORY_KIB, _MAX_MEMORY_KIB), + "iterations": _require_int(kdf, "iterations", _MIN_ITERATIONS, _MAX_ITERATIONS), + "lanes": _require_int(kdf, "lanes", _MIN_LANES, _MAX_LANES), + } + + salt = _unb64(envelope.get("salt"), "salt") + if not _MIN_SALT_BYTES <= len(salt) <= _MAX_SALT_BYTES: + raise EnvelopeError( + f"envelope salt must be between {_MIN_SALT_BYTES} and " + f"{_MAX_SALT_BYTES} bytes, got {len(salt)}" + ) + nonce = _unb64(envelope.get("nonce"), "nonce") + if len(nonce) != NONCE_BYTES: + raise EnvelopeError( + f"envelope nonce must be {NONCE_BYTES} bytes, got {len(nonce)}" + ) + ciphertext = _unb64(envelope.get("ct"), "ct") + + key = _derive(password, salt, params) + try: + return AESGCM(key).decrypt(nonce, ciphertext, _encode_aad(aad)) + except InvalidTag as exc: + raise DecryptionError( + "could not decrypt: wrong password, or the sealed data does not " + "belong here" + ) from exc + + +def seal_json(payload: Any, password: str, aad: str) -> dict[str, Any]: + """`seal` over a JSON-serialisable value. + + `sort_keys` so that the plaintext depends on the mapping's content and not + on the order a dict happened to be built in — the length of the ciphertext + already leaks the size of the payload, and there is no reason to let its + construction order leak too. + + `json.dumps`'s default `ensure_ascii=True` already escapes a lone + surrogate in `payload` to plain ASCII (`\\udc80`, six literal + characters), so the `.encode("utf-8")` below cannot itself raise on one — + confirmed empirically, not assumed. The `try` stays anyway, as a second + line of defence at the one call in this module a future change to + `ensure_ascii` or `separators` could put back in `_normalise`'s + situation, and because the cost of guarding an unreachable line here is + one `except` clause. + """ + try: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + except UnicodeEncodeError as exc: + raise EncodingError( + "payload contains a character that cannot be encoded as UTF-8" + ) from exc + return seal(encoded, password, aad) + + +def unseal_json(envelope: Any, password: str, aad: str) -> Any: + """`unseal`, then parse the authenticated plaintext as JSON. + + Parsing only ever happens on bytes GCM has already authenticated, so the + JSON parser is never exposed to attacker-chosen input — an ordering that + matters and is easy to reverse by accident. + """ + plaintext = unseal(envelope, password, aad) + try: + return json.loads(plaintext) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + # Reachable only for an envelope this tool did not write, since the tag + # already proved it was sealed under this password and AAD. The message + # names the failure and nothing about the plaintext. + raise EnvelopeError( + f"decrypted content is not valid JSON ({type(exc).__name__})" + ) from exc diff --git a/src/stackward/nets/__init__.py b/src/stackward/nets/__init__.py new file mode 100644 index 0000000..3730b4f --- /dev/null +++ b/src/stackward/nets/__init__.py @@ -0,0 +1,18 @@ +"""The credential-detection nets: independent ways to answer "is this a +plaintext credential?" over a parsed stack config document. + +`heuristic` is the key-name/parent-name net and ships unconditionally: it +needs nothing from the repository beyond `[check]` policy. `model` is the +declaration-driven net, built from a repository's own pydantic marks and +carried across the frozen-binary boundary as a committed graph (`[check]`'s +`model_net`/`stack_models`); it runs only when that repository declares it. + +The two are **unioned**, never ranked — a leaf either can name is a finding — +and they share one leaf decision (`heuristic.is_encrypted`, +`heuristic.is_empty`) so they cannot drift into disagreeing about whether the +same value is a credential. Both take an already-parsed document and do no +I/O, which is what lets one caller scan a file on disk and another scan a +staged git blob without either round-tripping content through a temp file. +""" + +from __future__ import annotations diff --git a/src/stackward/nets/heuristic.py b/src/stackward/nets/heuristic.py new file mode 100644 index 0000000..8969c61 --- /dev/null +++ b/src/stackward/nets/heuristic.py @@ -0,0 +1,275 @@ +"""The heuristic net: a key-name/parent-name scan for plaintext credentials. + +This is the check that runs unconditionally, with no repository cooperation +required — it needs nothing beyond the `[check]` policy and the parsed YAML +document. It walks the *parsed structure*, never a rendered path string: a +dict key may legally contain `.` or `[`, and re-parsing a rendered path to +decide sensitivity would silently lose track of such a key. `paths.render` +is the only thing that turns a walked position back into text, for exactly +the paths this module reports. + +Content-level, not file-level, by design. `find_plaintext_credentials` takes +already-parsed document data plus policy and returns findings; it does no +I/O, imports nothing beyond `..config` and `..paths`, and in particular +never imports `yaml` or `cryptography`. That keeps it callable both from a +file on disk (`commands.check_config`) and from a staged git blob +(`commands.pre_commit`, checking `git show ":"` output) without either +caller having to round-trip through a temp file first. + +Two rules a plausible implementation gets wrong — see `is_encrypted` and +`is_empty` for why, in detail: + +- "Encrypted" is a property of the leaf's *parent mapping*, not of the + leaf's own path text. `path.endswith(".secure")` cannot tell a genuine + `{"secure": ciphertext}` apart from a malformed `{"secure": ..., "other": + ...}` sitting next to it — both end in `.secure`. +- "Empty" is `None` or `""`, tested so that `True`/`False` also pass but the + integers `0` and `1` do not — a plain `value in (None, "", False, True)` + gets this backwards, because `0 == False` and `1 == True` in Python. + +A third rule, added after the first version of this module shipped: +**a key matching `sensitive_keys` makes its entire subtree sensitive**, not +just a leaf sitting directly under it. The `{"secure": ...}` envelope is +itself one level of nesting — `apiToken: {"secure": "v1:..."}` puts the +actual leaf at `apiToken.secure`, whose own key is `secure`, which matches +no built-in pattern. Under the original "only the leaf's own key, or an +explicitly-declared `sensitive_parents` ancestor" rule, that leaf was never +even considered sensitive when a repository declares no `sensitive_parents` +— which is every repository's starting policy, since `sensitive_parents` +ships empty by design. That made the malformed-envelope case this module +exists to catch (`apiToken: {"secure": X, "other": Y}`, `other` a plaintext +leak) *unreachable* under the default policy: neither `secure` nor `other` +ever became a candidate, so `is_encrypted`'s careful parent-shape check +never ran. Propagating `sensitive_keys` down the subtree closes that gap +without inventing an environment-specific default for `sensitive_parents` +(which Global Constraint 5 forbids) — it needs no new configuration, only a +key a repository (or the built-ins) already flagged as sensitive. + +**Accepted cost:** a sensitive-keyed mapping holding structured +non-credential data now reports every leaf beneath it — +`token_settings: {retries: 3}` flags `token_settings.retries`, even though +`3` is not a credential. This is a deliberate trade, not an oversight: in a +commit gate, a false positive costs someone a minute reading a diff; a +missed credential is permanent and public the moment it is pushed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ..config import CheckConfig +from ..paths import render + +# The one shape that means "this value is encrypted, not stored in +# plaintext" in a Pulumi stack config: the leaf's parent mapping has exactly +# one key, named `secure`. Anything else — extra sibling keys included — is +# not this shape, however much a stringly-typed check might want it to be. +_SECURE_WRAPPER_KEYS = frozenset({"secure"}) + + +class DocumentError(ValueError): + """The document cannot be scanned at all: it is not a mapping. + + Raised, never swallowed — a YAML document that failed to parse into a + key/value structure is a reason to refuse the check, not a reason to + report zero findings. Callers map this to the "could not run" exit + code, distinct from both "clean" and "findings present". + """ + + +def find_plaintext_credentials(document: Any, check: CheckConfig) -> list[str]: + """Walk `document` for plaintext credentials under `check`'s policy. + + `document` is already-parsed YAML data (e.g. the result of + `yaml.safe_load`) — this function does no parsing and no file I/O, so it + works identically whether the document came from a file on disk or a + `git show ":"` blob. + + A leaf is a finding when its own key matches `check.sensitive_keys` + (case-insensitive substring), OR any ancestor key matched + `check.sensitive_keys`, OR any ancestor key is a member of + `check.sensitive_parents` — unless the leaf's parent mapping is the + `{"secure": ...}` encryption wrapper, the value is empty (`None`, `""`, + `True` or `False`), or the leaf's rendered path is one of + `check.allowed_references` (a path that names another secret rather + than holding one). A sensitive key's subtree is sensitive all the way + down — see the module docstring for why. + + Returns the rendered paths of every finding, sorted. Never returns or + inspects a value for anything other than its structure and identity — + no finding is reported by value, only by path. + + Raises `DocumentError` if `document` is not a mapping at the top level. + """ + if not isinstance(document, dict): + raise DocumentError( + "stack config must be a mapping at the top level, got " + f"{type(document).__name__}" + ) + + ctx = _ScanContext( + check=check, + allowed_references=frozenset(check.allowed_references), + findings=[], + ) + _walk(document, [], False, frozenset({id(document)}), ctx) + return sorted(ctx.findings) + + +@dataclass(frozen=True) +class _ScanContext: + """The parts of a scan that stay the same as `_walk`/`_check_leaf` + recurse, bundled so their signatures carry only what actually varies + per call: the node, its path, and whether an ancestor was sensitive. + + `findings` is a plain mutable list, appended to in place — `frozen` + only stops `_walk`/`_check_leaf` from *reassigning* `ctx.findings` to a + different list, which they never need to do. + """ + + check: CheckConfig + allowed_references: frozenset[str] + findings: list[str] + + +def _walk( + node: Any, + path: list[str | int], + ancestor_sensitive: bool, + visiting: frozenset[int], + ctx: _ScanContext, +) -> None: + """Recurse through `node`, appending a rendered path to `ctx.findings` + for every leaf that qualifies. + + `ancestor_sensitive` is true once any *strict* ancestor key has matched + `sensitive_keys` or belonged to `sensitive_parents` — never the current + key itself, since that key's own contribution (if any) is what makes + its *children* sensitive, not itself; a leaf's own sensitivity is + checked separately in `_check_leaf`. It only ever turns true going + down and is never cleared, so a sensitive key's entire subtree stays + sensitive to the bottom. + + `visiting` holds `id()` of every container currently open on *this* + descent path — the call stack, not a global "already scanned" set. A + YAML anchor/alias pair (`credential: &x\\n b: *x\\n`) makes a dict or + list contain itself: `safe_load` permits it, and revisiting it would + recurse forever, each cycle producing a longer, distinct rendered path + (a depth cap would only delay the crash, not avoid it, and would also + have to pick an arbitrary limit). A container whose `id()` is already + in `visiting` is such a cycle and is skipped rather than walked. This + must be scoped to the current path, not "every id ever seen": two + *different*, non-cyclic branches legitimately sharing one object via + two YAML anchors (no alias pointing back at an ancestor) must each + still be walked at their own path. + """ + if isinstance(node, dict): + for raw_key, value in node.items(): + # YAML mapping keys are ordinarily strings already; this only + # guards the corner cases (a YAML 1.1 bareword like `on:` + # parsing as a bool, a stray int key) from crashing `render`, + # which requires str|int segments and rejects bool outright. + key = raw_key if isinstance(raw_key, str) else str(raw_key) + child_path = [*path, key] + if isinstance(value, (dict, list)): + if id(value) in visiting: + continue + key_is_sensitive = _matches_sensitive_key( + key, ctx.check.sensitive_keys + ) or key in ctx.check.sensitive_parents + _walk( + value, + child_path, + ancestor_sensitive or key_is_sensitive, + visiting | {id(value)}, + ctx, + ) + else: + _check_leaf(node, key, value, child_path, ancestor_sensitive, ctx) + elif isinstance(node, list): + for index, value in enumerate(node): + child_path = [*path, index] + if isinstance(value, (dict, list)): + if id(value) in visiting: + continue + _walk(value, child_path, ancestor_sensitive, visiting | {id(value)}, ctx) + else: + _check_leaf(None, None, value, child_path, ancestor_sensitive, ctx) + + +def _check_leaf( + parent: dict[str, Any] | None, + own_key: str | None, + value: Any, + path: list[str | int], + ancestor_sensitive: bool, + ctx: _ScanContext, +) -> None: + """Decide whether the scalar `value` at `path` is a finding. + + `parent` is the mapping directly containing this leaf (`None` when the + leaf sits directly in a list, which cannot be the `{"secure": ...}` + shape). `own_key` is that mapping's key for this leaf (`None` for a + list element, which has no name of its own to match `sensitive_keys` + against — only sensitivity inherited from a strict ancestor can make + such a leaf sensitive). + """ + sensitive_by_key = own_key is not None and _matches_sensitive_key( + own_key, ctx.check.sensitive_keys + ) + if not (sensitive_by_key or ancestor_sensitive): + return + if parent is not None and is_encrypted(parent): + return + if is_empty(value): + return + rendered = render(path) + if rendered in ctx.allowed_references: + return + ctx.findings.append(rendered) + + +def _matches_sensitive_key(key: str, sensitive_keys: frozenset[str]) -> bool: + lowered = key.lower() + return any(pattern.lower() in lowered for pattern in sensitive_keys) + + +def is_encrypted(parent: dict[str, Any]) -> bool: + """The leaf's parent mapping is exactly `{"secure": ...}` — one key, + named `secure`. + + Public (not `_`-prefixed) so `nets.model` can reuse it, for the same + reason `check_config.describe_yaml_error` is public: the model net has + to make the identical "is this leaf actually a credential?" decision, + and two independently-written copies of this check would eventually + disagree about the same leaf — which, since the two nets are unioned, + would show up as one net reporting a value the other suppressed. + + Tested against the mapping's shape, never against the leaf's own + rendered path: `path.endswith(".secure")` cannot distinguish this from + a malformed `{"secure": ..., "other": ...}`, whose `.secure` child is + not encrypted (nothing tells a reader the sibling `other` was not the + plaintext value that leaked) and must still be flagged. + """ + return set(parent.keys()) == _SECURE_WRAPPER_KEYS + + +def is_empty(value: Any) -> bool: + """`None`, `""`, `True` and `False` are excluded; every integer, + including `0` and `1`, is not. + + Public for the same reason as `is_encrypted` above: `nets.model` needs + exactly this predicate, not a second one that agrees with it today. + + `None`/`True`/`False` are tested by identity and `""` by an + exact-type-safe equality (`isinstance` first) — never by a containment + test like `value in (None, "", False, True)`. That test uses `==` under + the hood, and in Python `0 == False` and `1 == True`, so it would + silently also exclude the integers `0` and `1` — which occur as real + config leaves (e.g. a numeric value written in plaintext) and must + still be findings. + """ + if value is None or value is True or value is False: + return True + return isinstance(value, str) and value == "" diff --git a/src/stackward/nets/model.py b/src/stackward/nets/model.py new file mode 100644 index 0000000..3c4512c --- /dev/null +++ b/src/stackward/nets/model.py @@ -0,0 +1,747 @@ +"""The model net: declared secrets, carried across a process boundary as a graph. + +The heuristic net (`nets.heuristic`) names a credential by the *shape of its +key*. This net names one because a repository *declared* it — a pydantic +field marked `json_schema_extra={"secret": True}`. The two are unioned, so a +credential either of them can name is caught. + +**Why an artifact at all.** A shipped `stackward` binary bundles its own +interpreter and cannot import the consuming repository's classes. The +declarations therefore have to cross that boundary as data, written by +`stackward sync-declared-secrets` (which runs under the *repository's* +interpreter — see `commands.sync_declared`) and read back here. Nothing in +this module imports pydantic, and the shipped binary must never bundle it. + +**Why a graph and not a list of path patterns.** This is the decision the +whole design turns on, and the counterexample is concrete:: + + class Node(BaseModel): + token: str = Field(json_schema_extra={"secret": True}) + children: list["Node"] = [] + +Any generator that emits a flat list of patterns needs a cycle guard, so it +emits `['token']` and stops. The *data* that model describes is unbounded: +`token`, `children[0].token`, `children[0].children[0].token`, … The flat +form therefore fails **open** at exactly the depth it must fail closed. So +the artifact is a graph — models, their fields, which fields are marked, and +which child model a field's values belong to — and `find_declared_credentials` +walks that graph and the document *together*. Recursion and mutual recursion +then work by construction rather than by enumeration, because the walk is +driven by the data, which is finite. + +Two semantics the equivalence is false without: + +- **Prefix, not exact.** A marked field covers everything beneath it, + whatever its annotation. A marked `peer: P` covers `peer.url` and `peer.k` + alike — see `_descend`, which stops consulting the graph entirely once + coverage is on. +- **Structural, not string-glob.** A mapping key may legally contain `.` and + `[`. This module walks the parsed document alongside the graph and calls + `paths.render` only to report a position it has already walked to; it never + builds or re-parses a path string to decide anything, exactly as + `nets.heuristic` does not. + +**Suppressions are shared with the heuristic net**, through +`heuristic.is_encrypted` and `heuristic.is_empty`: a declared field whose +value is the `{"secure": ...}` envelope is encrypted, and one that is absent +or blank holds nothing. Reimplementing either here would let the two nets +drift into disagreeing about whether the same leaf is a credential. + +**Freshness, and what it does and does not close.** A committed artifact goes +stale. `load_model_net` reads it from the **git index** +(`git show ":.stackward/declared-secrets.json"`), never the working tree, so +an unstaged regeneration cannot make a stale committed artifact pass; and it +compares the **git blob id** of every recorded source against the index, in +one `git ls-files -s`. The recorded sources come from an MRO walk, so a mark +inherited from a base class in another file is covered, and from the module +that defines any `Annotated` alias carrying a mark; for a contributor outside +the repository the dependency lockfile's blob id stands in, since `git show` +cannot reach site-packages. + +That is a *layer*, not a proof, and the boundary is worth stating plainly +rather than overclaiming: blob ids detect **modification of a recorded +source**. They cannot detect a **newly added** file that ought to contribute +but was never recorded — nothing in the index distinguishes that from a file +this repository does not use. Regenerating the artifact in CI and failing on +any diff is what closes that, and it is the mechanism to rely on; this one +catches the common case cheaply, at commit time. + +**Every failure to load is exit 2, never exit 1.** A missing, unreadable, +malformed or stale artifact under `model_net = "artifact"` means the check +could not run — reporting it as "a credential was found" would hide a +fail-closed refusal inside a normal finding, and reporting it as clean would +be worse. `model_net = "none"` is a *declared* mode that skips this net +entirely; it is never reached as a fallback from a failure above. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Union + +from ..config import CheckConfig +from ..paths import render +from .heuristic import DocumentError, is_empty, is_encrypted + +# Repository-relative, POSIX-separated, and the same string on both sides: +# `commands.sync_declared` writes here and `load_model_net` reads `":"` +# out of the index. A `:` revision that does not start with `./` is +# resolved from the repository's top level, which is what makes one constant +# correct for both. +ARTIFACT_PATH = ".stackward/declared-secrets.json" + +# Named in every refusal this module raises. A fail-closed error that does not +# say how to clear itself is a fail-closed error someone disables. +REGEN_COMMAND = "stackward sync-declared-secrets" + +# Bumped when the artifact's shape changes **or when the generator's coverage +# does**. An unrecognised version is a hard refusal, not a best-effort parse, +# and the refusal has to run in both directions: a newer generator's graph read +# by an older matcher would be under-walked, and an older generator's graph read +# by a newer matcher is under-*covered* — internally consistent, fresh against +# every source it records, and quietly describing less than the repository +# declares. +# +# That second direction is why this is 2 rather than 1. Version 1's generator +# answered "this cannot hold a model" for any class it did not recognise, so a +# pydantic dataclass, a `TypedDict` or a `NamedTuple` holding a marked field +# left the graph with no refusal. Fixing the generator does nothing for an +# artifact already committed by the old one — nothing in it is stale — so the +# version is what forces every repository to regenerate once, and the refusal +# names the command that does it. +ARTIFACT_VERSION = 2 + +# The document key holding a Pulumi stack's configuration. Findings are +# rendered from the document root (`config..<...>`) so that a path +# this net reports and a path the heuristic net reports are the same string +# for the same leaf, and the union of the two can be deduplicated. +_CONFIG_KEY = "config" + + +class ModelNetError(Exception): + """The model net could not be loaded: the artifact is missing from the + index, unreadable, malformed, of an unrecognised version, or stale. + + Always maps to exit code 2 — "the check could not run" — never to 1, + which means specifically "a credential was found". + """ + + +@dataclass(frozen=True) +class ModelRef: + """A field's values are instances of the model with this id.""" + + model: str + + +@dataclass(frozen=True) +class ListOf: + """A field's values are a list; every element walks `item`.""" + + item: TypeNode + + +@dataclass(frozen=True) +class DictOf: + """A field's values are a mapping; every *value* walks `value`. + + The mapping's own keys are data, not field names, and are never matched + against anything — that is what keeps a key containing `.` or `[` from + being lost. + """ + + value: TypeNode + + +# `None` means "nothing beneath this position is a declared model", which is +# the overwhelmingly common case (every scalar field, and every container of +# scalars). Representing it as absence keeps the committed artifact small and +# its diffs meaningful. +TypeNode = Union[ModelRef, ListOf, DictOf] + + +@dataclass(frozen=True) +class DeclaredField: + """One field of one model, as the matcher needs it. + + `secret` is the mark. `child` is where the field's values go next in the + graph, and is `None` both for a scalar field and for a marked field — + once a field is marked, its annotation stops mattering, because coverage + is prefix-based and everything beneath it is a finding regardless. + """ + + secret: bool + child: TypeNode | None + + +@dataclass(frozen=True) +class ModelNet: + """The parsed graph: which model each config namespace starts at, and + every model reachable from those roots.""" + + roots: dict[str, str] + models: dict[str, dict[str, DeclaredField]] + + +def _require_mapping(value: Any, what: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ModelNetError(f"artifact: {what} must be an object") + for key in value: + if not isinstance(key, str): + raise ModelNetError(f"artifact: {what} has a non-string key") + return value + + +def _parse_type_node(raw: Any, where: str) -> TypeNode | None: + """One `child` entry, validated rather than trusted. + + An unknown `kind`, a missing member or a dangling model reference is a + refusal: a graph this module cannot fully walk would silently under-report + exactly the nesting it exists to reach. + """ + if raw is None: + return None + node = _require_mapping(raw, f"{where}: child") + kind = node.get("kind") + if kind == "model": + model = node.get("model") + if not isinstance(model, str): + raise ModelNetError(f"artifact: {where}: 'model' must be a string") + return ModelRef(model) + if kind == "list": + return ListOf(_require_node(node.get("item"), f"{where}: list item")) + if kind == "dict": + return DictOf(_require_node(node.get("value"), f"{where}: dict value")) + raise ModelNetError(f"artifact: {where}: unknown child kind {kind!r}") + + +def _require_node(raw: Any, where: str) -> TypeNode: + """A container's interior, which must itself be a node. + + A container whose interior holds no model is written as a scalar field by + the generator — `list[str]` is not `{"kind": "list", "item": null}` — so + a null interior here is a malformed artifact, not an empty one. + """ + node = _parse_type_node(raw, where) + if node is None: + raise ModelNetError(f"artifact: {where} must not be null") + return node + + +def parse_net(payload: Any) -> tuple[ModelNet, dict[str, str]]: + """Validate a decoded artifact and return its graph and its `sources` map. + + Raises `ModelNetError` on anything it cannot fully account for: an + unrecognised version, a missing or misshapen section, a root or a child + naming a model the artifact does not define, or an empty `sources` map + (which would leave freshness unverifiable, and so unverified). + + **These guards are the artifact's only integrity check, not a second + one.** `sync_declared.build_artifact` deliberately removes the artifact + from its own `sources` map (a self-reference would make every artifact + stale the moment it was written), so the committed file's own bytes are + compared against nothing: `verify_sources` checks the files that + *contributed* to it, and every one of them can be untouched while the + artifact itself has been hand-edited or badly merged. Everything this + function accepts is therefore something an edit can introduce and nothing + downstream will question — which is why a shape that merely *reads* + wrong, such as `"secret": 0` for `false` or a null container interior, is + refused here rather than tolerated. Both of those quietly shrink the net; + neither is anything the generator can emit. + """ + document = _require_mapping(payload, "top level") + + version = document.get("version") + if version != ARTIFACT_VERSION: + raise ModelNetError( + f"artifact: unsupported version {version!r} " + f"(this stackward reads version {ARTIFACT_VERSION}); " + f"regenerate with: {REGEN_COMMAND}" + ) + + raw_models = _require_mapping(document.get("models"), "'models'") + models: dict[str, dict[str, DeclaredField]] = {} + for model_id, raw_fields in raw_models.items(): + fields: dict[str, DeclaredField] = {} + for name, raw_field in _require_mapping( + raw_fields, f"model {model_id!r}" + ).items(): + entry = _require_mapping(raw_field, f"model {model_id!r} field {name!r}") + secret = entry.get("secret", False) + if not isinstance(secret, bool): + raise ModelNetError( + f"artifact: model {model_id!r} field {name!r}: " + "'secret' must be a boolean" + ) + child = _parse_type_node( + entry.get("child"), f"model {model_id!r} field {name!r}" + ) + fields[name] = DeclaredField(secret=secret, child=child) + models[model_id] = fields + + raw_roots = _require_mapping(document.get("roots"), "'roots'") + roots: dict[str, str] = {} + for namespace, model_id in raw_roots.items(): + if not isinstance(model_id, str): + raise ModelNetError(f"artifact: root {namespace!r} must name a model") + roots[namespace] = model_id + + for model_id in _referenced_models(roots, models): + if model_id not in models: + raise ModelNetError(f"artifact: undefined model {model_id!r}") + + sources: dict[str, str] = {} + for path, blob in _require_mapping(document.get("sources"), "'sources'").items(): + if not isinstance(blob, str): + raise ModelNetError(f"artifact: source {path!r} must name a blob id") + sources[path] = blob + if not sources: + raise ModelNetError( + "artifact: 'sources' is empty, so freshness cannot be verified; " + f"regenerate with: {REGEN_COMMAND}" + ) + + return ModelNet(roots=roots, models=models), sources + + +def _referenced_models( + roots: dict[str, str], models: dict[str, dict[str, DeclaredField]] +) -> set[str]: + """Every model id named anywhere in the graph, roots included.""" + referenced = set(roots.values()) + for fields in models.values(): + for field in fields.values(): + node = field.child + while node is not None: + if isinstance(node, ModelRef): + referenced.add(node.model) + break + node = node.item if isinstance(node, ListOf) else node.value + return referenced + + +@dataclass(frozen=True) +class _MatchContext: + """What stays the same as `_walk` recurses, so its signature carries only + what varies: the node, its path, its graph position and its coverage.""" + + net: ModelNet + allowed_references: frozenset[str] + findings: list[str] + + +def find_declared_credentials( + document: Any, net: ModelNet, check: CheckConfig +) -> list[str]: + """Walk `document` against `net`, returning the rendered path of every + declared field found holding a plaintext value. + + Takes an already-parsed document and does no I/O, exactly like + `heuristic.find_plaintext_credentials` — so the same function serves a + file read from disk and a blob read from the index, with no caller + round-tripping content through a temporary file to reuse it. + + Matching starts at `document["config"]`, whose keys are Pulumi's + `:` namespaces; a namespace the artifact declares a root + for is walked against that root model, and one it does not is left to + the heuristic net. Findings are rendered from the *document* root, so a + path reported here and a path reported by the heuristic net are the same + string for the same leaf. + + Raises `DocumentError` if `document` is not a mapping — the same refusal + the heuristic net makes, for the same reason. + """ + if not isinstance(document, dict): + raise DocumentError( + "stack config must be a mapping at the top level, got " + f"{type(document).__name__}" + ) + + config = document.get(_CONFIG_KEY) + if not isinstance(config, dict): + return [] + + ctx = _MatchContext( + net=net, + allowed_references=frozenset(check.allowed_references), + findings=[], + ) + for namespace, model_id in sorted(net.roots.items()): + if namespace not in config: + continue + value = config[namespace] + path: list[str | int] = [_CONFIG_KEY, namespace] + if isinstance(value, (dict, list)): + _walk( + value, + path, + ModelRef(model_id), + False, + frozenset({id(config), id(value)}), + ctx, + ) + # A namespace whose value is a scalar cannot be a model instance; + # there is no field beneath it to have been marked. + return sorted(set(ctx.findings)) + + +def _walk( + node: Any, + path: list[str | int], + tnode: TypeNode | None, + covered: bool, + visiting: frozenset[int], + ctx: _MatchContext, +) -> None: + """Recurse through `node`, driven jointly by the data and by `tnode`. + + `covered` is true once a marked field has been entered. From that point + the graph is not consulted again: every scalar beneath is a finding, + whatever the marked field's annotation was, which is what makes coverage + prefix-based rather than exact. + + `visiting` holds `id()` of every container open on *this* descent path, + for the same reason `heuristic._walk` carries one: a YAML anchor/alias + pair can make a container contain itself, and revisiting it would recurse + forever producing a longer distinct path each time. It is scoped to the + current path rather than "every container ever seen", so two sibling + branches legitimately sharing one object are each still walked. + """ + if isinstance(node, dict): + for raw_key, value in node.items(): + key = raw_key if isinstance(raw_key, str) else str(raw_key) + child_covered, child_node = _descend(ctx, tnode, key, covered) + if not child_covered and child_node is None: + continue + child_path = [*path, key] + if isinstance(value, (dict, list)): + if id(value) in visiting: + continue + _walk( + value, + child_path, + child_node, + child_covered, + visiting | {id(value)}, + ctx, + ) + elif child_covered: + _report(node, value, child_path, ctx) + elif isinstance(node, list): + child_covered, child_node = _element(tnode, covered) + if not child_covered and child_node is None: + return + for index, value in enumerate(node): + child_path = [*path, index] + if isinstance(value, (dict, list)): + if id(value) in visiting: + continue + _walk( + value, + child_path, + child_node, + child_covered, + visiting | {id(value)}, + ctx, + ) + elif child_covered: + _report(None, value, child_path, ctx) + + +def _descend( + ctx: _MatchContext, tnode: TypeNode | None, key: str, covered: bool +) -> tuple[bool, TypeNode | None]: + """Where mapping key `key` lands: still covered, and what graph position. + + Once `covered` is on it stays on and the graph position is irrelevant. + Otherwise the key is a *field name* only when the current position is a + model; under a `DictOf` the key is data and never matched against + anything, which is exactly what keeps a key containing `.` or `[` from + being lost the way a dotted glob would lose it. + """ + if covered: + return True, None + if isinstance(tnode, ModelRef): + field = ctx.net.models[tnode.model].get(key) + if field is None: + return False, None + if field.secret: + return True, None + return False, field.child + if isinstance(tnode, DictOf): + return False, tnode.value + return False, None + + +def _element(tnode: TypeNode | None, covered: bool) -> tuple[bool, TypeNode | None]: + """Where a list element lands. A list carries no names, so nothing here + can newly become covered — only an already-covered ancestor, or a + `ListOf` position, gives an element anywhere to go.""" + if covered: + return True, None + if isinstance(tnode, ListOf): + return False, tnode.item + return False, None + + +def _report( + parent: dict[str, Any] | None, + value: Any, + path: list[str | int], + ctx: _MatchContext, +) -> None: + """Record a covered leaf, unless it is encrypted, empty, or an allowed + reference. + + `parent` is the mapping directly containing the leaf, or `None` for a + list element (which cannot be the `{"secure": ...}` envelope). Reports the + rendered path only — never the value, and never anything derived from it. + """ + if parent is not None and is_encrypted(parent): + return + if is_empty(value): + return + rendered = render(path) + if rendered in ctx.allowed_references: + return + ctx.findings.append(rendered) + + +# -------------------------------------------------------------------------- +# Loading the artifact. Everything above this line is pure: parsed data in, +# findings out, no I/O. Below it is the part that has to talk to git, because +# freshness is a property of the index and cannot be read from anywhere else. +# -------------------------------------------------------------------------- + + +def run_git(args: list[str]) -> bytes: + """Run `git ` and return stdout, raising `ModelNetError` on failure. + + Runs in the current working directory; every invocation that must be + anchored to the repository passes `-C ` explicitly rather than + through a parameter here, so there is one way it is done rather than two. + + Shared with `commands.sync_declared` so that both sides of the artifact — + the writer resolving blob ids and the reader verifying them — use one + invocation style and one failure mode. git's own stderr is included: the + only invocations made through this helper are `rev-parse`, `ls-files` and + `show` of the artifact path, and a *failing* run of any of them has not + produced blob content to leak. + + Runs under `LC_ALL=C`, like `commands.pre_commit`'s own git calls. Nothing + here branches on git's message text — every caller branches on + `returncode` — so this is hardening rather than a live fix, but a stderr + line quoted verbatim into a `ModelNetError` should read the same in a bug + report as it did on the machine that hit it. + + The environment is **merged**, never replaced. git exports `GIT_DIR` and + `GIT_INDEX_FILE` to a hook process, and this helper is what reads the + index the commit is actually being gated on; a bare `env={"LC_ALL": "C"}` + would silently point every `ls-files` and `show` here at a different index + than `pre-commit` is checking — a fail-open no output assertion would + catch. + """ + try: + proc = subprocess.run( + ["git", *args], + capture_output=True, + check=False, + env={**os.environ, "LC_ALL": "C"}, + ) + except OSError as exc: + raise ModelNetError(f"cannot run git {' '.join(args)}: {exc}") from exc + if proc.returncode != 0: + stderr = proc.stderr.decode("utf-8", errors="replace").strip() + raise ModelNetError(f"git {' '.join(args)} failed: {stderr}") + return proc.stdout + + +def repo_toplevel() -> Path: + """The repository root, so every git invocation below can be anchored to + it with `-C`. `git ls-files`' pathspecs are resolved relative to the + current directory, so a check run from a subdirectory would otherwise + match nothing and read as "every source is missing".""" + raw = run_git(["rev-parse", "--show-toplevel"]) + try: + text = raw.decode("utf-8").strip() + except UnicodeDecodeError as exc: + raise ModelNetError(f"repository path is not valid UTF-8: {exc}") from exc + if not text: + raise ModelNetError("git did not report a repository root") + # Resolved, because callers compare it against `Path(...).resolve()` of a + # source file to decide whether that file is inside the repository. On a + # platform where the repository sits under a symlinked directory, git's + # own answer and a resolved module path are two different strings for one + # directory, and the comparison would fail for every file. + return Path(text).resolve() + + +def index_blob_ids(root: Path, paths: list[str]) -> dict[str, str]: + """Blob id of each of `paths` as the **index** currently holds it. + + One `git ls-files -s` for the whole set, which is both cheaper and more + exact than hashing file content: it reads what would actually be + committed, and it needs no assumption about line endings, filters, or the + working tree matching the index at all. + + Paths absent from the index are simply absent from the result — it is the + caller that decides whether that is a refusal, because the two callers + mean different things by it. An unmerged entry (stage other than 0) is a + refusal here, since there is no single blob to compare against. + """ + if not paths: + return {} + raw = run_git(["-C", str(root), "ls-files", "-s", "-z", "--", *paths]) + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ModelNetError(f"index listing is not valid UTF-8: {exc}") from exc + + found: dict[str, str] = {} + for entry in text.split("\0"): + if not entry: + continue + meta, _, path = entry.partition("\t") + parts = meta.split() + if len(parts) != 3 or not path: + raise ModelNetError("could not parse git ls-files output") + _mode, blob, stage = parts + if stage != "0": + raise ModelNetError( + f"{path}: unresolved merge conflict in the index; " + "resolve conflicts and re-stage before checking" + ) + found[path] = blob + return found + + +def read_artifact_from_index(root: Path) -> Any: + """The artifact as the **index** holds it, decoded from JSON. + + Read with `git show ":"`, never `Path.read_text()`. An unstaged + regeneration sitting only in the working tree must not let the stale + artifact that is actually about to be committed pass — which is the whole + reason this check exists rather than trusting the file on disk. + """ + try: + raw = run_git(["-C", str(root), "show", f":{ARTIFACT_PATH}"]) + except ModelNetError as exc: + # git's own message here is about a revision it could not resolve and + # says nothing about what to do — and "not in the index" is by far the + # most likely reason to reach it: the artifact was never generated, or + # was generated and never staged. Say that, and say how to fix it. + raise ModelNetError( + f"{ARTIFACT_PATH} is not in the git index, and " + 'check.model_net = "artifact" requires it. Generate it with: ' + f"{REGEN_COMMAND}, then: git add {ARTIFACT_PATH} " + f"(git reported: {str(exc).splitlines()[0]})" + ) from exc + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ModelNetError( + f"{ARTIFACT_PATH}: staged content is not valid UTF-8: {exc}" + ) from exc + try: + return json.loads(text) + except ValueError as exc: + raise ModelNetError(f"{ARTIFACT_PATH}: invalid JSON: {exc}") from exc + + +def verify_sources(root: Path, sources: dict[str, str]) -> None: + """Refuse unless every recorded source is in the index at its recorded + blob id. + + A mismatch means a file that contributed a mark has changed since the + artifact was written, and the artifact may no longer describe the models + — so it is a hard refusal naming the regeneration command, never a + downgrade to running without this net. Reports the first offending path + in sorted order, so the message is stable across runs. + """ + found = index_blob_ids(root, sorted(sources)) + for path in sorted(sources): + actual = found.get(path) + if actual is None: + raise ModelNetError( + f"{ARTIFACT_PATH} is stale: {path} is no longer in the index; " + f"regenerate with: {REGEN_COMMAND}" + ) + if actual != sources[path]: + raise ModelNetError( + f"{ARTIFACT_PATH} is stale: {path} has changed since it was " + f"generated; regenerate with: {REGEN_COMMAND}" + ) + + +def _verify_roots(net: ModelNet, check: CheckConfig) -> None: + """The artifact must start from exactly the namespaces the policy declares. + + A second, independent guard on the same drift the recorded blob id of + `.stackward.toml` catches. A namespace declared in `check.stack_models` + with no root in the artifact is not a smaller answer; it is *no* answer + for that namespace, silently. + + **When it is the only guard left.** `build_artifact` records + `.stackward.toml` as a source, so for any artifact this tool generated, + `verify_sources` runs first and catches a changed policy before this + function is reached. What it does not cover is the artifact's own bytes, + which it deliberately does not record (see `parse_net`): an edit that + removes `.stackward.toml` from `sources` *and* a namespace from `roots` + leaves every remaining recorded source matching the index exactly. This + comparison is what still refuses that file. + + **Key sets only.** A root's model id is `module:QualName` — what the + class says about itself — while `check.stack_models` names whatever + attribute the operator imported it as. A module-level alias, or a class + re-exported from a package `__init__`, makes the two diverge for a + completely correct artifact, and comparing pairs would refuse it while + calling it staleness. Regenerating would then produce the identical file + and the refusal would repeat forever. + + (This previously justified the same choice by nested classes. That case + cannot arise: `bootstrap.regen._import_object` resolves a target with a + flat `getattr`, so `declared:Outer.Inner` is refused at sync time and + never reaches an artifact. The alias form is the one that does occur, and + `tests/test_model_net.py` exercises it end to end.) + """ + declared = set(check.stack_models) + present = set(net.roots) + if declared == present: + return + missing = sorted(declared - present) + extra = sorted(present - declared) + detail = [] + if missing: + detail.append(f"declares {missing} that it does not cover") + if extra: + detail.append(f"covers {extra} that it no longer declares") + raise ModelNetError( + f"{ARTIFACT_PATH} is stale: this repository {' and '.join(detail)}; " + f"regenerate with: {REGEN_COMMAND}" + ) + + +def load_model_net(check: CheckConfig) -> ModelNet | None: + """The model net for this repository, or `None` when it is switched off. + + `model_net = "none"` returns `None` without touching git or the + filesystem: it is a *declared* mode, and this function must be + indistinguishable from a repository that never had an artifact. Every + other outcome under `model_net = "artifact"` — no artifact in the index, + unreadable content, a malformed or unsupported graph, a stale source — + raises `ModelNetError`, which callers map to exit code 2. + """ + if check.model_net == "none": + return None + if check.model_net != "artifact": + raise ModelNetError(f"unknown check.model_net mode {check.model_net!r}") + + root = repo_toplevel() + net, sources = parse_net(read_artifact_from_index(root)) + verify_sources(root, sources) + _verify_roots(net, check) + return net diff --git a/src/stackward/paths.py b/src/stackward/paths.py new file mode 100644 index 0000000..4362fd4 --- /dev/null +++ b/src/stackward/paths.py @@ -0,0 +1,183 @@ +"""The one config-path grammar, shared by findings, manifests and matching. + +Pulumi's `--path` syntax is dotted segments (`a.b`), `[n]` for a list index, +and `["..."]` for a key that cannot be written bare — one containing a dot, +a bracket, a double quote, a backslash, or the empty string. Inside the +quotes, a literal double quote or backslash is escaped by prefixing it with +a backslash. Everything downstream (the finding a scan prints, the key a +manifest stores, the walk that matches config data against policy) needs to +agree on exactly this representation; if the gate and the matcher each grew +their own path formatter, a key that round-trips through one and not the +other would silently stop matching. So there is exactly one `parse`/`render` +pair, here, and nothing else in the tool writes a path by hand. + +The one property that has to hold, in both directions: + + render(parse(text)) == text for every canonical text + parse(render(segments)) == segments for every segment list + +`parse` only ever produces an `int` from an *unquoted* `[n]` — a dotted or +quoted segment is always `str`, even when it looks like a number. That is +what keeps `["1"]` (the string key `"1"`) and `[1]` (list index `1`) distinct +on both sides of the round trip: `render` never has to guess, and `parse` +never has to coerce. +""" + +from __future__ import annotations + +# Characters that cannot appear in a bare or dotted segment. A key containing +# any of these, or the empty string, must be written `["..."]`. `\` is in +# this set *because* it is the escape prefix inside a bracketed key — once +# backslash means something inside brackets, a bare/dotted key containing one +# has to go through the same quoted-and-escaped regime, not a bare one where +# the same character would mean something else (itself, literally). +_NEEDS_QUOTING = frozenset('.[]"\\') + + +def _needs_quoting(key: str) -> bool: + return key == "" or any(char in _NEEDS_QUOTING for char in key) + + +def render(segments: list[str | int]) -> str: + """The inverse of `parse`: bracket-quote a segment only when required. + + A digit-only string (the dict key `"1"`) is rendered bare/dotted, not + quoted — `parse` never treats a dotted segment as an index, so quoting + buys nothing and `render(parse(x)) == x` would otherwise fail for the + canonical text `a.1`. + """ + if not segments: + raise ValueError("invalid path: segment list is empty") + + parts: list[str] = [] + for index, segment in enumerate(segments): + if isinstance(segment, bool): + # bool is an int subclass; letting one through would silently + # render `True` as the list index `[1]`. + raise ValueError( + f"invalid path segment at index {index}: bool is not a valid segment" + ) + if isinstance(segment, int): + if segment < 0: + raise ValueError( + f"invalid path segment at index {index}: list indices cannot be negative" + ) + parts.append(f"[{segment}]") + elif _needs_quoting(segment): + # Escape `\` before `"` — reversing the order would double-escape + # a backslash that a prior `"` -> `\"` pass had just inserted. + escaped = segment.replace("\\", "\\\\").replace('"', '\\"') + parts.append(f'["{escaped}"]') + elif index == 0: + parts.append(segment) + else: + parts.append(f".{segment}") + return "".join(parts) + + +def _scan_bare_key(text: str, pos: int) -> tuple[str, int]: + """Consume a bare/dotted key starting at `pos`, stopping before `.` or `[`.""" + start = pos + while pos < len(text) and text[pos] not in ".[": + if text[pos] in '"]': + raise ValueError( + f"malformed path at position {pos}: unexpected character in key" + ) + pos += 1 + key = text[start:pos] + if not key: + raise ValueError(f"malformed path at position {start}: empty key") + return key, pos + + +def _scan_bracket(text: str, pos: int) -> tuple[str | int, int]: + """Consume a `[...]` segment starting at the `[` and return it with the + position just past the closing `]`.""" + start = pos + pos += 1 # past '[' + if pos < len(text) and text[pos] == '"': + pos += 1 + content_start = pos + chars: list[str] = [] + closed = False + while pos < len(text): + char = text[pos] + if char == "\\": + # The only two valid escapes: `\"` for a literal quote and + # `\\` for a literal backslash. Anything else after a `\` + # (including end of string) is malformed, not "an unescaped + # backslash" — a lone `\` right before the closing `"` would + # otherwise be indistinguishable from an escaped quote. + if pos + 1 < len(text) and text[pos + 1] in "\\\"": + chars.append(text[pos + 1]) + pos += 2 + continue + raise ValueError( + f"malformed path at position {pos}: invalid escape sequence" + ) + if char == '"': + closed = True + pos += 1 + break + chars.append(char) + pos += 1 + if not closed: + raise ValueError( + f"malformed path at position {content_start}: unterminated quoted key" + ) + if pos >= len(text) or text[pos] != "]": + raise ValueError( + f"malformed path at position {pos}: expected ']' after quoted key" + ) + return "".join(chars), pos + 1 + + digits_start = pos + while pos < len(text) and text[pos] != "]": + pos += 1 + if pos >= len(text): + raise ValueError(f"malformed path at position {start}: unterminated '[' bracket") + content = text[digits_start:pos] + # A leading zero (other than "0" itself) is rejected rather than + # tolerated: `render` never produces one, and accepting "01" as a second, + # silently-equivalent spelling of index 1 is exactly the kind of leniency + # this grammar exists to avoid. + has_leading_zero = len(content) > 1 and content[0] == "0" + if not content or has_leading_zero or any(char not in "0123456789" for char in content): + raise ValueError(f"malformed path at position {digits_start}: invalid list index") + return int(content), pos + 1 + + +def parse(text: str) -> list[str | int]: + """Parse Pulumi `--path` syntax into segments, list indices as `int`. + + Raises `ValueError` naming the offending position for malformed input. + The message never echoes the surrounding text — only the position and + what was wrong with it. + """ + if not text: + raise ValueError("malformed path at position 0: empty path") + + segments: list[str | int] = [] + pos = 0 + while pos < len(text): + char = text[pos] + if char == ".": + if not segments: + raise ValueError( + f"malformed path at position {pos}: path cannot start with '.'" + ) + key, pos = _scan_bare_key(text, pos + 1) + segments.append(key) + elif char == "[": + segment, pos = _scan_bracket(text, pos) + segments.append(segment) + else: + if segments: + # A bare character right after a previous segment, with no + # '.' or '[' separator — e.g. the 'b' in "a[0]b". + raise ValueError( + f"malformed path at position {pos}: expected '.' or '[' before next segment" + ) + key, pos = _scan_bare_key(text, pos) + segments.append(key) + return segments diff --git a/src/stackward/providers/__init__.py b/src/stackward/providers/__init__.py new file mode 100644 index 0000000..dc522c3 --- /dev/null +++ b/src/stackward/providers/__init__.py @@ -0,0 +1,120 @@ +"""Provider interface: swapping the credential source is a configuration +change, not a migration. + +**Two roles, one method each.** `CredentialStore.resolve(profile)` returns a +profile's bootstrap set (the names `commands.session` needs to run `pulumi` +at all: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +`PULUMI_CONFIG_PASSPHRASE`), the same shape `store.resolve_credentials` +already has. `SecretSource.resolve(name)` returns one manifest logical name's +value, or `None`, the same shape `commands.set_secrets.LocalSecretSource` +already has. Both are `typing.Protocol`s — structural, not a base class a +provider must inherit from — because the whole point is that a caller written +against the method, not against a particular module, cannot tell which +provider answered it. `file` and `dotenv` in this package are thin adapters +over exactly those two pre-existing, reviewed functions (see `file.py` and +`dotenv.py`); this task adapts them rather than rewriting them. + +**Construction is per-provider; only the method is uniform.** A `file` +`CredentialStore` needs a password and a store directory; an `env` one needs +a list of names; a `command` one needs an argv prefix. None of that belongs +in one generic factory signature that would have to grow a parameter for +every provider's own configuration — so `providers/__init__.py` defines no +`build_*` function at all. `commands.session` and `commands.set_secrets` each +own a small `if provider == "...":` dispatch instead, exactly the same shape +`commands.set_secrets._pulumi_config_set` already uses for `_MODE_FLAGS`, and +that is the whole point of the pattern being small enough to read in place +rather than a registry needing its own tests. + +**A provider failure is `ProviderError`, reported, never a silent empty +value.** `SecretSource.resolve` returning `None` and `SecretSource.resolve` +raising are different facts: `None` is "not set", which +`commands.set_secrets._publish_entries` already treats as a normal skip; a +raise is "this provider could not answer", which must surface as a `failed` +outcome instead — an empty value and a broken provider are indistinguishable +to everything downstream unless this distinction is kept. `file`'s +`CredentialStore` is the one exception to `ProviderError` specifically: it +lets `store.StoreError` (a wrong password, a missing envelope) propagate +unchanged, because `commands.session`'s existing tests already assert on +`StoreError` for exactly those failures, and rewrapping a pre-existing, +correct error type would be a rewrite this task's brief does not ask for, not +an adaptation. + +**No provider module is ever imported from the gate path.** `check-config` +and `pre-commit` resolve no credentials at all — that is what stops a commit +being blocked by an expired session or a flaky network — so neither may ever +cause a `stackward.providers*` module to be imported, even transitively. +`commands.session` and `commands.set_secrets` are both imported at module +scope by `cli.py` (and so, transitively, by every `stackward` invocation, +`check-config` and `pre-commit` included), so the only way to keep that +property is for *both* modules to import from this package **only inside the +function that is actually about to resolve a credential** (`session. +_build_credential_store`, `set_secrets._build_secret_source`, and the +`ProviderError` imports inside `set_secrets._publish_entries`/`_check_drift`) +— never at their own module scope. This mirrors `store.store_dir`'s own +`from .cli import config_home` and `config.enforce_min_version`'s `from .cli +import VERSION_CHECK_EXEMPT`, both local for the same reason: an import cycle +or an eager load neither module can afford at its own top level. +`tests/test_providers.py`'s +`test_the_check_config_entry_point_never_loads_a_provider_module` (and its +stronger, `cli.main`-driven sibling) is what proves this holds, in a +subprocess, rather than merely by this docstring's say-so. + +This package's own module scope (this file) imports nothing from `file.py`, +`env.py`, `dotenv.py` or `command.py` — each of those is imported directly by +whichever command needs it, so importing `stackward.providers` bare costs +nothing beyond defining the two protocols and `ProviderError` below. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +class ProviderError(Exception): + """A provider could not answer: a non-zero exit, a timeout, a missing + executable, or output it could not parse. Never carries a credential + value — every message here names a command, an exit code or a profile, + never anything the command printed. + + The distinction this type exists to make: an empty resolved value and a + provider that failed to produce one are different facts (see the module + docstring). Raise this rather than returning `""` or an incomplete + mapping on failure; it is the caller's job, not the provider's, to decide + what "not set" itself means for the operation at hand. + """ + + +@runtime_checkable +class CredentialStore(Protocol): + """Resolves a profile's bootstrap credential set.""" + + def resolve(self, profile: str) -> dict[str, str]: + """Every credential name known for `profile`, decrypted/fetched. + + May return a mapping missing a name the caller needs — `file`'s + envelope legitimately might not hold every name, and `env`'s process + environment legitimately might not export every name — callers + (`commands.session._require_credential_names`) already tell a + missing name apart from a resolution failure. A resolution failure + itself (the store could not be opened, the remote CLI failed) is + raised, never folded into an empty or partial mapping — see + `ProviderError`. + """ + ... + + +@runtime_checkable +class SecretSource(Protocol): + """Resolves one manifest logical name to a value.""" + + def resolve(self, name: str) -> str | None: + """`name`'s value, or `None` when it is simply not set — a + legitimate, silent outcome the caller treats as a skip (see + `commands.set_secrets.LocalSecretSource`, whose contract this + matches exactly: presence, not truthiness, is what `None` means). + + Raises `ProviderError` when the provider itself could not answer at + all, which must never be conflated with `None` — see the module + docstring. + """ + ... diff --git a/src/stackward/providers/command.py b/src/stackward/providers/command.py new file mode 100644 index 0000000..5f3d567 --- /dev/null +++ b/src/stackward/providers/command.py @@ -0,0 +1,105 @@ +"""`command`: a generic provider that shells out to an externally-configured +CLI — what "adding a remote provider must not add a Python dependency" (the +brief's own wording) means in practice. This module imports nothing beyond +the standard library and `ProviderError` from this same package; the argv it +runs, and everything that binary needs to authenticate or reach a remote +store, comes entirely from `command` in configuration. No vendor or product +name, API shape, or authentication convention appears anywhere in this +module, by design — a real remote store's own CLI is exactly as far away +from this code as `config` says it is. + +**Wire format.** `CommandSecretSource` runs `[*command, name]` and reads +stdout as the value (one trailing newline stripped, matching +`commands.set_secrets._pulumi_config_get`'s own convention for the same +reason); empty output means "not set". `CommandCredentialStore` runs +`[*command, profile]` and parses stdout as a JSON object of `name: value` +strings — the one already-generic wire format this codebase depends on +everywhere else (`json` is stdlib; no new format to invent or document +per-remote). Both raise `ProviderError` — never return an empty or partial +result to mean failure — on a non-zero exit, a timeout, or a missing +executable; `CommandCredentialStore` raises it too on stdout that is not a +JSON object of strings, since that is this provider failing to answer just +as much as a non-zero exit is. + +**Never written to disk, and stdout is decoded, never re-encoded, for +`CommandSecretSource`.** A fetched value lives in this process's memory for +the one call that resolved it — matching `providers/__init__.py`'s +"fetched values are held in memory for the invocation only" — and `_run` +decodes the child's stdout with `errors="surrogateescape"` rather than +`"strict"`, so a value that is not valid UTF-8 becomes a `str` this module +can still return (Python's own encoding for `os.environ`, applied here for +the same reason) instead of this provider itself raising on a value it +never needed to interpret as text beyond passing it through. +""" + +from __future__ import annotations + +import json +import subprocess +from collections.abc import Sequence + +from . import ProviderError + +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +def _require_command(command: Sequence[str]) -> list[str]: + if not command: + raise ProviderError("command provider: 'command' must be a non-empty list") + return list(command) + + +def _run(command: Sequence[str], arg: str, timeout: float) -> str: + """Run `[*command, arg]` and return its stdout, decoded as UTF-8 with + `surrogateescape` (see the module docstring). + + Raises `ProviderError` on anything short of a clean exit: a timeout, a + missing executable, or a non-zero exit code. Never includes the child's + stdout or stderr in the exception message — neither is this module's to + vouch for as free of the very value it just ran to fetch. + """ + try: + completed = subprocess.run([*command, arg], capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired as exc: + raise ProviderError(f"{command[0]!r} timed out") from exc + except OSError as exc: + raise ProviderError(f"cannot run {command[0]!r}: {exc}") from exc + if completed.returncode != 0: + raise ProviderError(f"{command[0]!r} exited {completed.returncode}") + return completed.stdout.decode("utf-8", errors="surrogateescape") + + +class CommandSecretSource: + """Resolves one logical name by running `command ` and reading its + stdout as the value. Empty stdout means "not set" — see the module + docstring's wire format.""" + + def __init__(self, command: Sequence[str], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None: + self._command = _require_command(command) + self._timeout = timeout + + def resolve(self, name: str) -> str | None: + text = _run(self._command, name, self._timeout) + value = text[:-1] if text.endswith("\n") else text + return value or None + + +class CommandCredentialStore: + """Resolves a profile's bootstrap set by running `command ` and + parsing its stdout as a JSON object of `name: value` strings.""" + + def __init__(self, command: Sequence[str], *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None: + self._command = _require_command(command) + self._timeout = timeout + + def resolve(self, profile: str) -> dict[str, str]: + text = _run(self._command, profile, self._timeout) + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ProviderError(f"{self._command[0]!r} did not print a JSON object") from exc + if not isinstance(payload, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in payload.items() + ): + raise ProviderError(f"{self._command[0]!r} must print a JSON object of strings") + return payload diff --git a/src/stackward/providers/dotenv.py b/src/stackward/providers/dotenv.py new file mode 100644 index 0000000..6e31be6 --- /dev/null +++ b/src/stackward/providers/dotenv.py @@ -0,0 +1,44 @@ +"""`dotenv`: the process environment layered over parsed `KEY=VALUE` files, +adapted to the `SecretSource` role — this is `set-secrets`'s existing, +default value resolution, unchanged, and the default when +`[secrets.source].provider` is absent. + +`commands.set_secrets.LocalSecretSource` already has this role's exact +shape (`resolve(name) -> str | None`, environment first, then each parsed +file in declared order, later files overriding earlier ones) — its own +docstring says it was written to this shape specifically so this interface +could adapt it rather than needing `commands.set_secrets._publish_entries`/ +`_check_drift` rewritten. This module is that adapter: it does the file +parsing `commands.set_secrets.cmd_set_secrets` used to do inline, and then +delegates to `LocalSecretSource` for resolution itself. + +Importing `commands.set_secrets` from here (rather than the other way +round) is deliberate, not a layering inversion: `set_secrets.py` must not +import this package at its own module scope — see `providers/__init__.py`'s +module docstring on why — so the dependency has to run in this direction, +and it costs nothing new, since `commands.set_secrets` is already imported +at `cli.py`'s module scope by every `stackward` invocation today. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from ..commands.set_secrets import LocalSecretSource, parse_env_file + + +class DotenvSecretSource: + """`LocalSecretSource`, built from `environ` and the already-resolved + `files` list (`commands.set_secrets.resolve_source_files`'s output) — + each existing file parsed with `parse_env_file`, a missing one silently + contributing nothing, exactly as `cmd_set_secrets` did before this + provider existed. + """ + + def __init__(self, environ: Mapping[str, str], files: list[Path]) -> None: + file_values = [parse_env_file(f) for f in files if f.exists()] + self._inner = LocalSecretSource(environ, file_values) + + def resolve(self, name: str) -> str | None: + return self._inner.resolve(name) diff --git a/src/stackward/providers/env.py b/src/stackward/providers/env.py new file mode 100644 index 0000000..9277100 --- /dev/null +++ b/src/stackward/providers/env.py @@ -0,0 +1,55 @@ +"""`env`: for CI, where credentials and secret values already live directly +in the process environment — no store to decrypt, no file to parse. + +Reading a mapping never fails, so neither class here has a `ProviderError` +path: a name this provider does not have is "not set", the same legitimate, +silent outcome any other provider reports the same way (see +`providers/__init__.py`'s module docstring), not a malfunction. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, Sequence + + +class EnvCredentialStore: + """Resolves a profile's bootstrap set by reading exactly `names` out of + `environ` — never the whole environment (see `providers/__init__.py`'s + caution about this: a caller that iterates an unfiltered `dict(os. + environ)` back as though it were a credential mapping would leak + everything else the process happens to have set). + + `profile` is accepted, by `CredentialStore`'s shape, and ignored: a CI + runner's environment is not partitioned by profile, so there is nothing + here for a profile name to select between — whichever profile asks gets + the same environment. + """ + + def __init__(self, names: Sequence[str], environ: Mapping[str, str] | None = None) -> None: + self._names = tuple(names) + self._environ = os.environ if environ is None else environ + + def resolve(self, profile: str) -> dict[str, str]: + return {name: self._environ[name] for name in self._names if name in self._environ} + + +class EnvSecretSource: + """Resolves a logical name directly from the process environment. Unlike + `dotenv` (`providers.dotenv.DotenvSecretSource`), no file is ever + consulted — a manifest declaring `[secrets.source]` `provider = "env"` + with a `files` list still gets *only* the environment; `files` is + `dotenv`'s own key, and this provider does not look at it. + + Presence, not truthiness: an environment variable exported as `""` is + still returned as `""`, not `None` — matching `SecretSource.resolve`'s + contract that the two are different facts (see + `commands.set_secrets.LocalSecretSource`, whose environment tier this + mirrors exactly). + """ + + def __init__(self, environ: Mapping[str, str] | None = None) -> None: + self._environ = os.environ if environ is None else environ + + def resolve(self, name: str) -> str | None: + return self._environ.get(name) diff --git a/src/stackward/providers/file.py b/src/stackward/providers/file.py new file mode 100644 index 0000000..c3f66ea --- /dev/null +++ b/src/stackward/providers/file.py @@ -0,0 +1,36 @@ +"""`file`: the encrypted store, adapted to the `CredentialStore` role. + +`store.resolve_credentials` already has this role's exact shape — a profile +name and a password in, a mapping of every sealed name back out — so this +class adds nothing beyond binding the password and the store directory at +construction time, which `CredentialStore.resolve`'s single-argument +signature has no room for otherwise. See `store.resolve_credentials`'s own +docstring for the resolution semantics themselves; this module does not +duplicate them. + +`StoreError` (and its subclasses `ProfileError`, `PasswordError`) propagate +unchanged rather than being rewrapped as `ProviderError`. That is a +deliberate exception to this package's own "`ProviderError`, uniformly" +stance (see `providers/__init__.py`): `commands.session`'s existing, +reviewed tests already assert `pytest.raises(StoreError)` for a wrong +password and for a profile with no envelope, and this task's brief asks for +adaptation, not a rewrite of an error type nothing about this task requires +changing. +""" + +from __future__ import annotations + +from pathlib import Path + +from .. import store + + +class FileCredentialStore: + """`store.resolve_credentials`, bound to one password and directory.""" + + def __init__(self, password: str, *, directory: Path | None = None) -> None: + self._password = password + self._directory = directory + + def resolve(self, profile: str) -> dict[str, str]: + return store.resolve_credentials(profile, self._password, directory=self._directory) diff --git a/src/stackward/store.py b/src/stackward/store.py new file mode 100644 index 0000000..88f4ce7 --- /dev/null +++ b/src/stackward/store.py @@ -0,0 +1,782 @@ +"""The credential store: which backend, whose credentials, and where they live. + +A profile is a Pulumi state backend plus the credentials that unlock it. That +pairing is split across two files in `${XDG_CONFIG_HOME:-~/.config}/stackward/`: + + config TOML, mode 0600 — backend identity + credentials JSON, mode 0600 — one sealed envelope per profile + +The split is what makes `config` diffable, reviewable and safe to reason about +without decrypting anything: it says *which* bucket, endpoint and region a +profile names, and a user checking what a profile points at never has to open +the file that would show them a key if they mistyped a command. + +**`config` is not, however, credential-free, and must not be treated as such.** +It once carried mode 0644 on the stated grounds that it "holds backend identity +and never a credential". That claim was wrong, and in the one place it mattered +most: `pulumi login --help` documents `postgres://user:password@host/db` as a +supported backend form, `Profile.backend_url` passes a URL through verbatim, +and `commands.session` exists partly to redact exactly that password wherever +it prints one. A supported, documented configuration therefore put a live +credential in a world-readable file. Both files are 0600 now, and `config` is +covered by the same permissiveness warning `credentials` always was. A +`backend_url` carrying userinfo is still accepted — refusing it would make a +Pulumi-supported backend unusable through this tool, and there is nowhere else +to put it, since `credentials` holds only the sealed name/value envelopes the +session commands inject — but it is a credential, in a file whose mode and +error messages now assume so. + +**Everything else in this module follows from three refusals.** + +*Never guess a backend.* Selection runs `--profile`, then `STACKWARD_PROFILE`, +then the repository's own `profile =`, then `default_profile`, and then it +errors. There is no built-in default and no fallback: a tool that guesses which +backend it is pointed at can publish state, or a credential, to the wrong place. +A name that comes from a source but does not exist in `config` is an error too — +it never falls through to the next source, because "the profile you asked for is +missing" and "you asked for no profile" are different problems with different +fixes. + +*Never write a credential outside an envelope.* Values arrive as a mapping, go +into `crypto.seal_json`, and come back out only through `resolve_credentials`. +This module has no code path that prints one, logs one, or puts one in an +exception message; error text names profiles, files and key names only. + +*Never leave a half-written store.* Every write goes through `atomic_write`: +a temporary file in the same directory, `fsync`, the mode, then `rename`. The +mode is set before the rename and not after, because "after" is a window in +which the finished file exists at its real path with the wrong permissions. +Password rotation builds the entire re-sealed document in memory first and then +performs exactly one such write, so it re-seals every envelope or none — a +rotation that failed halfway through would leave a store whose profiles need two +different passwords, and no way to tell which is which. + +There is deliberately no CLI command here. This task's brief lists this module, +`crypto.py` and their tests; the commands that drive them (`login`, `exec`, +`credentials`) arrive with the tasks that own them, on top of this API. +""" + +from __future__ import annotations + +import json +import os +import re +import stat +import sys +import tempfile +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from .config import toml_position +from .crypto import ( + CryptoError, + DecryptionError, + EmptyPasswordError, + describe_value, + seal_json, + unseal_json, +) + +CONFIG_FILENAME = "config" +CREDENTIALS_FILENAME = "credentials" + +DIR_MODE = 0o700 +# 0600, not 0644: a `backend_url` of the documented `postgres://user:password@ +# host/db` form puts a live credential in `config`, so it gets the same +# owner-only mode `credentials` has. See the module docstring. +CONFIG_MODE = 0o600 +CREDENTIALS_MODE = 0o600 + +ENV_PROFILE = "STACKWARD_PROFILE" + +STORE_VERSION = 1 + +# The verifier is a store-level envelope holding a known plaintext, written when +# the store is initialised and opened before anything else. Without it a +# mistyped password at `credentials init` produces a store that looks fine and +# fails at first use, possibly weeks later and on a different machine; with it, +# a wrong password is reported as a wrong password, immediately, and is never +# confused with a corrupt profile envelope. +VERIFIER_KEY = "verifier" +VERIFIER_AAD = "stackward:verifier" +VERIFIER_PLAINTEXT = "stackward credential store" + +# A profile name is used verbatim as the AAD of its envelope, so the set of +# valid names must not contain the verifier's AAD — otherwise a profile could be +# created whose envelope is interchangeable with the verifier's. Excluding ':' +# from the charset is what keeps the two namespaces disjoint; it is a mechanism, +# not a convention, and `test_store.py` proves it by trying the collision. +# +# '.' is excluded too, for a different reason: TOML reads a dot in a table +# header as nesting, so `[profile.a.b]` declares a table `b` inside a table `a` +# and not a profile named `a.b`. Allowing dotted names would mean a name that +# has two spellings — one of which silently parses as something else — for no +# benefit. What remains is a strict subset of the TOML bare-key charset — TOML +# would also allow a leading '_' or '-', which this does not — so every valid +# name is spellable as `[profile.]` with no quoting and no ambiguity, +# but not every bare TOML key is a valid profile name. +_PROFILE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$") + +_TOP_LEVEL_KEYS = frozenset({"default_profile", "profile"}) +_PROFILE_KEYS = frozenset( + {"backend_url", "bucket", "prefix", "endpoint", "region", "credentials"} +) +_COMPONENT_KEYS = ("bucket", "prefix", "endpoint", "region") + + +class StoreError(Exception): + """Anything that stops the store from answering. Callers print these, so no + subclass ever interpolates a credential value into its message.""" + + +class ProfileError(StoreError): + """No profile could be selected, or the selected one does not exist. + + Separate from `StoreError` so a command can tell "you have not told me which + backend to use" — which a user fixes with `--profile` or a config edit — + apart from "the store itself is unusable".""" + + +class PasswordError(StoreError): + """The store password is unusable: absent, or wrong. + + Both mean "the password given cannot open this store", which is a single + problem to whoever reads it. Kept distinct from a profile envelope's + `DecryptionError`, which is a different one: this says "retype your + password", while a failure on a profile envelope *after* the verifier opened + says the store itself is damaged or was tampered with.""" + + +@dataclass(frozen=True) +class Profile: + """One `[profile.]` table: a backend, in one of the two forms. + + `backend_url` is passed through **verbatim** and is never parsed, + normalised or reassembled by this tool — that is what lets `s3://`, + `gs://`, `azblob://` and `file://` all work without this module knowing + anything about any of them, and what keeps a scheme nobody has thought of + yet working too. + + The component form (`bucket`/`prefix`/`endpoint`/`region`) describes an + S3-compatible store in generic terms for users who would rather not + hand-assemble a URL. Composing those components *into* a URL is + deliberately not done here: the query-parameter spelling a Pulumi backend + expects is knowledge about Pulumi, not about profiles, and it belongs with + the command that runs `pulumi login`. This module carries the components as + structured data and stops there. + + `credentials` is the raw, otherwise-unvalidated `[profile.. + credentials]` table -- `provider` (a string, defaulting to `"file"` when + the table or the key is absent) and whatever else a chosen provider + needs (a remote provider's `command`, say). This module validates only + that the table exists and that `provider`, if present, is a string; it + does not know which providers exist any more than `config.py`'s + `_build_secrets` knows what `[secrets.*]` means -- that is + `providers/__init__.py`'s vocabulary, not this one's, and this module + does not import it (see that package's own module docstring for why). + """ + + name: str + backend_url: str | None = None + bucket: str | None = None + prefix: str | None = None + endpoint: str | None = None + region: str | None = None + credentials: Mapping[str, Any] = field(default_factory=dict) + + @property + def has_components(self) -> bool: + return any(getattr(self, key) is not None for key in _COMPONENT_KEYS) + + +@dataclass(frozen=True) +class StoreConfig: + """A parsed `config` file: the default profile, and the profiles.""" + + default_profile: str | None = None + profiles: Mapping[str, Profile] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Locations and file handling +# --------------------------------------------------------------------------- + + +def store_dir(directory: Path | None = None) -> Path: + """The store directory: `directory` when given, otherwise `cli.config_home()`. + + Named `directory`, not `home`: this is the store directory itself, the + thing `config_home()` returns with `/stackward` already appended. A + parameter called `home` invites a caller to pass a config home or + `Path.home()` and silently get `/credentials`. + + `cli` is imported inside the function rather than at module scope. The + commands that will drive this store live under `commands/`, which `cli` + imports at *its* module scope — so a module-level `from .cli import + config_home` here would close a cycle (`cli` → `commands.x` → `store` → + `cli`) the moment such a command exists, and would fail on a name `cli` has + not defined yet at that point in its own execution. `config.py` avoids the + same cycle the same way; see `enforce_min_version` there. + """ + if directory is not None: + return directory + from .cli import config_home # local: breaks an import cycle + + return config_home() + + +def config_path(directory: Path | None = None) -> Path: + return store_dir(directory) / CONFIG_FILENAME + + +def credentials_path(directory: Path | None = None) -> Path: + return store_dir(directory) / CREDENTIALS_FILENAME + + +def warn_if_permissive(path: Path, allowed: int) -> None: + """Warn on stderr when `path` grants more than `allowed`. + + A warning and not an error: refusing to read a store because a `umask` or a + restored backup widened a mode would lock a user out of their own + credentials, and the value has already been exposed by then — refusing + afterwards protects nothing. The warning goes to stderr so it cannot + contaminate a command's stdout, which callers parse. + """ + try: + mode = stat.S_IMODE(path.stat().st_mode) + except OSError: + return # absent or unreadable: the caller's own read reports it properly + extra = mode & ~allowed + if extra: + print( + f"warning: {path} has mode {mode:04o}, which is more permissive " + f"than {allowed:04o}", + file=sys.stderr, + ) + + +def ensure_store_dir(directory: Path | None = None) -> Path: + """Create the store directory if absent, and make sure it is 0700. + + `chmod` runs unconditionally rather than relying on `mkdir(mode=...)`, + whose result the process `umask` modifies — a `umask` of 0 would otherwise + leave a world-readable directory holding a credentials file. + """ + resolved = store_dir(directory) + try: + resolved.mkdir(parents=True, exist_ok=True) + os.chmod(resolved, DIR_MODE) + except OSError as exc: + raise StoreError(f"{resolved}: cannot create store directory: {exc}") from exc + return resolved + + +def atomic_write(path: Path, data: bytes, mode: int) -> None: + """Replace `path` with `data`, atomically, at exactly `mode`. + + A crash at any point leaves either the old file or the new one, never a + truncated one: the content is written to a temporary file in the *same* + directory (so the final step is a rename within one filesystem, which is + atomic — across filesystems it would be a copy, and a copy can be + interrupted), flushed and `fsync`ed so the bytes are on the medium before + anything points at them, and only then renamed into place. + + The mode is set on the file descriptor before the rename. Doing it after + would leave a window in which the completed file is visible at its real + path under whatever `mkstemp` and the `umask` produced. `mkstemp` already + creates at 0600, so that window would not currently be world-readable — but + the ordering is what makes that true regardless of the mode being asked for, + and it is the ordering, not the accident, that the next reader should rely + on. + + On any failure the temporary file is removed, so a failed write leaves + neither a damaged target nor debris beside it. + """ + directory = path.parent + handle, tmp_name = tempfile.mkstemp( + dir=directory, prefix=f".{path.name}.", suffix=".tmp" + ) + tmp = Path(tmp_name) + try: + with os.fdopen(handle, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), mode) + os.replace(tmp, path) + except BaseException: + # BaseException, not Exception: a KeyboardInterrupt between mkstemp and + # rename would otherwise leave a temporary file holding sealed + # credentials in the store directory forever. + tmp.unlink(missing_ok=True) + raise + + try: + # Make the rename itself durable. Best effort: not every platform + # supports opening a directory for fsync, and by this point the data is + # already in place — failing here would report an error for a write + # that succeeded. + fd = os.open(directory, os.O_RDONLY) + try: + os.fsync(fd) + finally: + os.close(fd) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# `config` — backend identity +# --------------------------------------------------------------------------- + + +def validate_profile_name(name: str, source: str) -> str: + """Reject a name that is not spellable as a bare TOML key or that could + collide with a reserved AAD. `source` names where the value came from, so + the message points at the thing to edit.""" + if not name: + raise ProfileError(f"{source}: profile name is empty") + if not _PROFILE_NAME.match(name): + raise ProfileError( + f"{source}: invalid profile name {name!r}; names may contain " + "letters, digits, '_' and '-', and must start with a letter or digit" + ) + return name + + +def _require_str(table: Mapping[str, Any], key: str, where: str) -> str | None: + if key not in table: + return None + value = table[key] + if not isinstance(value, str): + raise StoreError(f"{where}.{key} must be a string") + return value + + +def _build_credentials_table(raw: Any, where: str) -> Mapping[str, Any]: + """Shape only: a table, with `provider` a string if present at all. + + Everything else in the table (a remote provider's `command`, say) is + passed through unexamined, for the same reason `config._build_secrets` + defers `[secrets.*]`'s interior to the module that knows its semantics: + this one does not know which providers exist, and inventing a whitelist + here would either reject a provider this module has never heard of or + have to be kept in step with `providers/__init__.py` by hand. + """ + if raw is None: + return {} + if not isinstance(raw, dict): + raise StoreError(f"{where}.credentials must be a table") + provider = raw.get("provider") + if provider is not None and not isinstance(provider, str): + raise StoreError(f"{where}.credentials.provider must be a string") + return raw + + +def _build_profile(name: str, raw: Any) -> Profile: + if not isinstance(raw, dict): + raise StoreError(f"profile.{name} must be a table") + where = f"profile.{name}" + + unknown = set(raw) - _PROFILE_KEYS + if unknown: + raise StoreError(f"unknown key {where}.{sorted(unknown)[0]}") + + profile = Profile( + name=name, + backend_url=_require_str(raw, "backend_url", where), + bucket=_require_str(raw, "bucket", where), + prefix=_require_str(raw, "prefix", where), + endpoint=_require_str(raw, "endpoint", where), + region=_require_str(raw, "region", where), + credentials=_build_credentials_table(raw.get("credentials"), where), + ) + + # Exactly one form. Both would leave two answers to "which backend?" with + # no rule for choosing; neither leaves none, and there is no default to fall + # back on. + if profile.backend_url is not None and profile.has_components: + raise StoreError( + f"{where}: set either backend_url or the component form " + "(bucket/prefix/endpoint/region), not both" + ) + if profile.backend_url is None and not profile.has_components: + raise StoreError( + f"{where}: needs a backend — either backend_url or the component " + "form (bucket/prefix/endpoint/region)" + ) + if profile.backend_url is None and profile.bucket is None: + raise StoreError(f"{where}: the component form requires bucket") + return profile + + +def load_store_config(directory: Path | None = None) -> StoreConfig: + """Parse `config`, or return an empty one when the file does not exist. + + Absent is a normal state for a user who has not set the tool up yet; the + profile that cannot then be selected is reported by `select_profile`, which + can say what to do about it. Present-but-invalid raises — a `config` with a + typo must never be treated as though it were absent, since that would turn + a broken profile into a silently missing one. + """ + path = config_path(directory) + if not path.exists(): + return StoreConfig() + + warn_if_permissive(path, CONFIG_MODE) + try: + with path.open("rb") as handle: + data = tomllib.load(handle) + except OSError as exc: + raise StoreError(f"{path}: cannot read: {exc}") from exc + except tomllib.TOMLDecodeError as exc: + # Position only, never the parser's message — see + # `config.toml_position`, which is where that judgement lives for + # both this file and `.stackward.toml`. + raise StoreError(f"{path}: invalid TOML{toml_position(exc)}") from exc + + try: + return _build_store_config(data) + except StoreError as exc: + raise StoreError(f"{path}: {exc}") from exc + + +def _build_store_config(data: Mapping[str, Any]) -> StoreConfig: + unknown = set(data) - _TOP_LEVEL_KEYS + if unknown: + raise StoreError(f"unknown key {sorted(unknown)[0]!r}") + + default_profile = _require_str(data, "default_profile", "config") + if default_profile is not None: + validate_profile_name(default_profile, "default_profile") + + raw_profiles = data.get("profile", {}) + if not isinstance(raw_profiles, dict): + raise StoreError("'profile' must be a table of tables") + + profiles: dict[str, Profile] = {} + for name, raw in raw_profiles.items(): + validate_profile_name(name, "config") + profiles[name] = _build_profile(name, raw) + + if default_profile is not None and default_profile not in profiles: + raise StoreError( + f"default_profile is {default_profile!r}, which has no " + f"[profile.{default_profile}] table" + ) + return StoreConfig(default_profile=default_profile, profiles=profiles) + + +# --------------------------------------------------------------------------- +# Profile selection +# --------------------------------------------------------------------------- + + +def select_profile( + explicit: str | None = None, + *, + config: StoreConfig, + repo_profile: str | None = None, + environ: Mapping[str, str] | None = None, +) -> Profile: + """Resolve which profile to use. First source that *speaks* wins. + + `--profile`, then `STACKWARD_PROFILE`, then the repository's own `profile =` + (the normal case — a repository's stacks live in one backend, which is a + fact about the repository and not about whoever's shell is running), then + `default_profile`, then an error naming every source that could have + answered. + + "Speaks" means *present*, not *valid*. A source that names a profile which + does not exist is an error, not a miss: falling through to the next source + would run the command against a different backend than the one asked for, + which is the single worst thing this tool could do quietly. An empty value + is likewise an error rather than a miss — `STACKWARD_PROFILE=""` in a CI + environment is a broken variable, not an absent one, and treating it as + absent would silently substitute the default. + """ + environ = os.environ if environ is None else environ + + sources: list[tuple[str, str | None]] = [ + ("--profile", explicit), + (ENV_PROFILE, environ.get(ENV_PROFILE)), + ("the repository's .stackward.toml 'profile'", repo_profile), + ("default_profile in the store config", config.default_profile), + ] + + for source, value in sources: + if value is None: + continue + name = validate_profile_name(value, source) + profile = config.profiles.get(name) + if profile is None: + raise ProfileError( + f"{source} selects profile {name!r}, which has no " + f"[profile.{name}] table in {CONFIG_FILENAME}" + ) + return profile + + raise ProfileError( + "no profile selected and there is no default: pass --profile, set " + f"{ENV_PROFILE}, set 'profile' in the repository's .stackward.toml, or " + f"set default_profile in {CONFIG_FILENAME}" + ) + + +# --------------------------------------------------------------------------- +# `credentials` — the sealed envelopes +# --------------------------------------------------------------------------- + + +def _read_document(directory: Path | None = None) -> dict[str, Any]: + path = credentials_path(directory) + warn_if_permissive(path, CREDENTIALS_MODE) + try: + raw = path.read_bytes() + except FileNotFoundError as exc: + raise StoreError( + f"{path}: no credential store here yet — run " + "`stackward credentials init`" + ) from exc + except OSError as exc: + raise StoreError(f"{path}: cannot read: {exc}") from exc + + try: + document = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + # Only the failure type, never the parser's message: the message quotes + # the offending text, and this is the one file that holds ciphertext + # the user may have pasted a plaintext into by mistake. + raise StoreError( + f"{path}: not a valid credential store ({type(exc).__name__})" + ) from exc + + if not isinstance(document, dict): + raise StoreError(f"{path}: not a valid credential store") + if document.get("v") != STORE_VERSION: + # `_describe`, not the raw value: this field is hand-editable and lives + # in the one file expected to hold secrets, so a credential pasted into + # it must not be read back out by the error that caught the mistake. + raise StoreError( + f"{path}: unsupported store version {describe_value(document.get('v'))} " + f"(this build reads {STORE_VERSION})" + ) + if not isinstance(document.get("profiles"), dict): + raise StoreError(f"{path}: not a valid credential store") + return document + + +def _write_document(document: Mapping[str, Any], directory: Path | None = None) -> None: + ensure_store_dir(directory) + payload = json.dumps(document, indent=2, sort_keys=True).encode("utf-8") + b"\n" + atomic_write(credentials_path(directory), payload, CREDENTIALS_MODE) + + +def _require_password(password: str, what: str = "a password") -> None: + """Refuse an absent password, as a `PasswordError` naming which one. + + `crypto._normalise` is the real guarantee — it sits under every seal and + every open, so no path can miss it. This exists on top of it only to phrase + the refusal in the store's own vocabulary and to say *which* password was + missing when a call takes two, which the crypto layer cannot know. + """ + if not password.strip(): + raise PasswordError(f"{what} is required") + + +def _check_password(document: Mapping[str, Any], password: str) -> None: + """Open the verifier, and fail with `PasswordError` if it does not. + + Everything that touches an envelope goes through here first, so a wrong + password is always reported as a wrong password — before any profile + envelope is attempted, and therefore before a decryption failure could be + mistaken for a damaged store. + """ + _require_password(password) + envelope = document.get(VERIFIER_KEY) + if envelope is None: + raise StoreError( + "credential store has no verifier: it was not written by this tool, " + "or it is damaged" + ) + try: + plaintext = unseal_json(envelope, password, VERIFIER_AAD) + except DecryptionError as exc: + raise PasswordError("wrong password for the credential store") from exc + except EmptyPasswordError as exc: + # Unreachable while `_require_password` above stands, and mapped anyway: + # `EmptyPasswordError` is a `CryptoError`, so the clause below would + # otherwise recode "no password" as "the verifier is unusable". + raise PasswordError("a password is required") from exc + except CryptoError as exc: + raise StoreError(f"credential store verifier is unusable: {exc}") from exc + + if plaintext != VERIFIER_PLAINTEXT: + # The GCM tag already proves the plaintext is the one sealed under this + # password and AAD, so this compares a value nothing else could have + # produced. It is kept because it is what makes the verifier a + # *known-plaintext* check rather than an implicit property of the tag, + # and because a future format change that broke the invariant would + # otherwise pass silently. + raise StoreError("credential store verifier did not match") + + +def _require_credentials(payload: Any, profile: str) -> dict[str, str]: + """A decrypted payload must be a flat mapping of names to strings. + + This is the one place in this module where decrypted credential values are + in scope at the moment an exception is raised, so the message below + interpolates only the profile name and a fixed string. + + Do not "improve" it by naming the offending entry. A key here would + ordinarily be an environment variable name and harmless — but the payload + is whatever was sealed, and a mapping built the wrong way round + (`{value: name}`) puts a credential in the key position. The safety of this + branch rests on interpolating neither half of an entry, not on an + assumption about which half is sensitive. + """ + if not isinstance(payload, dict): + raise StoreError(f"credentials for profile {profile!r} are malformed") + result: dict[str, str] = {} + for key, value in payload.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise StoreError( + f"credentials for profile {profile!r}: every entry must be a " + "string name with a string value" + ) + result[key] = value + return result + + +def init_store(password: str, *, directory: Path | None = None) -> None: + """Create the store directory and an empty `credentials` holding a verifier. + + Refuses to overwrite an existing store: doing so would discard every sealed + envelope in it, irrecoverably, in response to a command a user could plausibly + run twice. + """ + _require_password(password) + ensure_store_dir(directory) + path = credentials_path(directory) + if path.exists(): + raise StoreError(f"{path}: a credential store already exists here") + _write_document( + { + "v": STORE_VERSION, + VERIFIER_KEY: seal_json(VERIFIER_PLAINTEXT, password, VERIFIER_AAD), + "profiles": {}, + }, + directory, + ) + + +def store_profiles(directory: Path | None = None) -> list[str]: + """Names that have a sealed envelope, sorted. Reads no password and opens + nothing, so it is safe to call for a listing.""" + return sorted(_read_document(directory)["profiles"]) + + +def set_credentials( + profile: str, + credentials: Mapping[str, str], + password: str, + *, + directory: Path | None = None, +) -> None: + """Seal `credentials` as `profile`'s envelope, replacing any existing one. + + The password is checked against the verifier first, so a mistyped password + cannot add an envelope that nothing else in the store can open. + """ + validate_profile_name(profile, "profile") + for key, value in credentials.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise StoreError("credentials must be a mapping of strings to strings") + + document = _read_document(directory) + _check_password(document, password) + document["profiles"][profile] = seal_json(dict(credentials), password, profile) + _write_document(document, directory) + + +def resolve_credentials( + profile: str, password: str, *, directory: Path | None = None +) -> dict[str, str]: + """The bootstrap values for `profile`: names to values, decrypted. + + This is the function the `file` credential-store provider will register in + Task 11 — one name, a profile, a mapping back — so that adding an `env` or + a remote provider adapts this rather than rewriting it. It deliberately does + not know *which* names a caller wants: the environment variables a Pulumi + backend needs are the calling command's business, and naming them here would + put a vendor's vocabulary into the store. + + The password is not read from anywhere. Prompting, `getpass`, and any + environment fallback belong to the command; a library that could prompt + would be a library that can block a hook. + """ + document = _read_document(directory) + _check_password(document, password) + + envelope = document["profiles"].get(profile) + if envelope is None: + raise ProfileError(f"profile {profile!r} has no credentials in the store") + + try: + payload = unseal_json(envelope, password, profile) + except DecryptionError as exc: + # The verifier already opened under this password, so the password is + # right and this envelope is not the one that belongs here — the AAD + # mismatch an envelope moved between profiles produces, or a modified + # ciphertext. Either way it is damage, not a typo. + raise StoreError( + f"profile {profile!r}: sealed data does not belong to this profile, " + "or has been modified" + ) from exc + except CryptoError as exc: + raise StoreError(f"profile {profile!r}: {exc}") from exc + + return _require_credentials(payload, profile) + + +def rotate_password( + old_password: str, new_password: str, *, directory: Path | None = None +) -> None: + """Re-seal every envelope under `new_password`, or change nothing. + + The whole document is rebuilt in memory and written once, so a failure at + any point — a damaged envelope, an interrupted process, a full disk — + leaves the store exactly as it was, still opening under the old password. + A partial rotation would be the worst outcome available: a store whose + profiles need different passwords, with nothing recording which is which. + + Every envelope *in the credentials file* is re-sealed, not every profile in + `config`. An envelope whose `[profile.]` table has been removed is + unreachable but not gone, and rotating past it would quietly destroy it. + """ + _require_password(new_password, "the new password") + document = _read_document(directory) + _check_password(document, old_password) + + rotated: dict[str, Any] = {} + for name, envelope in document["profiles"].items(): + try: + payload = unseal_json(envelope, old_password, name) + except CryptoError as exc: + raise StoreError( + f"profile {name!r}: cannot re-seal, its envelope did not open " + f"({type(exc).__name__}); nothing has been changed" + ) from exc + rotated[name] = seal_json(payload, new_password, name) + + # Replace only the two fields this operation owns and keep the rest of the + # document as it was read. Rebuilding from a literal would silently drop any + # other top-level field, and would leave the two write paths disagreeing + # about what a document is — `set_credentials` preserves the whole thing, + # and rotation would be the one discarding. + document[VERIFIER_KEY] = seal_json(VERIFIER_PLAINTEXT, new_password, VERIFIER_AAD) + document["profiles"] = rotated + _write_document(document, directory) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b117aec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,115 @@ +"""Fixtures shared by the tests that drive a real `git`. + +Two of them, for two reasons that are not the same. `isolated_git` (below) +belongs here because four files need it and a judgement about which git +configuration a test may see must not exist in four copies. This one +belongs here because the rule about when a missing console script is a +skip and when it is a failure is a judgement that must not drift either. + +Two test files need the *installed* `stackward` console script — the one +that proves a git hook really runs, rather than that a file landed at the +expected path. The rule about when its absence is a skip and when it is a +failure is a judgement that must not exist in two places and drift: a second +copy that forgot the `CI` branch would let a CI run go green having lost +exactly the coverage these tests are for. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest + +from stackward.commands import install_hooks as install_hooks_module + + +def real_stackward_executable() -> str: + """The actual installed `stackward` console script for this test + interpreter -- not a stub. + + Used only by the end-to-end tests, which prove the generated hook body + really gets invoked by a real `git commit`, not merely that a file landed + at the expected path: a file at the right path that git never actually + runs is precisely the "silent no-op" the hooks-directory resolution exists + to prevent, and asserting on the file alone cannot catch that failure + mode. + + Skipping when nothing is found keeps a local run convenient (an + interpreter not launched through this project's own `uv`-managed venv + genuinely may not have one), but `uv sync` always produces + `.venv/bin/stackward`, so CI must never quietly go green having lost + exactly the coverage the real-git-repository approach leans on hardest. + Skip locally; fail loudly under `CI`. + """ + candidate = Path(sys.executable).parent / "stackward" + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + found = shutil.which("stackward") + if found: + return found + message = "no installed `stackward` console script found for an end-to-end test" + if os.environ.get("CI"): + pytest.fail(f"{message} -- `uv sync` should have installed one") + pytest.skip(message) + + +@pytest.fixture +def real_executable(monkeypatch) -> str: + """Make `_install_hook` embed the real `stackward` console script instead + of whatever `sys.argv[0]` happens to be under pytest (its own + interpreter) -- see `real_stackward_executable`.""" + executable = real_stackward_executable() + monkeypatch.setattr(install_hooks_module, "_running_executable", lambda: executable) + return executable + + +@pytest.fixture +def isolated_git(tmp_path: Path, monkeypatch) -> Path: + """Cut every `git` a test runs off from the machine's own git config. + + Without this, a developer (or a CI image) with a *global* + `core.hooksPath` -- what husky, lefthook and the `pre-commit` framework + all set, and precisely the configuration `hooks install` exists to + handle -- makes `git init`/`git commit` inherit that hook, and dozens of + tests across four files error out for a reason that has nothing to do + with the code under test. A suite whose result depends on the + developer's `~/.gitconfig` is not testing what it claims to. + + Two consequences are worse than a confusing error, and are why this is + a fixture every git-touching test takes rather than a convention: + + * A global `core.hooksPath` silently moves where `hooks install` + *writes*, so tests asserting on `.git/hooks/pre-commit` would be + examining a directory the command was never pointed at. + * It also moves where `hooks install` writes to a directory **outside + `tmp_path`**. Reproduced on this branch: with a global `core.hooksPath` + set, `tests/test_model_net.py` installed a stackward pre-commit hook + into the developer's own global hooks directory, where it stayed after + the run and then ran against every unrelated repository on the + machine. That is an escape, not merely a failure. + + All four sources git consults are redirected, not only the global one: + `GIT_CONFIG_SYSTEM` covers `/etc/gitconfig`, which is exactly where an + org-wide `hooksPath` lives; `HOME` and `XDG_CONFIG_HOME` cover + `~/.gitconfig` and `$XDG_CONFIG_HOME/git/config`, which git still reads + on a path that leaves `GIT_CONFIG_GLOBAL` unset. `/dev/null` is git's + own documented spelling of "this configuration file does not exist" for + the two `GIT_CONFIG_*` variables. + + The redirect is set with `monkeypatch`, so it is still in place while + the *test body* runs `git commit` -- not only while the fixture built + the repository. It lives here, in `conftest.py`, because it was + previously two identical copies in two files and two other files had + none; a third copy is how the two that had it drift apart, and having + none is what let the escape above happen. + """ + home = tmp_path / "git-home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + return home diff --git a/tests/leakcheck.py b/tests/leakcheck.py new file mode 100644 index 0000000..d6bc57b --- /dev/null +++ b/tests/leakcheck.py @@ -0,0 +1,109 @@ +"""One assertion for "this text does not disclose that credential". + +Every leak test on this branch was written as `assert value not in text`. +The final review showed what that idiom cannot see: `{value!r}` is caught +(repr embeds the whole string), but `value[:8]` in an error message, or the +value base64-encoded into a payload, passes a whole-string `in` check while +disclosing the credential just as completely. A partial disclosure is still +a disclosure -- an attacker who learns the first eight characters of a +passphrase has had the search space cut by orders of magnitude. + +So the check is: the value, any run of it long enough to be identifying, +and the encodings it plausibly passes through on its way to an output +stream. + +**Choose opaque markers.** The partial-run check compares against the text +a command legitimately prints, and a marker built out of real words will +collide with it. `marker-db-password-1a2b3c4d` shares the run "password" +with the config path `db.password` that the same test asserts is reported, +so the helper fires on output that discloses nothing. That is the helper +working, not a bug in it -- a credential really can be recovered a run at a +time, and the checker cannot know which run was a coincidence. Give the +marker no substring in common with any path, flag or message under test, +and put the reason in the variable name rather than in the value. +""" + +from __future__ import annotations + +import base64 +import urllib.parse + +# Shorter than this and a "leak" is more likely a coincidental collision +# with ordinary output than a disclosure -- a 4-character run of a +# hex-ish credential appears in unrelated text often enough to make the +# assertion flaky, which is how a leak check gets deleted. +MIN_IDENTIFYING_RUN = 8 + +# A *truncated* encoding is still a disclosure, and the whole-string check +# above cannot see one: `value.encode("utf-8").hex()[:16]` contains neither +# the raw value nor the complete hex encoding, yet `bytes.fromhex` turns it +# straight back into the first eight characters of the credential. That is +# not hypothetical -- it is the exact shape of the passphrase-fingerprint +# defect this branch shipped and the tautological test that hid it. +# +# So the run check applies to the byte-aligned encodings too, at run +# lengths that each recover about the same amount of plaintext as +# MIN_IDENTIFYING_RUN does. Runs are NOT checked for the URL-quoted forms: +# percent-encoding is variable-width, so a run boundary can land mid-escape +# and the recovered text is not what the run implies. +RUN_LENGTHS = { + "raw": MIN_IDENTIFYING_RUN, + "hex": MIN_IDENTIFYING_RUN * 2, # 2 hex characters per byte + "base64": ((MIN_IDENTIFYING_RUN + 2) // 3) * 4, # 4 characters per 3 bytes +} + + +def _encodings(value: str) -> dict[str, str]: + """The forms a credential can reach an output stream in. + + Not exhaustive -- it cannot be. These are the ones this codebase + actually produces: raw, what `json`/`repr` emit, what a URL carrying + userinfo emits, and what a serialised envelope emits. + """ + raw = value.encode("utf-8") + return { + "raw": value, + "base64": base64.b64encode(raw).decode("ascii"), + "base64-unpadded": base64.b64encode(raw).decode("ascii").rstrip("="), + "hex": raw.hex(), + "urlquote": urllib.parse.quote(value, safe=""), + "urlquote-plus": urllib.parse.quote_plus(value), + } + + +def assert_no_leak(text: str, value: str, *, what: str = "value") -> None: + """Fail if `text` discloses `value` whole, in part, or re-encoded. + + `what` names the thing in the failure message. The message never + contains `value` itself -- a leak check that prints the credential to + prove the credential leaked would be its own bug (Global Constraint 4). + It reports the encoding and the offset, which is enough to find it. + """ + if not value: + raise ValueError( + "assert_no_leak called with an empty value: it would pass " + "against any text and prove nothing" + ) + + for name, encoded in _encodings(value).items(): + if encoded and encoded in text: + raise AssertionError( + f"{what} leaked into output, {name}-encoded, in full " + f"(at offset {text.index(encoded)})" + ) + + # Partial disclosure: an identifying-length run of the value, or of an + # encoding a reader can decode a fragment of. + encodings = _encodings(value) + for name, run_length in RUN_LENGTHS.items(): + candidate = encodings[name] + for start in range(0, max(1, len(candidate) - run_length + 1)): + run = candidate[start : start + run_length] + if len(run) < run_length: + break + if run in text: + raise AssertionError( + f"{what} partially leaked into output: a {run_length}-" + f"character run of its {name} form, starting at index " + f"{start}, appears at offset {text.index(run)}" + ) diff --git a/tests/test_check_passphrase.py b/tests/test_check_passphrase.py new file mode 100644 index 0000000..f1dc906 --- /dev/null +++ b/tests/test_check_passphrase.py @@ -0,0 +1,687 @@ +"""Tests for `check-passphrase`. + +**The interoperability proof is a frozen, real `pulumi`-generated vector, not +a round trip against this module's own code.** `REAL_PULUMI_SALT_FIELD` +below was captured once from an actual `pulumi stack export`, against a +throwaway local (`file://`) backend created solely to produce it -- a +placeholder project, stack, and passphrase, per GC4, none of it real. A test +that only encrypted and then decrypted with this module's own functions +would prove nothing beyond internal self-consistency: if this module's +understanding of the byte layout, the base64 variant, or the KDF parameters +were wrong in some way that happened to be wrong *consistently*, such a test +would still pass. `test_check_salt_accepts_a_real_pulumi_generated_vector` +below cannot pass that way -- it can only pass if this module's decryption +genuinely matches what Pulumi itself produced. + +A second, independently-written PBKDF2+AES-GCM helper +(`_encrypt_pulumi_style`) is used for the malformed-input and +wrong-passphrase fixtures a fixed real vector cannot conveniently produce on +demand -- it calls `cryptography` primitives directly and never reuses this +module's own `_derive_key`/`check_salt`, so it does not share a bug with the +code it is testing. + +**No test prints the passphrase, at the Python level or the real child +process's own stdout/stderr.** Assertions about absence are made against +`capsys`-captured output for pure-Python paths and `capfd`-captured output +(file-descriptor level, covering the real stub `pulumi` child process's own +writes) for anything that goes through `main()`. + +**Several tests exist to fail against a plausible wrong implementation, not +merely to exercise a right one** -- most importantly the too-short-ciphertext +and wrong-length-nonce tests: `cryptography`'s `AESGCM.decrypt` raises +`InvalidTag`, not `ValueError`, for both, so an implementation that skips the +explicit byte-length checks would silently report "rejected" instead of the +error this is required to be. Each is written to fail on such an +implementation and to pass only once the explicit checks are present. +""" + +from __future__ import annotations + +import base64 +import json +import os +import sys +import textwrap +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import hashes + +from leakcheck import assert_no_leak +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from stackward.cli import main +from stackward.commands import check_passphrase +from stackward.commands.check_passphrase import ( + CheckPassphraseError, + check_salt, + fingerprint, + _extract_salt, + _read_passphrase, +) + +# Placeholder values only -- see GC4. Distinctive enough that an accidental +# substring match elsewhere in captured output would be implausible, and +# deliberately free of any word this command itself prints: the leak checks +# below look for any eight-character run of these values, and a placeholder +# spelled `marker-passphrase-...` collides with the command's own +# `passphrase fingerprint:` line on the run `passphra`. A placeholder that +# embeds the tool's vocabulary makes a partial-disclosure check unusable, +# which is how such a check ends up deleted. +MARKER_PASSPHRASE = "marker-candidate-7c8d9e0f" +WRONG_PASSPHRASE = "decoy-input-1a2b3c4d5e6f" + +# --------------------------------------------------------------------------- +# A real vector, captured once from an actual `pulumi stack export` against +# a throwaway local backend -- see the module docstring. Reproduced here: +# +# $ pulumi login file:///tmp/scratch-backend +# $ pulumi stack init placeholder-stack --non-interactive +# $ PULUMI_CONFIG_PASSPHRASE=placeholder-passphrase-marker \ +# pulumi config set --secret placeholder.key placeholder-value +# $ pulumi stack export +# -> deployment.secrets_providers.state.salt +# --------------------------------------------------------------------------- +REAL_PULUMI_SALT_FIELD = "v1:qs/zMEaMbWE=:v1:iMv8Ne7bNxnv6rkP:vGQfE+/D8kOTOH8eT1DTPj68sddAoA==" +REAL_PULUMI_PASSPHRASE = "placeholder-passphrase-marker" + + +def assert_no_disclosure(text: str, value: str, *, what: str) -> None: + """`assert_no_leak`, plus the one case it cannot see on its own here: a + **truncated** re-encoding of the value. + + `assert_no_leak` checks each whole encoding (so `value.hex()` in full is + caught) and every eight-character run of the *raw* value. This command + prints a fixed-length value derived from the passphrase, so the shape + that matters is neither: `passphrase.encode("utf-8").hex()[:16]` is a + 16-character *prefix* of the hex encoding, so the whole-encoding check + never matches it, and it shares no raw run with the passphrase because + it is hex. Verified rather than assumed -- that exact defective + implementation passes a bare `assert_no_leak(fingerprint(p), p)`. + + Handing the hex encoding in as the *value* is what closes it: the + run-based half of the check then operates on hex runs, and a truncated + hex encoding of any length above eight characters is caught. + """ + assert_no_leak(text, value, what=what) + assert_no_leak(text, value.encode("utf-8").hex(), what=f"{what}, hex-encoded") + + +def _encrypt_pulumi_style(passphrase: str, plaintext: bytes = b"pulumi") -> str: + """Build a `v1::v1::` string the same way + Pulumi's own passphrase secrets provider does -- see the module + docstring for why this is written independently of + `check_passphrase._derive_key`/`check_salt` rather than reusing them. + """ + salt = os.urandom(8) + nonce = os.urandom(12) + key = PBKDF2HMAC( + algorithm=hashes.SHA256(), length=32, salt=salt, iterations=1_000_000 + ).derive(passphrase.encode("utf-8")) + ciphertext = AESGCM(key).encrypt(nonce, plaintext, None) + b64 = lambda raw: base64.b64encode(raw).decode("ascii") # noqa: E731 + return f"v1:{b64(salt)}:v1:{b64(nonce)}:{b64(ciphertext)}" + + +def _with_field(salt_field: str, index: int, raw_bytes: bytes) -> str: + """`salt_field` with the base64 field at `index` (1=salt, 3=nonce, + 4=ciphertext) replaced by `raw_bytes`, re-encoded -- for building + deliberately malformed variants of an otherwise well-formed vector.""" + parts = salt_field.split(":") + parts[index] = base64.b64encode(raw_bytes).decode("ascii") + return ":".join(parts) + + +# --------------------------------------------------------------------------- +# The interoperability proof. +# --------------------------------------------------------------------------- + + +def test_check_salt_accepts_a_real_pulumi_generated_vector(): + """The one test that cannot pass on self-consistency alone -- see the + module docstring.""" + assert check_salt(REAL_PULUMI_SALT_FIELD, REAL_PULUMI_PASSPHRASE) is True + + +def test_check_salt_rejects_a_wrong_passphrase_against_the_real_vector(): + assert check_salt(REAL_PULUMI_SALT_FIELD, "not the right passphrase") is False + + +# --------------------------------------------------------------------------- +# The synthesised-salt proof the brief names explicitly, independent of +# whether a real `pulumi` binary is available to generate one. +# --------------------------------------------------------------------------- + + +def test_check_salt_accepts_a_synthesised_salt_the_known_passphrase_opens(): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + assert check_salt(salt_field, MARKER_PASSPHRASE) is True + + +def test_check_salt_rejects_a_wrong_passphrase_against_a_synthesised_salt(): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + assert check_salt(salt_field, WRONG_PASSPHRASE) is False + + +# --------------------------------------------------------------------------- +# Fail-closed: malformed input is an error, never "rejected" or "accepted". +# --------------------------------------------------------------------------- + + +def test_a_salt_field_missing_the_v1_prefix_errors(): + with pytest.raises(CheckPassphraseError): + check_salt("bogus:AAAA:v1:BBBB:CCCC", MARKER_PASSPHRASE) + + +def test_a_salt_field_with_the_wrong_number_of_parts_errors(): + with pytest.raises(CheckPassphraseError): + check_salt("v1:AAAA:v1:BBBB", MARKER_PASSPHRASE) + + +def test_a_salt_field_with_invalid_base64_errors(): + with pytest.raises(CheckPassphraseError): + check_salt("v1:not-valid-base64!!!:v1:BBBB:CCCC", MARKER_PASSPHRASE) + + +def test_a_non_string_salt_field_errors_rather_than_crashing(): + with pytest.raises(CheckPassphraseError): + check_salt(12345, MARKER_PASSPHRASE) # type: ignore[arg-type] + + +def test_a_wrong_length_salt_errors_rather_than_being_silently_derived_from(): + base = _encrypt_pulumi_style(MARKER_PASSPHRASE) + mutated = _with_field(base, 1, b"\x00" * 4) # real salt is 8 bytes + with pytest.raises(CheckPassphraseError): + check_salt(mutated, MARKER_PASSPHRASE) + + +def test_a_wrong_length_nonce_errors_rather_than_being_reported_as_rejected(): + """`AESGCM.decrypt` tolerates a 16-byte nonce without raising -- it just + fails the tag -- so this can only pass if `check_salt` validates the + nonce length itself before ever calling `AESGCM.decrypt`. An + implementation missing that check would report this case as "rejected" + (a wrong passphrase) instead of the malformed-input error it actually + is, which fail-closed forbids.""" + base = _encrypt_pulumi_style(MARKER_PASSPHRASE) + mutated = _with_field(base, 3, b"\x00" * 16) # real nonce is 12 bytes + with pytest.raises(CheckPassphraseError): + check_salt(mutated, MARKER_PASSPHRASE) + + +def test_a_ciphertext_shorter_than_the_gcm_tag_errors_rather_than_being_reported_as_rejected(): + """`AESGCM.decrypt` raises `InvalidTag` -- not a distinguishable error -- + for a ciphertext too short to even hold a tag, so the length must be + validated before decryption is attempted at all. Without that check this + case is indistinguishable from a wrong passphrase and would be reported + as "rejected" instead of the malformed-input error it actually is.""" + base = _encrypt_pulumi_style(MARKER_PASSPHRASE) + mutated = _with_field(base, 4, b"\x00" * 5) # shorter than the 16-byte tag + with pytest.raises(CheckPassphraseError): + check_salt(mutated, MARKER_PASSPHRASE) + + +# --------------------------------------------------------------------------- +# The fingerprint. +# --------------------------------------------------------------------------- + + +# SHA-256 of the exact UTF-8 bytes, truncated to 16 hex characters, written +# out as literals. Computed once with `hashlib` at a Python prompt and pasted; +# deliberately not recomputed inside the test from `hashlib.sha256(...)`, which +# would only prove this file and `check_passphrase` agree on how to spell the +# same call rather than pinning the digest to a known value. +FINGERPRINT_OF_X = "2d711642b726b044" +FINGERPRINT_OF_MARKER = "7e742b5d41ffb360" + + +def test_fingerprint_is_the_truncated_sha256_of_the_passphrase_bytes(): + """The digest itself, pinned against known values. + + Nothing pinned it before. "16 hex characters", "stable", "differs for + different inputs" and "does not contain the passphrase" are jointly + satisfied by `passphrase.encode("utf-8").hex()[:16]` -- which is not a + digest at all, but the passphrase's own first eight bytes, and which + `cmd_check_passphrase` prints to stdout on every run. Every one of those + four tests passed against it. This is the assertion that does not. + """ + assert fingerprint("x") == FINGERPRINT_OF_X + assert fingerprint(MARKER_PASSPHRASE) == FINGERPRINT_OF_MARKER + + +def test_fingerprint_is_16_hex_characters(): + fp = fingerprint(MARKER_PASSPHRASE) + assert len(fp) == 16 + int(fp, 16) # raises ValueError if it is not hex + + +def test_fingerprint_is_over_the_exact_bytes_and_not_a_normalised_form(): + """The module's stated rule -- the fingerprint covers the identical bytes + handed to PBKDF2, unlike `crypto.py`'s stored passwords, which are NFC + normalised because they must reproduce across sessions. Two spellings of + the same grapheme derive two different keys, so they must fingerprint + differently; a fingerprint that normalised would report one candidate + for two that behave differently, which is the one thing it exists to + prevent.""" + composed = "caf\u00e9" # e-acute as a single code point + decomposed = "cafe\u0301" # 'e' plus a combining acute + assert composed != decomposed + assert fingerprint(composed) != fingerprint(decomposed) + + +def test_fingerprint_is_stable_for_the_same_passphrase(): + assert fingerprint(MARKER_PASSPHRASE) == fingerprint(MARKER_PASSPHRASE) + + +def test_fingerprint_differs_for_different_passphrases(): + """The whole point of a fingerprint is telling two candidates apart -- + a constant or a collision-prone implementation would defeat that even + while satisfying "16 hex characters" and "stable".""" + assert fingerprint(MARKER_PASSPHRASE) != fingerprint(WRONG_PASSPHRASE) + + +def test_fingerprint_never_discloses_the_passphrase_whole_or_in_part(): + """`MARKER_PASSPHRASE not in fingerprint(...)` was a tautology: the + fingerprint is pinned to 16 characters by the test above and the + passphrase is 26, so no implementation could ever fail it. + `assert_no_leak` is what actually discriminates -- it checks the hex + encoding and every eight-character run, which is exactly the shape a + `passphrase.encode("utf-8").hex()[:16]` "fingerprint" takes.""" + assert_no_disclosure( + fingerprint(MARKER_PASSPHRASE), MARKER_PASSPHRASE, what="the passphrase" + ) + + +# --------------------------------------------------------------------------- +# Reading the passphrase -- stdin by default, `getpass` when stdin is a tty, +# never a command-line argument (there is no code path that could accept +# one -- `build_parser` gives `check-passphrase` no such option, and this +# section proves `_read_passphrase` never touches anything but `sys.stdin` +# or `getpass.getpass`). +# --------------------------------------------------------------------------- + + +class _FakeStdin: + """A minimal stand-in for `sys.stdin`: `isatty()` reports a fixed value, + and `readline()` returns each of `lines` in turn, then `""` (EOF).""" + + def __init__(self, isatty: bool, lines: list[str] | None = None): + self._isatty = isatty + self._lines = list(lines or []) + + def isatty(self) -> bool: + return self._isatty + + def readline(self, *a, **k) -> str: + return self._lines.pop(0) if self._lines else "" + + +class _PoisonedReadline: + """`isatty()` reports a real terminal; `readline()` raises if called -- + proves `_read_passphrase` never reads `sys.stdin` on the tty branch.""" + + def isatty(self) -> bool: + return True + + def readline(self, *a, **k): + raise AssertionError("_read_passphrase touched stdin on the tty branch") + + +def test_read_passphrase_reads_a_line_from_piped_stdin(monkeypatch): + monkeypatch.setattr(sys, "stdin", _FakeStdin(isatty=False, lines=[f"{MARKER_PASSPHRASE}\n"])) + + def boom(*a, **k): + raise AssertionError("getpass.getpass must not run when stdin is not a tty") + + monkeypatch.setattr(check_passphrase.getpass, "getpass", boom) + assert _read_passphrase() == MARKER_PASSPHRASE + + +def test_read_passphrase_strips_only_the_line_terminator(monkeypatch): + """Trailing whitespace that is not the line terminator is preserved -- + trimming it would silently change what the caller actually piped in.""" + monkeypatch.setattr( + sys, "stdin", _FakeStdin(isatty=False, lines=[f"{MARKER_PASSPHRASE} \r\n"]) + ) + assert _read_passphrase() == f"{MARKER_PASSPHRASE} " + + +def test_read_passphrase_raises_on_empty_stdin(monkeypatch): + monkeypatch.setattr(sys, "stdin", _FakeStdin(isatty=False, lines=[])) + with pytest.raises(CheckPassphraseError): + _read_passphrase() + + +def test_read_passphrase_falls_back_to_getpass_when_stdin_is_a_tty(monkeypatch): + monkeypatch.setattr(sys, "stdin", _PoisonedReadline()) + monkeypatch.setattr(check_passphrase.getpass, "getpass", lambda prompt="": MARKER_PASSPHRASE) + assert _read_passphrase() == MARKER_PASSPHRASE + + +def test_read_passphrase_never_reads_stdin_on_the_tty_branch(monkeypatch): + """`_PoisonedReadline` itself asserts this, but this test names the + property directly: the interactive branch must go through `getpass` + only, with no fallback read of `sys.stdin`.""" + monkeypatch.setattr(sys, "stdin", _PoisonedReadline()) + monkeypatch.setattr(check_passphrase.getpass, "getpass", lambda prompt="": "typed") + assert _read_passphrase() == "typed" # would have raised via readline() otherwise + + +# --------------------------------------------------------------------------- +# `_extract_salt`: the pure, subprocess-free half of reading an export. +# --------------------------------------------------------------------------- + + +def test_extract_salt_reads_the_nested_field(): + document = { + "deployment": {"secrets_providers": {"state": {"salt": REAL_PULUMI_SALT_FIELD}}} + } + assert _extract_salt(document) == REAL_PULUMI_SALT_FIELD + + +def test_extract_salt_errors_when_deployment_is_missing(): + with pytest.raises(CheckPassphraseError): + _extract_salt({}) + + +def test_extract_salt_errors_when_secrets_providers_is_missing(): + """The case of a stack using a non-passphrase secrets provider (a cloud + KMS URL, or the Pulumi Service's own managed encryption): no `salt` + field exists to check at all, which fail-closed treats as an error for + this stack, never a silent "rejected" or "accepted" -- see the module + docstring.""" + with pytest.raises(CheckPassphraseError): + _extract_salt({"deployment": {}}) + + +def test_extract_salt_errors_when_state_is_missing(): + with pytest.raises(CheckPassphraseError): + _extract_salt({"deployment": {"secrets_providers": {}}}) + + +def test_extract_salt_errors_when_salt_is_missing(): + with pytest.raises(CheckPassphraseError): + _extract_salt({"deployment": {"secrets_providers": {"state": {}}}}) + + +def test_extract_salt_errors_when_salt_is_not_a_string(): + with pytest.raises(CheckPassphraseError): + _extract_salt( + {"deployment": {"secrets_providers": {"state": {"salt": 12345}}}} + ) + + +# --------------------------------------------------------------------------- +# End-to-end, through `main()`, against a real stub `pulumi` process -- +# never a mocked `subprocess.run` -- so a leak through the real child's own +# stdout/stderr, or through its actual `argv`, would be caught the same way +# `test_set_secrets.py`'s stdin/argv test is. +# --------------------------------------------------------------------------- + +_PULUMI_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys, time + + argv = sys.argv[1:] + + dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") + if dump_argv_to: + with open(dump_argv_to, "a") as f: + f.write(json.dumps(argv) + "\\n") + + + def arg_after(flag): + if flag in argv: + i = argv.index(flag) + if i + 1 < len(argv): + return argv[i + 1] + return None + + + stack = arg_after("--stack") + + slow_stack = os.environ.get("STUB_SLOW_STACK") + if slow_stack and stack == slow_stack: + time.sleep(float(os.environ.get("STUB_SLEEP_SECONDS", "5"))) + + exit_codes = json.loads(os.environ.get("STUB_EXIT_CODES", "{}")) + if stack in exit_codes: + sys.stderr.write(os.environ.get("STUB_ECHO", "")) + sys.exit(int(exit_codes[stack])) + + exports = json.loads(os.environ.get("STUB_EXPORTS", "{}")) + sys.stdout.write(exports.get(stack, os.environ.get("STUB_DEFAULT_EXPORT", "{}"))) + sys.exit(0) + """ +) + + +@pytest.fixture +def stub_pulumi(tmp_path) -> Path: + path = tmp_path / "pulumi-stub.py" + path.write_text(_PULUMI_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path | None) -> None: + monkeypatch.setattr( + check_passphrase.shutil, + "which", + lambda name: str(executable) if (executable and name == "pulumi") else None, + ) + + +def feed_stdin(monkeypatch, passphrase: str) -> None: + monkeypatch.setattr( + check_passphrase.sys, "stdin", _FakeStdin(isatty=False, lines=[f"{passphrase}\n"]) + ) + + +@pytest.fixture(autouse=True) +def fast_timeout(monkeypatch): + """The real default (30s) would make the timeout test below take thirty + real seconds for no benefit.""" + monkeypatch.setattr(check_passphrase, "DEFAULT_TIMEOUT_SECONDS", 0.3) + + +def _export_with_salt(salt_field: str) -> str: + return json.dumps( + {"deployment": {"secrets_providers": {"type": "passphrase", "state": {"salt": salt_field}}}} + ) + + +def test_a_correct_passphrase_is_accepted_end_to_end(stub_pulumi, monkeypatch, capsys): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": _export_with_salt(salt_field)})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 0 + out = capsys.readouterr().out + assert "stack-a: accepted" in out + assert "passphrase fingerprint:" in out + + +def test_a_wrong_passphrase_is_rejected_and_exits_non_zero(stub_pulumi, monkeypatch, capsys): + salt_field = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": _export_with_salt(salt_field)})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, WRONG_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + assert "stack-a: rejected" in capsys.readouterr().out + + +def test_every_named_stack_must_accept_for_exit_zero(stub_pulumi, monkeypatch, capsys): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + bad = _encrypt_pulumi_style(WRONG_PASSPHRASE) + monkeypatch.setenv( + "STUB_EXPORTS", + json.dumps( + {"stack-a": _export_with_salt(good), "stack-b": _export_with_salt(bad)} + ), + ) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a", "stack-b"]) == 2 + out = capsys.readouterr().out + assert "stack-a: accepted" in out + assert "stack-b: rejected" in out + + +def test_all_stacks_accepted_is_the_only_way_to_exit_zero(stub_pulumi, monkeypatch, capsys): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv( + "STUB_EXPORTS", + json.dumps( + {"stack-a": _export_with_salt(good), "stack-b": _export_with_salt(good)} + ), + ) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a", "stack-b"]) == 0 + out = capsys.readouterr().out + assert "stack-a: accepted" in out + assert "stack-b: accepted" in out + + +def test_a_missing_pulumi_binary_is_reported_as_error_not_rejected(monkeypatch, capsys): + fake_pulumi(monkeypatch, None) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + err = capsys.readouterr().err + assert "stack-a: error:" in err + assert "rejected" not in err + + +def test_a_nonzero_pulumi_exit_is_reported_as_error_not_rejected(stub_pulumi, monkeypatch, capsys): + monkeypatch.setenv("STUB_EXIT_CODES", json.dumps({"stack-a": 1})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + captured = capsys.readouterr() + assert "stack-a: error:" in captured.err + assert "stack-a: rejected" not in captured.out + + +def test_a_stack_lacking_a_secrets_provider_is_reported_as_error(stub_pulumi, monkeypatch, capsys): + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": json.dumps({"deployment": {}})})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + assert "stack-a: error:" in capsys.readouterr().err + + +def test_invalid_json_from_pulumi_is_reported_as_error(stub_pulumi, monkeypatch, capsys): + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": "not json at all"})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-a"]) == 2 + assert "stack-a: error:" in capsys.readouterr().err + + +def test_a_pulumi_timeout_is_reported_as_error_without_aborting_other_stacks( + stub_pulumi, monkeypatch, capsys +): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_SLOW_STACK", "stack-slow") + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-fast": _export_with_salt(good)})) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + assert main(["check-passphrase", "stack-slow", "stack-fast"]) == 2 + captured = capsys.readouterr() + assert "stack-slow: error:" in captured.err + assert "stack-fast: accepted" in captured.out + + +class _ControlViolation(BaseException): + """Raised by a control that must never run. `BaseException`, not + `Exception`, and that is the whole point of it. + + `cmd_check_passphrase` is `@fail_closed` *and* its per-stack loop has + its own `except Exception`, so a control raised as an `AssertionError` + inside either is swallowed into `all_accepted = False` and the command + returns 2 -- the same 2 the test below expects for the right reason. + The assertion then holds whether the early refusal exists or not, which + is not a test. `tests/test_credentials.py`'s `_PromptViolation` and + `tests/test_gate_isolation.py`'s `GateViolation` are the same device for + the same reason. Not `KeyboardInterrupt`, which pytest treats as a + request to abort the whole session. + """ + + +def test_no_passphrase_provided_on_stdin_errors_before_any_stack_is_checked(monkeypatch): + """The refusal must happen *before* the loop, not be absorbed by it. + + Both halves of that are load-bearing: without a passphrase there is + nothing to check, and a per-stack "could not check" would report the + same exit code while having run `pulumi` against every named stack. + Only a control that `fail_closed` cannot swallow can tell the two + apart -- see `_ControlViolation`. + """ + monkeypatch.setattr(check_passphrase.sys, "stdin", _FakeStdin(isatty=False, lines=[])) + + def boom(*a, **k): + raise _ControlViolation("no stack should be checked without a passphrase") + + monkeypatch.setattr(check_passphrase, "_check_stack", boom) + assert main(["check-passphrase", "stack-a"]) == 2 + + +# --------------------------------------------------------------------------- +# The passphrase never appears anywhere in output -- Python-level `print`s +# (capsys) or the real stub child process's own writes (capfd) -- across +# every reachable per-stack outcome, and never reaches the child's `argv`. +# --------------------------------------------------------------------------- + + +def test_the_passphrase_never_appears_in_output_on_any_outcome(stub_pulumi, monkeypatch, capfd): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv( + "STUB_EXPORTS", + json.dumps({"stack-accept": _export_with_salt(good), "stack-error": "not json"}), + ) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + main(["check-passphrase", "stack-accept", "stack-error"]) + + combined = "".join(capfd.readouterr()) + # `assert_no_disclosure`, not `not in`: this command *prints* a value + # derived from the passphrase on every run (`passphrase fingerprint: + # ...`), so a whole-string check here is exactly the assertion a partial + # or re-encoded disclosure walks straight through -- and did. + assert_no_disclosure(combined, MARKER_PASSPHRASE, what="the passphrase") + + # A rejected outcome too, in a separate run against a fresh capture. + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, WRONG_PASSPHRASE) + main(["check-passphrase", "stack-accept"]) + combined_reject = "".join(capfd.readouterr()) + assert_no_disclosure(combined_reject, MARKER_PASSPHRASE, what="the passphrase") + assert_no_disclosure( + combined_reject, WRONG_PASSPHRASE, what="the wrong passphrase" + ) + + +def test_the_passphrase_never_reaches_pulumis_argv(stub_pulumi, monkeypatch, tmp_path): + good = _encrypt_pulumi_style(MARKER_PASSPHRASE) + monkeypatch.setenv("STUB_EXPORTS", json.dumps({"stack-a": _export_with_salt(good)})) + argv_dump = tmp_path / "argv.jsonl" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + fake_pulumi(monkeypatch, stub_pulumi) + feed_stdin(monkeypatch, MARKER_PASSPHRASE) + + main(["check-passphrase", "stack-a"]) + + assert_no_disclosure( + argv_dump.read_text(), MARKER_PASSPHRASE, what="the passphrase" + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 326123d..244f814 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,14 +2,19 @@ from __future__ import annotations +import argparse +import ast +import inspect + import pytest -from stackward import __version__ +from stackward import __version__, cli from stackward.cli import ( VERSION_CHECK_EXEMPT, + _import_command, + build_parser, config_home, crypto_selftest, - find_repo_config, main, ) @@ -78,26 +83,143 @@ def test_config_home_falls_back_to_dot_config(monkeypatch, tmp_path): assert config_home() == tmp_path / ".config" / "stackward" -def test_repo_config_found_from_a_subdirectory(tmp_path): +def test_check_config_is_registered_and_dispatches(tmp_path, monkeypatch): + """Wiring only — the rules `check-config` enforces are covered in + tests/test_heuristic.py. + + The `.stackward.toml` and the `chdir` are what make this a dispatch + test rather than a policy test: a repository that declares no policy is + now refused with exit 2 before any file is opened, so without them this + would assert the wrong exit code for a reason that has nothing to do + with whether the subcommand is registered. + """ + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text('[check]\nmodel_net = "none"\n') + monkeypatch.chdir(tmp_path) + clean = tmp_path / "Pulumi.dev.yaml" + clean.write_text("name: myproject\n") + assert main(["check-config", str(clean)]) == 0 + + +def test_doctor_still_works_when_repo_config_is_malformed(tmp_path, monkeypatch, capsys): + """`doctor` is the command you run to diagnose a broken + `.stackward.toml`, so dispatch must not load it before routing to + `doctor` — that would make the one command that reports the problem + unable to run at all.""" + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text("this is not valid toml [[[") + monkeypatch.chdir(tmp_path) + assert main(["doctor"]) == 0 + assert str(tmp_path / ".stackward.toml") in capsys.readouterr().out + + +def test_an_unmet_floor_is_reported_beside_a_key_this_binary_does_not_know( + tmp_path, monkeypatch, capsys +): + """The floor must win over the unknown key, because the unknown key is + a *consequence* of the unmet floor. + + A repository that demands a newer stackward is very likely to be using + something that newer stackward added, so an unrecognised top-level key + next to an unmet `min_version` is the expected shape of this config + rather than an exotic one -- it is forward-compatibility, which is the + situation the floor exists to make survivable. Reporting `unknown key + 'future_key'` here names the symptom and sends the reader to delete the + one line that would have explained what to do. + + The exit code is 2 either way (`check-config` refuses a config it + cannot validate), so this asserts on the *message*: that is the whole + difference, and an exit-code-only assertion would hold whether the + refusal reads the floor first or not. + """ (tmp_path / ".git").mkdir() - config = tmp_path / ".stackward.toml" - config.write_text("") - nested = tmp_path / "deploy" / "nested" - nested.mkdir(parents=True) - assert find_repo_config(nested) == config + (tmp_path / ".stackward.toml").write_text( + 'min_version = "9.9.9"\nfuture_key = "a key only a newer stackward knows"\n' + ) + monkeypatch.chdir(tmp_path) + assert main(["check-config", "Pulumi.dev.yaml"]) == 2 + err = capsys.readouterr().err + assert "requires stackward >= 9.9.9" in err + assert "future_key" not in err + + +# --------------------------------------------------------------------------- +# Lazy dispatch, and the frozen bundle +# --------------------------------------------------------------------------- -def test_repo_config_absent_returns_none_rather_than_guessing(tmp_path): - """Refusing beats defaulting: a tool that guesses which backend it is - pointed at can publish a credential to the wrong place.""" - (tmp_path / ".git").mkdir() - assert find_repo_config(tmp_path) is None +def _dispatched_module_names(parser: argparse.ArgumentParser) -> set[str]: + """Every command module reached through `cli._dispatch`, read off the + parser rather than from a list a test keeps in step by hand. -def test_search_stops_at_the_repo_root(tmp_path): - """A config outside the repository must not be picked up — it would make - behaviour depend on where the repo happens to be checked out.""" - (tmp_path / ".stackward.toml").write_text("") - repo = tmp_path / "repo" - (repo / ".git").mkdir(parents=True) - assert find_repo_config(repo) is None + `_dispatch` stamps its thunk's `__qualname__` as `.`; + a directly registered entry point (`cmd_doctor`, `cmd_check_config`) has + no dot in its qualname and is therefore not one of these. + """ + found: set[str] = set() + for action in parser._actions: + if not isinstance(action, argparse._SubParsersAction): + continue + for sub in action.choices.values(): + found |= _dispatched_module_names(sub) + func = sub.get_default("func") + qualname = getattr(func, "__qualname__", "") + if func is not None and "." in qualname: + found.add(qualname.partition(".")[0]) + return found + + +def test_every_lazily_dispatched_command_module_can_actually_be_imported(): + """A command registered through `_dispatch` but missing from + `_import_command`'s chain would raise `RuntimeError` the first time + anyone ran it, and nothing else in the suite would notice: every other + test either calls an entry point directly or goes through a subcommand + that already works. + + The membership check is a vacuity guard -- a `_dispatched_module_names` + that silently returned nothing would satisfy the loop below against any + implementation at all. + """ + names = _dispatched_module_names(build_parser()) + assert {"session", "credentials"} <= names + + for name in names: + assert _import_command(name).__name__ == f"stackward.commands.{name}" + + +def test_a_command_module_is_never_named_dynamically(): + """The one property no in-process test can observe, asserted against the + module's own syntax tree instead. + + PyInstaller resolves imports by reading `import` statements out of + bytecode; it cannot resolve a module name assembled at runtime. While + dispatch used `importlib.import_module(f".commands.{name}", ...)`, every + lazily dispatched command -- `login`, `exec`, `shell`, `set-secrets`, + `sync-declared-secrets`, `check-passphrase` -- was simply absent from + the shipped binary and died on `ModuleNotFoundError` with a traceback + and exit code 1, the code reserved for "a credential was found". Source + checkouts, this suite included, cannot see that: they import from a real + filesystem where the module is there to be found. + + The tree, not the text: this module's own docstrings quote the old call + on purpose, and a substring search over the source would fail on the + explanation of the fix rather than on the defect. + + So this asserts the mechanism, and the release workflow's smoke step + asserts the consequence by running a lazily dispatched command against + the real bundle. Neither is sufficient alone: this one cannot prove the + bundle works, and that one only runs on a release. + """ + tree = ast.parse(inspect.getsource(cli)) + called = { + node.func.attr + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + } + assert "import_module" not in called + assert "__import__" not in { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..757a6c5 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,668 @@ +"""Tests for `.stackward.toml` discovery, parsing and validation. + +This is the seam that keeps `stackward` generic: every fact a repository +wants applied — what counts as sensitive, which stack models declare +secrets, the oldest release its policy works with — arrives only through +this file. Absent means "no policy" (`find_repo_config` returns `None`); +present-but-invalid must raise, never fall back to a default. +""" + +from __future__ import annotations + +import tomllib + +import pytest + +from leakcheck import assert_no_leak + +from stackward import __version__ +from stackward.config import ( + BUILTIN_SENSITIVE_KEYS, + Config, + ConfigError, + MinVersionError, + _extend, + _replace, + enforce_min_version, + find_repo_config, + load_config, + load_config_text, +) + + +def write_config(tmp_path, text: str): + path = tmp_path / ".stackward.toml" + path.write_text(text) + return path + + +# --------------------------------------------------------------------------- +# find_repo_config — moved here from tests/test_cli.py along with the +# function itself (Task 2 brief). +# --------------------------------------------------------------------------- + + +def test_repo_config_found_from_a_subdirectory(tmp_path): + (tmp_path / ".git").mkdir() + config = tmp_path / ".stackward.toml" + config.write_text("") + nested = tmp_path / "deploy" / "nested" + nested.mkdir(parents=True) + assert find_repo_config(nested) == config + + +def test_repo_config_absent_returns_none_rather_than_guessing(tmp_path): + """Refusing beats defaulting: a tool that guesses which backend it is + pointed at can publish a credential to the wrong place.""" + (tmp_path / ".git").mkdir() + assert find_repo_config(tmp_path) is None + + +def test_search_stops_at_the_repo_root(tmp_path): + """A config outside the repository must not be picked up — it would make + behaviour depend on where the repo happens to be checked out.""" + (tmp_path / ".stackward.toml").write_text("") + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + assert find_repo_config(repo) is None + + +# --------------------------------------------------------------------------- +# Each recognised key, parsed. +# --------------------------------------------------------------------------- + + +def test_profile_key_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, 'profile = "staging"\n')) + assert config.profile == "staging" + + +def test_python_key_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, 'python = ".venv/bin/python"\n')) + assert config.python == ".venv/bin/python" + + +def test_min_version_key_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, 'min_version = "0.4.0"\n')) + assert config.min_version == "0.4.0" + + +def test_check_model_net_is_parsed(tmp_path): + config = load_config(write_config(tmp_path, '[check]\nmodel_net = "artifact"\n')) + assert config.check.model_net == "artifact" + + +def test_check_model_net_defaults_to_none(tmp_path): + config = load_config(write_config(tmp_path, "[check]\n")) + assert config.check.model_net == "none" + + +def test_check_stack_models_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[check.stack_models]\n"infra" = "infra.config:StackConfig"\n', + ) + ) + assert config.check.stack_models == {"infra": "infra.config:StackConfig"} + + +def test_check_stack_models_value_with_no_colon_raises(tmp_path): + """The brief states the `"module:Class"` shape explicitly (unlike + `[secrets.*]`'s interior), so a dot-instead-of-colon typo must be + rejected here rather than surfacing later as an ImportError in Task 10.""" + with pytest.raises(ConfigError) as exc_info: + load_config( + write_config( + tmp_path, + '[check.stack_models]\n"infra" = "infra.config.StackConfig"\n', + ) + ) + assert "infra" in str(exc_info.value) + + +def test_check_stack_models_value_with_two_colons_raises(tmp_path): + with pytest.raises(ConfigError): + load_config( + write_config( + tmp_path, + '[check.stack_models]\n"infra" = "infra:config:StackConfig"\n', + ) + ) + + +def test_check_stack_models_value_with_empty_module_raises(tmp_path): + with pytest.raises(ConfigError): + load_config( + write_config(tmp_path, '[check.stack_models]\n"infra" = ":StackConfig"\n') + ) + + +def test_check_stack_models_value_with_empty_class_raises(tmp_path): + with pytest.raises(ConfigError): + load_config( + write_config(tmp_path, '[check.stack_models]\n"infra" = "infra.config:"\n') + ) + + +def test_check_declared_paths_fn_defaults_to_none(tmp_path): + """Absent means this tool's own marking convention, not a disabled net.""" + config = load_config(write_config(tmp_path, "[check]\n")) + assert config.check.declared_paths_fn is None + + +def test_check_declared_paths_fn_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[check]\ndeclared_paths_fn = "conventions.marks:secret_fields"\n', + ) + ) + assert config.check.declared_paths_fn == "conventions.marks:secret_fields" + + +def test_check_declared_paths_fn_with_no_colon_raises(tmp_path): + """Same shape as `stack_models`, rejected here for the same reason: a + malformed value would otherwise surface as a confusing ImportError deep + inside the generator subprocess, far from the file that caused it.""" + with pytest.raises(ConfigError) as exc_info: + load_config( + write_config( + tmp_path, + '[check]\ndeclared_paths_fn = "conventions.marks.secret_fields"\n', + ) + ) + assert "declared_paths_fn" in str(exc_info.value) + + +def test_check_declared_paths_fn_must_be_a_string(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, "[check]\ndeclared_paths_fn = 3\n")) + + +def test_check_sensitive_keys_is_parsed_and_extends_the_builtins(tmp_path): + config = load_config( + write_config(tmp_path, '[check]\nsensitive_keys = ["jwtsecret"]\n') + ) + # Both the declared addition and a built-in survive — extension, not + # replacement. + assert "jwtsecret" in config.check.sensitive_keys + assert "password" in config.check.sensitive_keys + + +def test_check_sensitive_parents_is_parsed(tmp_path): + config = load_config( + write_config(tmp_path, '[check]\nsensitive_parents = ["example_parent"]\n') + ) + assert config.check.sensitive_parents == frozenset({"example_parent"}) + + +def test_check_allowed_references_is_parsed(tmp_path): + config = load_config( + write_config(tmp_path, '[check]\nallowed_references = ["a.other_secret"]\n') + ) + assert config.check.allowed_references == ["a.other_secret"] + + +def test_secrets_project_table_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[secrets."infra"]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + ) + assert config.secrets["infra"]["secret"] == {"db.password": "DB_PASSWORD"} + + +def test_secrets_source_table_is_parsed(tmp_path): + config = load_config( + write_config( + tmp_path, + '[secrets.source]\nfiles = [".env", ".env.{stack}.local"]\n', + ) + ) + assert config.secrets["source"]["files"] == [".env", ".env.{stack}.local"] + + +# --------------------------------------------------------------------------- +# Unknown keys are loud, at every level this module validates. +# --------------------------------------------------------------------------- + + +def test_unknown_top_level_key_errors_naming_the_key(tmp_path): + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, 'bogus = "oops"\n')) + assert "bogus" in str(exc_info.value) + + +def test_unknown_check_key_errors_naming_the_key(tmp_path): + """`[check]`'s keys are fully enumerated in the brief, so a typo there + must be as loud as an unknown top-level key — this module does not stop + validating strictly the moment it descends one table.""" + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, '[check]\nbogus = "oops"\n')) + assert "bogus" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# The extend-vs-replace asymmetry, proven for both lists. +# +# `sensitive_parents`' shipped built-in default is the empty set (see +# `BUILTIN_SENSITIVE_PARENTS` in config.py), so a load_config-level test +# alone cannot distinguish "replaced an empty set" from "extended an empty +# set" — both produce the same result. `_extend`/`_replace` are tested +# directly, against a synthetic non-empty `builtin`, to prove the mechanism +# itself rather than relying on the shipped default happening to be +# non-empty. +# --------------------------------------------------------------------------- + + +def test_extend_unions_declared_with_builtin(): + builtin = frozenset({"password"}) + assert _extend(builtin, ["apikey"]) == frozenset({"password", "apikey"}) + + +def test_extend_returns_builtin_unchanged_when_nothing_declared(): + builtin = frozenset({"password"}) + assert _extend(builtin, None) == builtin + + +def test_replace_discards_the_builtin_entirely(): + builtin = frozenset({"example_parent"}) + result = _replace(builtin, ["only_this_one"]) + assert result == frozenset({"only_this_one"}) + assert "example_parent" not in result + + +def test_replace_returns_builtin_unchanged_when_nothing_declared(): + builtin = frozenset({"example_parent"}) + assert _replace(builtin, None) == builtin + + +def test_sensitive_parents_replacement_survives_through_load_config(tmp_path, monkeypatch): + """End-to-end proof that `_build_check`'s call site actually wires + `_replace` (not `_extend`) to `sensitive_parents`. + + The shipped `BUILTIN_SENSITIVE_PARENTS` is empty, so a test against the + real default can't tell the two apart: extending or replacing an empty + set both yield exactly the declared list. Monkeypatching the builtin to + a non-empty synthetic value makes the two operations diverge, so this + proves the wiring itself, not just the `_extend`/`_replace` helpers in + isolation — mirrors + `test_sensitive_keys_extension_survives_through_load_config` below, + which gets this proof for free against the real (non-empty) + `sensitive_keys` builtin. + """ + monkeypatch.setattr( + "stackward.config.BUILTIN_SENSITIVE_PARENTS", frozenset({"builtin_only"}) + ) + config = load_config( + write_config(tmp_path, '[check]\nsensitive_parents = ["declared_only"]\n') + ) + assert config.check.sensitive_parents == frozenset({"declared_only"}) + assert "builtin_only" not in config.check.sensitive_parents + + +def test_sensitive_keys_extension_survives_through_load_config(tmp_path): + """End-to-end proof, against the real (non-empty) built-in default: a + declared sensitive_keys list adds to BUILTIN_SENSITIVE_KEYS rather than + supplanting it.""" + config = load_config( + write_config(tmp_path, '[check]\nsensitive_keys = ["custom_marker"]\n') + ) + assert BUILTIN_SENSITIVE_KEYS <= config.check.sensitive_keys + assert "custom_marker" in config.check.sensitive_keys + + +# --------------------------------------------------------------------------- +# min_version enforcement. +# --------------------------------------------------------------------------- + + +def test_min_version_accepts_an_equal_installed_version(): + config = Config(min_version="0.4.0") + enforce_min_version(config, "check-config", installed="0.4.0") # must not raise + + +def test_min_version_accepts_a_higher_installed_version(): + config = Config(min_version="0.4.0") + enforce_min_version(config, "check-config", installed="0.5.0") # must not raise + + +def test_min_version_rejects_a_lower_installed_version(): + config = Config(min_version="0.4.0") + with pytest.raises(MinVersionError) as exc_info: + enforce_min_version(config, "check-config", installed="0.3.0") + message = str(exc_info.value) + assert "0.4.0" in message + # The instruction has to name something that exists. `stackward + # self-update` does not: `cli.build_parser` registers no such + # subcommand, so the one message this feature prints used to end by + # telling the reader to run a command that would exit 2 as a usage + # error. `install.sh` is the upgrade path this project actually ships. + assert "install.sh" in message + assert "self-update" not in message + + +def test_min_version_compares_numerically_not_lexicographically(): + """"0.10.0" > "0.9.0" is False under string ordering but True + numerically — string comparison would wrongly block here.""" + config = Config(min_version="0.9.0") + enforce_min_version(config, "check-config", installed="0.10.0") # must not raise + + +def test_min_version_numeric_comparison_rejects_when_genuinely_lower(): + config = Config(min_version="0.10.0") + with pytest.raises(MinVersionError): + enforce_min_version(config, "check-config", installed="0.9.0") + + +def test_min_version_with_fewer_components_than_installed_still_compares(): + config = Config(min_version="0.2") + with pytest.raises(MinVersionError): + enforce_min_version(config, "check-config", installed="0.1.1") + + +def test_min_version_absent_never_raises(): + config = Config(min_version=None) + enforce_min_version(config, "check-config", installed="0.0.1") # must not raise + + +def test_min_version_config_none_never_raises(): + enforce_min_version(None, "check-config", installed="0.0.1") # must not raise + + +def test_doctor_is_exempt_from_min_version(): + config = Config(min_version="99.0.0") + enforce_min_version(config, "doctor", installed="0.1.1") # must not raise + + +def test_self_update_is_exempt_from_min_version(): + config = Config(min_version="99.0.0") + enforce_min_version(config, "self-update", installed="0.1.1") # must not raise + + +# --------------------------------------------------------------------------- +# General fail-closed behaviour: present-but-invalid always raises, never +# falls back to a default. +# --------------------------------------------------------------------------- + + +def test_malformed_toml_raises_naming_the_path(tmp_path): + path = write_config(tmp_path, "this is not valid toml [[[\n") + with pytest.raises(ConfigError) as exc_info: + load_config(path) + assert str(path) in str(exc_info.value) + + +def test_invalid_model_net_value_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, '[check]\nmodel_net = "bogus"\n')) + + +def test_malformed_min_version_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, 'min_version = "not-a-version"\n')) + + +def test_secrets_value_that_is_not_a_table_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, 'secrets = "oops"\n')) + + +def test_secrets_member_that_is_not_a_table_raises(tmp_path): + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, '[secrets]\ninfra = "oops"\n')) + + +# --------------------------------------------------------------------------- +# Branches that had no test at all: each `raise`/`read` below survived the +# whole suite when mutated into a silent default, which is the exact failure +# shape this module's docstring says must not exist ("no third state where an +# invalid file quietly falls back to defaults"). +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "key", ["sensitive_keys", "sensitive_parents", "allowed_references"] +) +def test_a_list_valued_check_key_of_the_wrong_type_raises(tmp_path, key): + """`_require_str_list`'s rejection. Mutating its `raise` to `return + None` made every one of these load as "key absent" — which for + `sensitive_keys` means the built-ins alone, and for `sensitive_parents` + means the empty default: a policy that declared a whole sensitive + category, mistyped, would silently protect nothing.""" + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, f"[check]\n{key} = 5\n")) + assert f"check.{key}" in str(exc_info.value) + + +def test_a_list_valued_check_key_containing_a_non_string_raises(tmp_path): + """The `all(isinstance(...))` half of the same guard: a list is not + enough, its members have to be names.""" + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, '[check]\nsensitive_keys = ["ok", 5]\n')) + + +def test_a_check_table_that_is_not_a_table_raises(tmp_path): + """`_build_check`'s type guard. Mutating it to `raw = {}` made + `check = "oops"` load as an empty `[check]` table — every declared key + silently discarded, including `model_net`.""" + with pytest.raises(ConfigError) as exc_info: + load_config(write_config(tmp_path, 'check = "oops"\n')) + assert "check" in str(exc_info.value) + + +def test_an_unreadable_config_raises_rather_than_defaulting(tmp_path): + """`load_config`'s `OSError` branch. Mutating it to `return Config()` + turned a config the tool could not read into a config that said + nothing — the single worst outcome available, since `Config()` carries + `model_net = "none"`.""" + unreadable = tmp_path / ".stackward.toml" + unreadable.mkdir() # a directory where a file is expected + with pytest.raises(ConfigError) as exc_info: + load_config(unreadable) + assert "cannot read" in str(exc_info.value) + + +def test_a_config_that_is_not_utf8_raises_rather_than_defaulting(tmp_path): + """TOML is defined as UTF-8, so bytes that are not are a broken config + file, not an absent one. `Path.read_text` raises `UnicodeDecodeError`, + which is a `ValueError` and not an `OSError` — caught separately, or it + would escape `load_config` as something no caller expects.""" + path = tmp_path / ".stackward.toml" + path.write_bytes(b'profile = "\xff\xfe"\n') + with pytest.raises(ConfigError): + load_config(path) + + +# --------------------------------------------------------------------------- +# Parsing separated from reading: what `commands.pre_commit` needs to load +# the policy the git index holds rather than the one on disk. +# --------------------------------------------------------------------------- + + +def test_load_config_text_parses_without_touching_the_filesystem(): + config = load_config_text('[check]\nmodel_net = "artifact"\n', ":.stackward.toml") + assert config.check.model_net == "artifact" + + +def test_load_config_text_names_its_origin_in_an_error(): + """The origin label is how a person reproduces the failure: for the + index reader it is literally the argument to `git show`.""" + with pytest.raises(ConfigError) as exc_info: + load_config_text("this is not valid toml [[[", ":.stackward.toml") + assert ":.stackward.toml" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# A parse failure reports a position, never the parser's own message. +# +# `tomllib` quotes document text in some of its messages, and this call site +# interpolated `exc` in full on the strength of a claim about what +# `.stackward.toml` is allowed to contain -- which is exactly the assumption +# a file that failed to parse has already broken. `store.py` fixed the same +# defect for the store's own `config`; both now go through +# `config.toml_position`. +# --------------------------------------------------------------------------- + +# Opaque -- see `tests/leakcheck.py`. The messages under test print the +# origin label and the words "invalid TOML", and a marker built out of real +# words would share an eight-character run with them. +PASTED_INTO_THE_POLICY = "Xr4Nb8Kw2Vd6Ty9Qm3Zs7Fj" + + +def test_a_duplicate_declaration_is_reported_by_position_and_never_quoted_back(): + """`tomllib` really does read a document's own text back. + + Not hypothetical, and not reachable through the malformed-syntax tests + above: nearly every `TOMLDecodeError` is a fixed description plus a + coordinate, and only a handful name something from the file. A + re-declared table is the one this project has actually confirmed -- + `Cannot declare ('a',) twice` -- so it is the shape worth pinning. + """ + text = ( + f'[secrets."{PASTED_INTO_THE_POLICY}"]\n' + f'[secrets."{PASTED_INTO_THE_POLICY}"]\n' + ) + # Guard: if a future tomllib stops quoting the key, this test would pass + # against the unfixed call site too, and would need to be rewritten + # rather than quietly kept. + try: + tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: + assert PASTED_INTO_THE_POLICY in str(exc), "tomllib no longer quotes the key" + + with pytest.raises(ConfigError) as exc_info: + load_config_text(text, ":.stackward.toml") + + message = str(exc_info.value) + assert_no_leak(message, PASTED_INTO_THE_POLICY, what="text from the policy file") + assert message.startswith(":.stackward.toml: invalid TOML") + assert "line 2" in message + + +def test_a_parser_message_is_reduced_to_its_coordinate(monkeypatch): + """The general rule, independent of which messages this `tomllib` happens + to produce: whatever the parser says, only the coordinate survives.""" + + def raise_quoting_the_document(*_args, **_kwargs): + raise tomllib.TOMLDecodeError( + f"Cannot declare ('{PASTED_INTO_THE_POLICY}',) twice " + "(at line 3, column 3)" + ) + + monkeypatch.setattr(tomllib, "loads", raise_quoting_the_document) + with pytest.raises(ConfigError) as exc_info: + load_config_text("irrelevant", "") + + message = str(exc_info.value) + assert_no_leak(message, PASTED_INTO_THE_POLICY, what="the parser's own message") + assert message == ": invalid TOML (at line 3, column 3)" + + +def test_a_message_with_no_coordinate_loses_the_coordinate_not_the_secrecy(monkeypatch): + """The fallback must degrade to *no position*, never to the whole + message -- which is what this call site used to do unconditionally. + `TOMLDecodeError`'s text is not an API and this project supports three + Python versions, so an unrecognised shape has to be survivable.""" + + def raise_an_unparseable_shape(*_args, **_kwargs): + raise tomllib.TOMLDecodeError( + f"a shape from some future release: {PASTED_INTO_THE_POLICY}" + ) + + monkeypatch.setattr(tomllib, "loads", raise_an_unparseable_shape) + with pytest.raises(ConfigError) as exc_info: + load_config_text("irrelevant", "") + + message = str(exc_info.value) + assert_no_leak(message, PASTED_INTO_THE_POLICY, what="the parser's own message") + assert message == ": invalid TOML" + + +def test_load_config_applies_the_same_validation_as_load_config_text(tmp_path): + """The file reader must not be a second, drifting implementation: it is + a reader in front of the same validator, so an invalid key is rejected + identically whichever entry point saw it.""" + text = '[check]\nmodel_net = "bogus"\n' + with pytest.raises(ConfigError): + load_config_text(text, "") + with pytest.raises(ConfigError): + load_config(write_config(tmp_path, text)) + + +# --------------------------------------------------------------------------- +# `min_version` reaches a real invocation. +# +# The floor was built here and never wired: `enforce_min_version` had zero +# production call sites, and `cli.main` dispatched straight to `args.func`. +# The plan states Task 2's goal as "Load and validate `.stackward.toml`, and +# enforce `min_version`" — the second half of which no command performed. +# These tests go through `cli.main`, because that is the only place the +# wiring exists and a unit test of `enforce_min_version` cannot see it. +# --------------------------------------------------------------------------- + + +def _repo_declaring(tmp_path, monkeypatch, text: str): + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text(text) + (tmp_path / "Pulumi.dev.yaml").write_text("name: myproject\n") + monkeypatch.chdir(tmp_path) + return tmp_path / "Pulumi.dev.yaml" + + +def test_main_refuses_a_command_below_the_repositorys_min_version( + tmp_path, monkeypatch, capsys +): + """A repository whose policy needs a newer `stackward` than the one + installed must not have that policy interpreted by this one. Exit 2 — + could-not-run, never 1 — and the message names the required version and + how to upgrade.""" + from stackward.cli import main as cli_main + + target = _repo_declaring( + tmp_path, monkeypatch, 'min_version = "99.0.0"\n[check]\nmodel_net = "none"\n' + ) + assert cli_main(["check-config", str(target)]) == 2 + err = capsys.readouterr().err + assert "99.0.0" in err + assert "install.sh" in err + + +def test_main_dispatches_normally_when_the_floor_is_met(tmp_path, monkeypatch, capsys): + """The other half: a satisfied floor must be invisible. Without this, the + test above would pass just as well if every command exited 2.""" + from stackward.cli import main as cli_main + + target = _repo_declaring( + tmp_path, monkeypatch, 'min_version = "0.0.1"\n[check]\nmodel_net = "none"\n' + ) + assert cli_main(["check-config", str(target)]) == 0 + assert capsys.readouterr().out == "" + + +def test_doctor_still_runs_under_an_unmet_min_version(tmp_path, monkeypatch, capsys): + """`VERSION_CHECK_EXEMPT` reaching a real invocation. `doctor` is how you + diagnose the problem; a floor that blocked it would print an instruction + from a command it had just made unreachable.""" + from stackward.cli import main as cli_main + + _repo_declaring(tmp_path, monkeypatch, 'min_version = "99.0.0"\n') + assert cli_main(["doctor"]) == 0 + assert __version__ in capsys.readouterr().out + + +def test_a_malformed_config_does_not_break_dispatch(tmp_path, monkeypatch, capsys): + """Task 2's carry-forward ruling, re-pinned against the new call site: + the floor check runs before every command, so a `.stackward.toml` that + will not parse must make it stand aside rather than take `doctor` down + with it. The gate commands still refuse — with their own, better + message — because they load the same file themselves.""" + from stackward.cli import main as cli_main + + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text("this is not valid toml [[[") + monkeypatch.chdir(tmp_path) + assert cli_main(["doctor"]) == 0 + assert __version__ in capsys.readouterr().out diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 0000000..a424b0d --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,878 @@ +"""Tests for `credentials init|set|list|show|rotate`. + +Three things this file is careful about, for the reasons `test_store.py` and +`test_session.py` state them: + +*No assertion depends on a credential value being displayed.* Where a value +must round-trip it is compared against a constant; where it must not appear +the assertion is on absence, through `leakcheck.assert_no_leak`, which also +catches a truncated or re-encoded disclosure. + +*The markers are opaque.* `assert_no_leak` fails on any eight-character run +of a value, and these commands legitimately print profile names, store paths +and the literal string `AWS_ACCESS_KEY_ID`. A marker built out of real words +collides with that and makes the check unusable — see `tests/leakcheck.py`. + +*A "must not be called" control is a `BaseException`.* Every entry point here +is wrapped in `@fail_closed`, which turns any `Exception` into `return 2`, so +an `AssertionError` raised inside one is swallowed and the test then asserts +on an exit code that looks like an ordinary refusal. `_PromptViolation` below +inherits from `BaseException` instead, and is not `KeyboardInterrupt`, which +pytest would treat as "the human wants out" and use to abort the session. +""" + +from __future__ import annotations + +import io +import json +import os +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from leakcheck import assert_no_leak +from stackward import store +from stackward.cli import main +from stackward.commands import credentials, session +from stackward.commands.credentials import ( + CONFIRM_PASSWORD_PROMPT, + NEW_PASSWORD_PROMPT, + OLD_PASSWORD_PROMPT, +) +from stackward.commands.session import ( + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + ENV_STORE_PASSWORD, + PULUMI_BACKEND_URL, + PULUMI_CONFIG_PASSPHRASE, +) + +# Placeholder values only (GC4), and deliberately wordless: every one of +# these is passed to `assert_no_leak` against output that also contains the +# tool's own vocabulary. +PASSWORD = "Kx7Rq2Vn9Ld4Ts6Bw" +WRONG_PASSWORD = "Zj3Mp8Hc5Yf1Gd7Nr" +NEW_PASSWORD = "Qv6Wb4Xs2Tk9Pm3Ld" + +MARKER_KEY = "Ab7Kd2Ns9Rv4Tq6Xw" +MARKER_SECRET = "Cf3Jm8Ph5Lz1Yb7Gd" +MARKER_PASSPHRASE = "Ew9Ur4Vt2Kn6Sx8Qc" +MARKER_EXTRA = "Hq5Zt3Bw8Nk2Ly6Fp" +ALL_MARKERS = (MARKER_KEY, MARKER_SECRET, MARKER_PASSPHRASE, MARKER_EXTRA) + +# A name a real `.env` would carry that this tool never injects. Generic on +# purpose: no vendor, product or environment appears in this repository. +EXTRA_NAME = "DATABASE_URL" + +FULL_CREDENTIALS = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + PULUMI_CONFIG_PASSPHRASE: MARKER_PASSPHRASE, +} + +BACKEND_URL = "file:///placeholder-backend" + + +def env_text(values: dict[str, str]) -> str: + return "".join(f"{name}={value}\n" for name, value in values.items()) + + +class _PromptViolation(BaseException): + """Raised when something asks for input a test did not expect it to ask + for. See this module's docstring for why it is not an `Exception`.""" + + +class _Terminal(io.StringIO): + """Stands in for a `sys.stdin` attached to a terminal. `io.StringIO` + already reports `isatty()` as `False`, which is the piped case, so only + this direction needs a subclass.""" + + def isatty(self) -> bool: + return True + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only -- the same fixture + `test_store.py` and `test_session.py` carry, for the same reason: these + tests perform several real seal/open round trips each and none of them is + about how expensive a guess is. `test_crypto.py` pins the shipped + parameters.""" + from stackward import crypto + + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture(autouse=True) +def no_ambient_state(monkeypatch, tmp_path): + """Nothing about the store, the profile or the backend left to whoever's + shell runs the suite. Each test opts back in to whatever it needs.""" + for name in ( + ENV_STORE_PASSWORD, + store.ENV_PROFILE, + PULUMI_BACKEND_URL, + *session.CREDENTIAL_NAMES, + *session.SCRUBBED_NAMES, + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) + + +@pytest.fixture +def cli_store(tmp_path, monkeypatch) -> Path: + """Point the real config-home lookup at an isolated directory -- the way + `cli.config_home` is actually consulted by every `cmd_*` entry point, + rather than through the `directory=` seam `store.py`'s own unit tests + use.""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + return home / "stackward" + + +@pytest.fixture +def configured(cli_store) -> Path: + """A store directory whose `config` declares one profile, and no + `credentials` file at all -- the state a user is in before + `credentials init`.""" + cli_store.mkdir(parents=True, exist_ok=True) + config = cli_store / store.CONFIG_FILENAME + config.write_text(f'[profile.staging]\nbackend_url = "{BACKEND_URL}"\n') + config.chmod(store.CONFIG_MODE) + return cli_store + + +@pytest.fixture +def initialised(configured, monkeypatch) -> Path: + """`configured`, plus a store initialised under `PASSWORD`. + + The variable is removed again once the store exists. Leaving it set + would be an ambient answer to every later password read in the test, and + the tests that most need it *not* to be set -- rotation, which refuses + outright while it is -- are exactly the ones that would then be testing + the fixture rather than the command. Each test opts back in. + """ + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + assert main(["credentials", "init"]) == 0 + monkeypatch.delenv(ENV_STORE_PASSWORD) + return configured + + +def pipe(monkeypatch, text: str) -> None: + """Make `sys.stdin` a pipe carrying `text`.""" + monkeypatch.setattr(sys, "stdin", io.StringIO(text)) + + +def terminal(monkeypatch, text: str = "") -> None: + monkeypatch.setattr(sys, "stdin", _Terminal(text)) + + +def answer_prompts(monkeypatch, answers: dict[str, str]) -> None: + """Answer `getpass` by prompt text, and refuse any prompt not listed. + + Keying on the prompt is what lets one seam serve both the master + password and the per-name value prompts, and what makes "it asked for + something else" a failure rather than a silently wrong answer. + """ + + def fake_getpass(prompt: str = "") -> str: + if prompt not in answers: + raise _PromptViolation(f"unexpected prompt: {prompt!r}") + return answers[prompt] + + monkeypatch.setattr(credentials.getpass, "getpass", fake_getpass) + monkeypatch.setattr(session, "_tty_available", lambda: True) + + def refuse_input(*_args, **_kwargs): + raise _PromptViolation("a prompt echoed: builtins.input was used") + + monkeypatch.setattr("builtins.input", refuse_input) + + +# --------------------------------------------------------------------------- +# `credentials init` +# --------------------------------------------------------------------------- + + +def test_init_creates_a_store_the_session_commands_can_open(configured, monkeypatch): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "init"]) == 0 + + path = configured / store.CREDENTIALS_FILENAME + assert path.is_file() + assert stat.S_IMODE(path.stat().st_mode) == store.CREDENTIALS_MODE + assert stat.S_IMODE(configured.stat().st_mode) == store.DIR_MODE + # Empty, and openable: the verifier is what proves the password took. + assert store.store_profiles(configured) == [] + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=configured) + + +def test_init_refuses_to_overwrite_an_existing_store(initialised, monkeypatch, capsys): + """Overwriting would discard every sealed envelope irrecoverably, in + response to a command someone could plausibly run twice.""" + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "init"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + assert store.resolve_credentials("staging", PASSWORD, directory=initialised) + assert "already exists" in capsys.readouterr().err + + +def test_init_asks_for_the_new_password_twice_and_refuses_a_mismatch( + configured, monkeypatch, capsys +): + """A typo in the password that seals a store is not recoverable by any + means afterwards, because nothing else can open what it sealed. The + second read is what catches it, and the refusal must leave no store + behind -- a half-created one would be opened by neither answer.""" + answer_prompts( + monkeypatch, + {NEW_PASSWORD_PROMPT: PASSWORD, CONFIRM_PASSWORD_PROMPT: WRONG_PASSWORD}, + ) + + assert main(["credentials", "init"]) == 2 + + assert not (configured / store.CREDENTIALS_FILENAME).exists() + assert "do not match" in capsys.readouterr().err + + +def test_init_prompts_without_echoing_and_seals_what_was_typed( + configured, monkeypatch +): + """The interactive path, end to end: `getpass` (never `input`, which + echoes -- `answer_prompts` fails the test if `input` is used at all), and + the store really opens under the typed password afterwards.""" + answer_prompts( + monkeypatch, + {NEW_PASSWORD_PROMPT: PASSWORD, CONFIRM_PASSWORD_PROMPT: PASSWORD}, + ) + + assert main(["credentials", "init"]) == 0 + + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=configured) + assert ( + store.resolve_credentials("staging", PASSWORD, directory=configured) + == FULL_CREDENTIALS + ) + + +def test_init_says_when_it_took_the_password_from_the_environment( + configured, monkeypatch, capsys +): + """A store sealed with an ambient variable the user has forgotten about + is a store they cannot open tomorrow. The name is printed; the value + never is.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "init"]) == 0 + + out = capsys.readouterr().out + assert ENV_STORE_PASSWORD in out + assert_no_leak(out, PASSWORD, what="the store password") + + +# --------------------------------------------------------------------------- +# `credentials set` +# --------------------------------------------------------------------------- + + +def test_set_seals_values_read_from_stdin(initialised, monkeypatch): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + + +def test_the_sealed_file_holds_none_of_the_values(initialised, monkeypatch): + """A `set` that stringified or base64-encoded its input instead of + sealing it would pass the round-trip test above and fail here.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + written = (initialised / store.CREDENTIALS_FILENAME).read_text() + for marker in ALL_MARKERS: + assert_no_leak(written, marker, what="a sealed credential") + assert_no_leak(written, PASSWORD, what="the store password") + + +def test_set_prints_no_value_of_its_own(initialised, monkeypatch, capfd): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_set_takes_no_value_as_an_argument(initialised, monkeypatch): + """Global Constraint 6, pinned at the parser rather than by inspection: + a command line is visible in `ps` for the life of the call and stays in + shell history afterwards, so there must be no positional and no + `--value` for a value to be passed *through*. argparse rejects the + attempt itself, before any command code runs. + """ + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + for argv in ( + ["credentials", "set", "--profile", "staging", f"{AWS_ACCESS_KEY_ID}={MARKER_KEY}"], + ["credentials", "set", "--profile", "staging", "--value", MARKER_KEY], + ): + with pytest.raises(SystemExit) as raised: + main(argv) + assert raised.value.code == 2 + assert store.store_profiles(initialised) == [] + + +def test_set_refuses_a_missing_required_name_and_writes_nothing( + initialised, monkeypatch, capsys +): + """The same refusal `exec` would make, made at the point the envelope is + written instead of at the point it is needed -- and `set_credentials` + *replaces* an envelope, so accepting a partial one would silently drop + the names that were not supplied.""" + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text({AWS_ACCESS_KEY_ID: MARKER_KEY})) + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + err = capsys.readouterr().err + assert AWS_SECRET_ACCESS_KEY in err and PULUMI_CONFIG_PASSPHRASE in err + assert_no_leak(err, MARKER_KEY, what="a credential") + + +def test_set_refuses_an_empty_value_for_a_required_name( + initialised, monkeypatch, capsys +): + """A placeholder line in a `.env` (`AWS_ACCESS_KEY_ID=`) seals perfectly + cleanly and then fails to authenticate with nothing pointing back at + this command, so it is refused here where the cause is still visible.""" + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text({**FULL_CREDENTIALS, AWS_ACCESS_KEY_ID: ""})) + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + assert AWS_ACCESS_KEY_ID in capsys.readouterr().err + + +def test_set_refuses_a_malformed_line_without_echoing_it( + initialised, monkeypatch, capsys +): + """A line with no `=` is fail-closed, not silently skipped -- and the + refusal names a line number, never the line, because the thing that + failed to parse may be exactly what a value was pasted into.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, f"{env_text(FULL_CREDENTIALS)}{MARKER_SECRET}\n") + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + err = capsys.readouterr().err + assert "stdin:4" in err + assert_no_leak(err, MARKER_SECRET, what="a malformed input line") + + +def test_set_refuses_empty_stdin_rather_than_sealing_nothing( + initialised, monkeypatch, capsys +): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, "\n \n") + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert "no credentials on stdin" in capsys.readouterr().err + assert store.store_profiles(initialised) == [] + + +def test_set_with_a_wrong_password_is_refused_by_the_verifier( + initialised, monkeypatch, capsys +): + """The store-level verifier is what makes this immediate: without it, an + envelope sealed under a mistyped password would be written happily and + fail weeks later, at first use, as unexplained damage.""" + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, WRONG_PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "staging"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + err = capsys.readouterr().err + assert "wrong password" in err + assert_no_leak(err, WRONG_PASSWORD, what="the store password") + + +def test_set_replaces_the_whole_envelope(initialised, monkeypatch): + """Documented behaviour, pinned: `store.set_credentials` writes what it + is given rather than merging, which is why every required name has to be + supplied on every run.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + first = {**FULL_CREDENTIALS, "EXTRA_NAME": "Nt4Bq8Wz2Ck6Hm9Rd"} + pipe(monkeypatch, env_text(first)) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + + +def test_set_prompts_for_each_name_without_echo_when_stdin_is_a_terminal( + initialised, monkeypatch +): + """The interactive path. `answer_prompts` refuses `builtins.input` + outright, so an implementation that echoed what was typed fails here + rather than passing quietly.""" + terminal(monkeypatch) + answer_prompts( + monkeypatch, + { + f"{AWS_ACCESS_KEY_ID}: ": MARKER_KEY, + f"{AWS_SECRET_ACCESS_KEY}: ": MARKER_SECRET, + f"{PULUMI_CONFIG_PASSPHRASE}: ": MARKER_PASSPHRASE, + session.DEFAULT_PASSWORD_PROMPT: PASSWORD, + }, + ) + + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + + +def test_set_refuses_a_profile_that_has_no_table_in_config( + initialised, monkeypatch, capsys +): + """Profile selection is `session._resolve_profile`, unchanged: a name + that no `[profile.]` declares is an error rather than a silent + fall-through, so credentials cannot be sealed under a typo that nothing + will ever read.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set", "--profile", "typo"]) == 2 + + assert "typo" in capsys.readouterr().err + assert store.store_profiles(initialised) == [] + + +def test_set_honours_the_same_profile_precedence_as_exec(initialised, monkeypatch): + """`STACKWARD_PROFILE` with no `--profile`: the tier below the flag. If + this module selected profiles its own way, the two commands could seal + and read different envelopes from the same environment.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv(store.ENV_PROFILE, "staging") + pipe(monkeypatch, env_text(FULL_CREDENTIALS)) + + assert main(["credentials", "set"]) == 0 + + assert store.store_profiles(initialised) == ["staging"] + + +# --------------------------------------------------------------------------- +# `credentials list` and `credentials show` +# --------------------------------------------------------------------------- + + +def test_list_prints_the_profiles_that_have_credentials( + initialised, monkeypatch, capsys +): + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "list"]) == 0 + + captured = capsys.readouterr() + assert captured.out.splitlines() == ["staging"] + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_list_needs_no_password_at_all(initialised, monkeypatch): + """`store_profiles` reads the document's keys and opens nothing, so a + listing must not prompt -- and must not fail on a machine where no + password is available.""" + + def refuse(*_args, **_kwargs): + raise _PromptViolation("listing asked for the store password") + + monkeypatch.setattr(session, "_read_store_password", refuse) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "list"]) == 0 + + +def test_list_before_init_names_the_command_that_fixes_it(configured, capsys): + """Global Constraint 3: a refusal says what to do about it.""" + assert main(["credentials", "list"]) == 2 + assert "credentials init" in capsys.readouterr().err + + +def test_list_says_so_when_no_profile_has_credentials_yet(initialised, capsys): + assert main(["credentials", "list"]) == 0 + + captured = capsys.readouterr() + assert captured.out == "" + assert "credentials set" in captured.err + + +def test_show_lists_names_and_never_values(initialised, monkeypatch, capfd): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + for name in session.CREDENTIAL_NAMES: + assert name in captured.out + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_show_reports_a_shortfall_without_failing(initialised, monkeypatch, capfd): + """The question was answered correctly and the store is not in a state + anything needs to refuse, so this exits 0 -- `exec` is what refuses an + incomplete envelope, and already does.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + partial = {AWS_ACCESS_KEY_ID: MARKER_KEY} + store.set_credentials("staging", partial, PASSWORD, directory=initialised) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + assert AWS_SECRET_ACCESS_KEY in captured.err + assert_no_leak(captured.out + captured.err, MARKER_KEY, what="a credential") + + +def test_show_names_what_is_sealed_but_never_injected( + initialised, monkeypatch, capfd +): + """`set` seals every name it is given; `exec`/`shell` inject three. + + Sealing the extras is intended -- `< .env` is the documented workflow + and a real `.env` carries more than three names -- so the defect was + never the behaviour, it was that nothing anywhere said so. `show` is + where a person would notice a fourth name, so it is where the sentence + belongs; without it the listing states a fact and leaves its meaning to + be guessed at. + """ + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + store.set_credentials( + "staging", + {**FULL_CREDENTIALS, EXTRA_NAME: MARKER_EXTRA}, + PASSWORD, + directory=initialised, + ) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + captured = capfd.readouterr() + # Sealed and listed, exactly as before -- the note explains it, it does + # not hide it. + assert EXTRA_NAME in captured.out + assert EXTRA_NAME in captured.err + assert "never injected" in captured.err + for marker in ALL_MARKERS: + assert_no_leak(captured.out + captured.err, marker, what="a credential") + + +def test_show_says_nothing_about_extras_when_there_are_none( + initialised, monkeypatch, capfd +): + """The other half: a note that fires on the ordinary envelope is noise, + and noise is how the useful half stops being read.""" + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + + assert main(["credentials", "show", "--profile", "staging"]) == 0 + + assert "never injected" not in capfd.readouterr().err + + +def test_show_on_a_profile_with_no_envelope_says_so(initialised, monkeypatch, capsys): + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "show", "--profile", "staging"]) == 2 + + assert "no credentials" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# `credentials rotate` +# --------------------------------------------------------------------------- + + +def test_rotate_reseals_every_envelope_under_the_new_password( + initialised, monkeypatch +): + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + store.set_credentials("other", FULL_CREDENTIALS, PASSWORD, directory=initialised) + answer_prompts( + monkeypatch, + { + OLD_PASSWORD_PROMPT: PASSWORD, + NEW_PASSWORD_PROMPT: NEW_PASSWORD, + CONFIRM_PASSWORD_PROMPT: NEW_PASSWORD, + }, + ) + + assert main(["credentials", "rotate"]) == 0 + + for name in ("staging", "other"): + assert ( + store.resolve_credentials(name, NEW_PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + with pytest.raises(store.PasswordError): + store.resolve_credentials(name, PASSWORD, directory=initialised) + + +def test_rotate_refuses_a_mismatched_confirmation_and_changes_nothing( + initialised, monkeypatch, capsys +): + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + answer_prompts( + monkeypatch, + { + OLD_PASSWORD_PROMPT: PASSWORD, + NEW_PASSWORD_PROMPT: NEW_PASSWORD, + CONFIRM_PASSWORD_PROMPT: WRONG_PASSWORD, + }, + ) + + assert main(["credentials", "rotate"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + assert "do not match" in capsys.readouterr().err + + +def test_rotate_refuses_when_the_environment_variable_is_the_only_source( + initialised, monkeypatch, capsys +): + """`STACKWARD_PASSWORD` answers every read of the store password, so with + it set the "old" and "new" passwords are the same string and the user was + asked nothing. Reporting that as a bare sameness would describe something + they never typed, so the refusal names the variable and the fix.""" + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + before = (initialised / store.CREDENTIALS_FILENAME).read_bytes() + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + assert main(["credentials", "rotate"]) == 2 + + assert (initialised / store.CREDENTIALS_FILENAME).read_bytes() == before + err = capsys.readouterr().err + assert ENV_STORE_PASSWORD in err + assert "unset" in err + + +def test_rotate_that_cannot_reseal_one_envelope_changes_nothing( + initialised, monkeypatch, capsys +): + """All-or-nothing, surfaced honestly. A partial rotation would leave a + store whose profiles need different passwords with nothing recording + which is which -- the worst outcome available -- and reporting a failed + rotation as success would be the second worst.""" + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=initialised) + path = initialised / store.CREDENTIALS_FILENAME + document = json.loads(path.read_text()) + document["profiles"]["damaged"] = document["profiles"]["staging"] + path.write_text(json.dumps(document)) + before = path.read_bytes() + + answer_prompts( + monkeypatch, + { + OLD_PASSWORD_PROMPT: PASSWORD, + NEW_PASSWORD_PROMPT: NEW_PASSWORD, + CONFIRM_PASSWORD_PROMPT: NEW_PASSWORD, + }, + ) + + assert main(["credentials", "rotate"]) == 2 + + assert path.read_bytes() == before + assert ( + store.resolve_credentials("staging", PASSWORD, directory=initialised) + == FULL_CREDENTIALS + ) + assert "nothing has been changed" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Dispatch: registered, and off the gate path +# --------------------------------------------------------------------------- + + +_IMPORT_PROBE = """ +import sys +from stackward.cli import build_parser +build_parser() +print("loaded", "stackward.commands.credentials" in sys.modules) +""" + + +def test_building_the_parser_does_not_import_the_credentials_module(): + """`credentials` imports `store` -> `crypto` -> `cryptography`, and + `build_parser` runs on *every* invocation, `check-config` and + `pre-commit` included. `tests/test_gate_isolation.py` asserts the + property over the union of forbidden modules; this asserts the mechanism + that keeps it true for this command specifically, so that a regression + here is reported as what it is rather than as a module-list diff. + + A subprocess, because this test session has already imported the module + directly at the top of this file. + """ + proc = subprocess.run( + [sys.executable, "-c", _IMPORT_PROBE], + capture_output=True, + text=True, + check=True, + ) + assert "loaded False" in proc.stdout + + +@pytest.mark.parametrize( + "argv", + [ + ["credentials", "init"], + ["credentials", "set"], + ["credentials", "list"], + ["credentials", "show"], + ["credentials", "rotate"], + ], +) +def test_every_subcommand_is_registered_and_dispatches(argv, cli_store, monkeypatch): + """Wiring only. Each must reach its own entry point -- which then refuses, + because `cli_store` points `XDG_CONFIG_HOME` at a directory holding no + store and no profile -- rather than argparse's "invalid choice", which + would raise `SystemExit` here instead of returning. + + `_tty_available` is forced false so that the two subcommands which read a + password (`init`, `rotate`) refuse instead of prompting: without it this + test would block on `/dev/tty` for anyone running the suite from a + terminal, and pass only on machines that have none. + """ + monkeypatch.setattr(session, "_tty_available", lambda: False) + assert main(argv) == 2 + + +def test_a_bare_credentials_command_prints_help_rather_than_guessing(capsys): + assert main(["credentials"]) == 2 + assert "usage:" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# The migration the plan's Step 2 exit criterion names, end to end. +# --------------------------------------------------------------------------- + + +_STUB = """\ +import json, os, sys +with open(os.environ["STUB_DUMP_ENV_TO"], "w") as handle: + json.dump(dict(os.environ), handle) +""" + + +def test_a_dotenv_file_can_be_sealed_and_then_deleted_and_exec_still_works( + configured, monkeypatch, tmp_path, capfd +): + """The plan's Step 2 exit criterion, walked end to end. + + "`stackward exec -- pulumi ...` works with the repo `.env` **renamed**, + then delete the `.env` bootstrap copies." Every part of that is asserted + rather than assumed: + + * the values reach the child from the **store**, not from the + environment -- the three credential names are deleted from this + process's own environment first, and the assertion at the end is that + no `STACKWARD_*` variable survived either, so the `env` provider and + the master-password variable are both out of the picture; + * no `.env` exists anywhere under `tmp_path` when `exec` runs, so the + `dotenv` provider cannot be what answered; + * the password is *typed*, not exported, which is the path a fresh user + actually has; + * and nothing leaks: the whole run's output is checked at + file-descriptor level (`capfd`, not `capsys` -- a real child's writes + do not pass through `sys.stdout`). + + A stub child rather than `pulumi`, for the reason `test_session.py` + gives: what is under test is this tool's environment handling, not + Pulumi's presence on the machine. + """ + # 1. The bootstrap file a repository has today. + bootstrap = tmp_path / "bootstrap-values" + bootstrap.write_text(env_text(FULL_CREDENTIALS)) + + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.chdir(repo) + answer_prompts( + monkeypatch, + { + NEW_PASSWORD_PROMPT: PASSWORD, + CONFIRM_PASSWORD_PROMPT: PASSWORD, + session.DEFAULT_PASSWORD_PROMPT: PASSWORD, + }, + ) + + # 2. Seal it, exactly as `stackward credentials set --profile staging + # < .env` would. + assert main(["credentials", "init"]) == 0 + pipe(monkeypatch, bootstrap.read_text()) + assert main(["credentials", "set", "--profile", "staging"]) == 0 + + # 3. Delete the bootstrap copy. Nothing on disk holds these values in + # plaintext from here on. + bootstrap.unlink() + assert list(tmp_path.rglob("*.env")) == [] + assert list(tmp_path.rglob(".env")) == [] + # Named, not swept by prefix. A `STACKWARD_*` prefix check would also + # catch `STACKWARD_DENYLIST`, which this repository's own README tells + # developers to export and `scripts/pre-push` reads -- so the test would + # fail on the machine of anyone who followed the instructions, for a + # reason with nothing to do with the store. These three names are what + # the claim is actually about. + assert ENV_STORE_PASSWORD not in os.environ + assert store.ENV_PROFILE not in os.environ + for name in session.CREDENTIAL_NAMES: + assert name not in os.environ + + # 4. `exec` a child, and read back what it actually received. + stub = tmp_path / "stub.py" + stub.write_text(_STUB) + dump = tmp_path / "child-env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + + assert main(["exec", "--profile", "staging", "--", sys.executable, str(stub)]) == 0 + + child_env = json.loads(dump.read_text()) + assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY + assert child_env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET + assert child_env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE + assert child_env[PULUMI_BACKEND_URL] == BACKEND_URL + + captured = capfd.readouterr() + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out + captured.err, marker, what="a credential") diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..2b43e3f --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,520 @@ +"""Tests for the sealed envelope. + +Each test is named for the property it proves, and several are written +specifically so that they cannot pass for the wrong reason. The three that +matter most: + +- a wrong password must fail *through the GCM tag*, so the test asserts on the + underlying `InvalidTag` and not merely on "something was raised" — a length + check or a plaintext comparison would satisfy the weaker assertion while + leaving unauthenticated bytes reachable; +- an envelope must not open under a different AAD, which is the only evidence + that the AAD is genuinely bound rather than passed and ignored; +- the ciphertext must contain none of the plaintext, asserted against a known + marker value's bytes in three encodings, so that "we base64-encoded it and + called it sealed" fails loudly. + +No test prints a password or a plaintext on any path, including a failing one: +assertions are on absence and on exception types, never on captured secret text. +""" + +from __future__ import annotations + +import base64 +import json + +import pytest +from cryptography.exceptions import InvalidTag, UnsupportedAlgorithm + +from leakcheck import assert_no_leak + +from stackward import crypto +from stackward.crypto import ( + ARGON2ID_ITERATIONS, + ARGON2ID_LANES, + ARGON2ID_MEMORY_KIB, + ENVELOPE_VERSION, + KDF_NAME, + KEY_BYTES, + NONCE_BYTES, + SALT_BYTES, + CryptoError, + DecryptionError, + EmptyPasswordError, + EnvelopeError, + describe_value, + seal, + seal_json, + unseal, + unseal_json, +) + +PASSWORD = "correct password for these tests" +OTHER_PASSWORD = "a different password entirely" +AAD = "profile-one" +OTHER_AAD = "profile-two" + +# A value chosen to be findable: if any part of it survives into the envelope, +# the absence assertions below will say so. +MARKER = b"marker-value-never-to-appear-in-ciphertext" + + +def test_seal_and_unseal_round_trip_returns_the_exact_plaintext(): + envelope = seal(MARKER, PASSWORD, AAD) + assert unseal(envelope, PASSWORD, AAD) == MARKER + + +def test_a_wrong_password_fails_through_the_gcm_tag_rather_than_returning_garbage(): + """The failure must come from authentication, not from a check this module + performs after the fact — otherwise unauthenticated plaintext exists + somewhere in the call, however briefly.""" + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(DecryptionError) as raised: + unseal(envelope, OTHER_PASSWORD, AAD) + assert isinstance(raised.value.__cause__, InvalidTag) + + +def test_an_envelope_sealed_under_one_profile_does_not_open_under_another(): + """The AAD is what stops an envelope being moved between profiles. A build + that accepted the `aad` argument and never passed it to AES-GCM would pass + the round-trip test above and fail here.""" + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(DecryptionError) as raised: + unseal(envelope, PASSWORD, OTHER_AAD) + assert isinstance(raised.value.__cause__, InvalidTag) + + +def test_the_envelope_contains_none_of_the_plaintext(): + envelope = json.dumps(seal(MARKER, PASSWORD, AAD)).encode("utf-8") + assert MARKER not in envelope + assert base64.b64encode(MARKER) not in envelope + assert MARKER.hex().encode("ascii") not in envelope + + +def test_the_envelope_contains_none_of_the_password(): + envelope = json.dumps(seal(MARKER, PASSWORD, AAD)).encode("utf-8") + assert PASSWORD.encode("utf-8") not in envelope + + +def test_every_seal_draws_a_fresh_salt_and_nonce(): + """Sealing the same plaintext twice must produce three different fields. + Equal ciphertexts would mean a reused nonce, which for GCM is not a + weakness but a break.""" + first = seal(MARKER, PASSWORD, AAD) + second = seal(MARKER, PASSWORD, AAD) + assert first["salt"] != second["salt"] + assert first["nonce"] != second["nonce"] + assert first["ct"] != second["ct"] + + +def test_a_modified_ciphertext_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + raw = bytearray(base64.b64decode(envelope["ct"])) + raw[0] ^= 0x01 + envelope["ct"] = base64.b64encode(bytes(raw)).decode("ascii") + with pytest.raises(DecryptionError) as raised: + unseal(envelope, PASSWORD, AAD) + assert isinstance(raised.value.__cause__, InvalidTag) + + +def test_the_envelope_has_exactly_the_documented_fields(): + envelope = seal(MARKER, PASSWORD, AAD) + assert set(envelope) == {"v", "kdf", "salt", "nonce", "ct"} + assert envelope["v"] == ENVELOPE_VERSION + assert envelope["kdf"]["name"] == KDF_NAME + for field in ("salt", "nonce", "ct"): + assert isinstance(envelope[field], str) + base64.b64decode(envelope[field], validate=True) + + +def test_the_shipped_cost_parameters_are_the_documented_ones(): + """Pinned deliberately. `tests/test_store.py` lowers these for speed, so + without this test a production weakening would go unnoticed.""" + assert (ARGON2ID_MEMORY_KIB, ARGON2ID_ITERATIONS, ARGON2ID_LANES) == (65536, 3, 4) + assert (KEY_BYTES, SALT_BYTES, NONCE_BYTES) == (32, 16, 12) + + +def test_the_envelope_records_its_parameters_so_a_later_default_change_still_opens_it( + monkeypatch, +): + """The reason `kdf` carries parameters rather than only a name: raising the + cost for new envelopes must not orphan every envelope already on disk.""" + envelope = seal(MARKER, PASSWORD, AAD) + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", ARGON2ID_MEMORY_KIB * 2) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", ARGON2ID_ITERATIONS + 1) + assert unseal(envelope, PASSWORD, AAD) == MARKER + assert seal(MARKER, PASSWORD, AAD)["kdf"]["memory_kib"] == ARGON2ID_MEMORY_KIB * 2 + + +def test_a_password_is_normalised_so_the_same_characters_open_the_same_envelope(): + """The same text entered on two platforms can arrive as two different byte + sequences — precomposed or decomposed. Without NFC normalisation the store + would open only on the machine that created it, and the failure would look + like a forgotten password.""" + # Written as escapes rather than as literal characters: an editor or a + # tool that normalised this source file would otherwise quietly turn + # this into a test of nothing. + precomposed = "passw\u00f6rd" # o-with-diaeresis, one code point + decomposed = "passwo\u0308rd" # o, then a combining diaeresis + assert precomposed != decomposed + envelope = seal(MARKER, precomposed, AAD) + assert unseal(envelope, decomposed, AAD) == MARKER + + +# --------------------------------------------------------------------------- +# Malformed input. `credentials` is hand-editable, so every one of these is +# reachable, and each must produce this module's own error rather than the +# library's — a caller catching `CryptoError` must not also have to enumerate +# `ValueError`, `OverflowError`, `TypeError` and `MemoryError` to stay closed. +# --------------------------------------------------------------------------- + + +def test_an_unsupported_envelope_version_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["v"] = ENVELOPE_VERSION + 1 + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +def test_an_unknown_kdf_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["kdf"]["name"] = "something-else" + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +# --------------------------------------------------------------------------- +# `describe_value` -- the guard on the two header fields whose *contents* get +# printed back. Untested until now: `return repr(value)` for every input +# passed the entire suite, because the two tests that reach the branch +# (`test_an_unsupported_envelope_version_is_refused` and +# `test_an_unknown_kdf_is_refused`) assert only that something was raised. +# --------------------------------------------------------------------------- + +# Opaque on purpose -- see `tests/leakcheck.py`. A marker containing a real +# word would share an eight-character run with the words these very messages +# print ("version", "unsupported"), and the run check would fire on output +# that disclosed nothing. +PASTED_INTO_A_HEADER_FIELD = "Zq7Xv4Rm2Kt9Lp5Nc8Wd6Hb3Jf" + + +@pytest.mark.parametrize( + "value", + [ + "some string", + PASTED_INTO_A_HEADER_FIELD, + ["a", "list"], + {"a": "dict"}, + b"some bytes", + ], + # Named, so that a value never reaches a test id: pytest builds ids out + # of the parameters themselves, and a failure report is output like any + # other (GC4). + ids=["str", "pasted-credential", "list", "dict", "bytes"], +) +def test_describe_value_reports_a_type_and_never_the_value(value): + described = describe_value(value) + assert described == f"<{type(value).__name__}>" + assert str(value) not in described + + +@pytest.mark.parametrize("value", [None, 0, 1, -7, 1.5, True]) +def test_describe_value_shows_what_cannot_carry_a_credential(value): + """A number, a bool and `None` are shown, because the message is far more + useful naming the version it actually found than reporting `` -- and + none of the three can be a pasted credential. `True` is included + deliberately: `bool` is an `int` subclass, so it takes the shown branch, + and a future rewrite that tightened the check to `type(value) is int` + would silently start reporting ``.""" + assert describe_value(value) == repr(value) + + +def test_a_credential_pasted_into_the_version_field_is_not_read_back(): + """`v` is hand-editable and this message is printed. A user who pasted a + credential one field too high must not have it echoed by the error that + caught the mistake.""" + envelope = seal(MARKER, PASSWORD, AAD) + envelope["v"] = PASTED_INTO_A_HEADER_FIELD + + with pytest.raises(EnvelopeError) as raised: + unseal(envelope, PASSWORD, AAD) + + message = str(raised.value) + assert_no_leak(message, PASTED_INTO_A_HEADER_FIELD, what="a pasted header value") + assert "" in message + + +def test_a_credential_pasted_into_the_kdf_name_is_not_read_back(): + """The second call site, which the version test above cannot cover: the + two are separate `describe_value` calls and a fix applied to one only + would leave the other echoing.""" + envelope = seal(MARKER, PASSWORD, AAD) + envelope["kdf"]["name"] = PASTED_INTO_A_HEADER_FIELD + + with pytest.raises(EnvelopeError) as raised: + unseal(envelope, PASSWORD, AAD) + + message = str(raised.value) + assert_no_leak(message, PASTED_INTO_A_HEADER_FIELD, what="a pasted header value") + assert "" in message + + +@pytest.mark.parametrize( + ("key", "value"), + [ + # Rejected by _MAX_MEMORY_KIB before it reaches the library. Note this + # bound has a fallback: were it gone, `_derive` would still map the + # library's MemoryError. The `iterations` ceiling below does not. + ("memory_kib", 2**31), + ("memory_kib", 1), + ("memory_kib", 0), + ("memory_kib", -1), + ("memory_kib", "65536"), + ("memory_kib", True), + ("memory_kib", 65536.0), + ("iterations", 0), + ("iterations", -1), + # The case the bounds exist for: without the ceiling this does not + # raise, it runs — the test hangs rather than failing. + ("iterations", 10**9), + ("lanes", 0), + ("lanes", 2**40), + ("lanes", None), + ], +) +def test_a_hand_edited_kdf_parameter_is_refused_before_it_reaches_the_library(key, value): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["kdf"][key] = value + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +@pytest.mark.parametrize("field", ["salt", "nonce", "ct"]) +def test_a_field_that_is_not_valid_base64_is_refused(field): + envelope = seal(MARKER, PASSWORD, AAD) + envelope[field] = "not base64!!" + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +@pytest.mark.parametrize("field", ["salt", "nonce", "ct", "kdf", "v"]) +def test_a_missing_field_is_refused(field): + envelope = seal(MARKER, PASSWORD, AAD) + del envelope[field] + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +def test_a_nonce_of_the_wrong_length_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["nonce"] = base64.b64encode(b"\x00" * (NONCE_BYTES + 1)).decode("ascii") + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +def test_a_salt_shorter_than_the_floor_is_refused(): + envelope = seal(MARKER, PASSWORD, AAD) + envelope["salt"] = base64.b64encode(b"\x00" * 4).decode("ascii") + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +@pytest.mark.parametrize("envelope", ["a string", 17, None, ["a", "list"]]) +def test_something_that_is_not_an_envelope_at_all_is_refused(envelope): + with pytest.raises(EnvelopeError): + unseal(envelope, PASSWORD, AAD) + + +# --------------------------------------------------------------------------- +# The JSON layer +# --------------------------------------------------------------------------- + + +def test_seal_json_round_trips_a_mapping(): + payload = {"NAME_ONE": "value-one", "NAME_TWO": "value-two"} + envelope = seal_json(payload, PASSWORD, AAD) + assert unseal_json(envelope, PASSWORD, AAD) == payload + + +def test_seal_json_does_not_depend_on_the_order_a_mapping_was_built_in(): + first = seal_json({"A": "1", "B": "2"}, PASSWORD, AAD) + second = seal_json({"B": "2", "A": "1"}, PASSWORD, AAD) + assert len(base64.b64decode(first["ct"])) == len(base64.b64decode(second["ct"])) + assert unseal_json(first, PASSWORD, AAD) == unseal_json(second, PASSWORD, AAD) + + +def test_authenticated_plaintext_that_is_not_json_is_reported_rather_than_crashing(): + envelope = seal(b"\xff\xfe not json at all", PASSWORD, AAD) + with pytest.raises(EnvelopeError): + unseal_json(envelope, PASSWORD, AAD) + + +def test_unseal_json_does_not_parse_anything_the_tag_rejected(): + """Ordering property: JSON parsing must sit behind authentication, never in + front of it. A wrong password produces a decryption error, never a parse + error, because nothing is parsed at all.""" + envelope = seal_json({"NAME": "value"}, PASSWORD, AAD) + with pytest.raises(DecryptionError): + unseal_json(envelope, OTHER_PASSWORD, AAD) + + +def test_no_error_message_repeats_the_password_or_the_plaintext(): + envelope = seal(MARKER, PASSWORD, AAD) + messages: list[str] = [] + for broken in ( + {**envelope, "v": 99}, + {**envelope, "salt": "!!"}, + {**envelope, "kdf": {**envelope["kdf"], "memory_kib": 2**31}}, + envelope, + ): + try: + unseal(broken, OTHER_PASSWORD, OTHER_AAD) + except Exception as exc: # noqa: BLE001 - the messages are the subject + messages.append(str(exc)) + assert messages + for message in messages: + assert MARKER.decode("ascii") not in message + assert PASSWORD not in message + assert OTHER_PASSWORD not in message + + +# --------------------------------------------------------------------------- +# The password itself +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("password", ["", " ", "\t", "\n", " \t\n "]) +def test_an_absent_password_is_refused_rather_than_deriving_a_key_from_nothing(password): + """Argon2id derives a perfectly good 32-byte key from `b""` — verified + against the pinned library — so without this check an empty password seals + a real store that opens with no password at all, indistinguishably from one + that was protected. Strength is the prompting command's business; emptiness + is not, because "" cannot be told apart from "nothing was supplied". + """ + with pytest.raises(EmptyPasswordError): + seal(MARKER, password, AAD) + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(EmptyPasswordError): + unseal(envelope, password, AAD) + + +def test_an_absent_password_is_refused_as_a_crypto_error(): + """`EmptyPasswordError` must sit under `CryptoError`, or `store.py`'s + `except CryptoError` boundaries would not see it at all.""" + assert issubclass(EmptyPasswordError, CryptoError) + + +def test_a_password_is_refused_but_never_trimmed(): + """Whitespace-only is refused; a password with significant surrounding + whitespace is honoured exactly as typed. Stripping would silently change + what the user entered into something else.""" + padded = " " + PASSWORD + " " + envelope = seal(MARKER, padded, AAD) + assert unseal(envelope, padded, AAD) == MARKER + with pytest.raises(DecryptionError): + unseal(envelope, PASSWORD, AAD) + + +def test_a_backend_that_cannot_do_argon2id_fails_closed_as_a_crypto_error(monkeypatch): + """Argon2id needs an OpenSSL 3.2+ backend; on an older one `cryptography` + raises `UnsupportedAlgorithm`, which inherits straight from `Exception`. + Uncaught it would sail past every `except CryptoError` in `store.py` as a + raw traceback, on the one class of machine where nothing about the store + works. Simulated, because this build's backend does support it. + """ + + def unsupported(*_args, **_kwargs): + raise UnsupportedAlgorithm("no Argon2id in this backend") + + monkeypatch.setattr(crypto, "Argon2id", unsupported) + with pytest.raises(CryptoError): + seal(MARKER, PASSWORD, AAD) + + +# --------------------------------------------------------------------------- +# A lone surrogate — reachable once a provider feeds this module a password +# read out of `os.environ`, which Python decodes with `surrogateescape` +# rather than raising. See `EncodingError`. +# --------------------------------------------------------------------------- + +# One low surrogate `os.environ` would actually produce for a byte that was +# never valid UTF-8 (`os.fsdecode(b"\x80")`, on a POSIX filesystem encoding) — +# not an arbitrary codepoint picked for the shape alone. +SURROGATE_PASSWORD = "before\udc80after" + + +def test_a_lone_surrogate_password_fails_as_a_crypto_error_not_a_bare_one(): + """`_normalise`'s `str.encode("utf-8")` is not JSON-escaped the way a + `seal_json` payload is, so a password containing a lone surrogate must + raise `EncodingError` (a `CryptoError`), not let Python's own + `UnicodeEncodeError` escape past every `except CryptoError` boundary in + `store.py`.""" + with pytest.raises(crypto.EncodingError): + seal(MARKER, SURROGATE_PASSWORD, AAD) + + +def test_encoding_error_is_a_crypto_error(): + """`pytest.raises(crypto.EncodingError)` above proves the *specific* + type; this proves the property those tests actually rely on for + `store.py`'s `except CryptoError` boundaries to see it at all -- the + same distinction `test_an_absent_password_is_refused_as_a_crypto_error` + draws for `EmptyPasswordError`.""" + assert issubclass(crypto.EncodingError, CryptoError) + + +def test_a_lone_surrogate_password_fails_closed_on_unseal_too(): + """`_derive` — and therefore `_normalise` — sits under `unseal` as well + as `seal`; the guard must hold on the read path, not only the write + path a review first flagged this on.""" + envelope = seal(MARKER, PASSWORD, AAD) + with pytest.raises(crypto.EncodingError): + unseal(envelope, SURROGATE_PASSWORD, AAD) + + +def test_a_lone_surrogate_password_without_the_guard_would_raise_a_bare_unicode_error(): + """Proves the failure this guard exists for is real, not hypothetical: + the exact same encode, done the way `_normalise` used to do it, raises + Python's own `UnicodeEncodeError` — outside `CryptoError` entirely.""" + import unicodedata + + with pytest.raises(UnicodeEncodeError): + unicodedata.normalize("NFC", SURROGATE_PASSWORD).encode("utf-8") + + +def test_seal_json_payload_with_a_lone_surrogate_round_trips_without_raising(): + """The opposite finding, stated as a test: `json.dumps`'s default + `ensure_ascii=True` escapes a lone surrogate in the *payload* to plain + ASCII before it is ever encoded, so `seal_json` does not raise on one — + unlike the password case above. Documented here so a future change to + `seal_json`'s `ensure_ascii`/`separators` that reintroduced the failure + would be caught by `test_a_lone_surrogate_payload_value_fails_as_a_crypto_error` + below, and so this module's own claim about where the bug does and does + not live is verified, not merely asserted in a docstring.""" + envelope = seal_json({"NAME": SURROGATE_PASSWORD}, PASSWORD, AAD) + assert unseal_json(envelope, PASSWORD, AAD) == {"NAME": SURROGATE_PASSWORD} + + +def test_a_lone_surrogate_payload_value_fails_as_a_crypto_error(monkeypatch): + """`seal_json`'s own defensive guard, exercised directly: forces the + `ensure_ascii` escaping that normally protects this call to fail, so the + `except UnicodeEncodeError` inside `seal_json` itself is proven live + rather than merely present in the source.""" + + class _Unencodable(str): + def encode(self, *_args, **_kwargs): + raise UnicodeEncodeError("utf-8", self, 0, 1, "simulated") + + def fake_dumps(*_args, **_kwargs): + return _Unencodable("{}") + + monkeypatch.setattr(crypto.json, "dumps", fake_dumps) + with pytest.raises(crypto.EncodingError): + seal_json({"NAME": "value"}, PASSWORD, AAD) + + +def test_a_lone_surrogate_aad_fails_as_a_crypto_error(): + """The same guard on `aad.encode("utf-8")` in `seal`/`unseal` — no + current caller passes an AAD sourced from the environment, but the encode + is identical in shape to the password's, and identically reachable.""" + with pytest.raises(crypto.EncodingError): + seal(MARKER, PASSWORD, SURROGATE_PASSWORD) diff --git a/tests/test_gate_isolation.py b/tests/test_gate_isolation.py new file mode 100644 index 0000000..6c5e334 --- /dev/null +++ b/tests/test_gate_isolation.py @@ -0,0 +1,215 @@ +"""Global Constraint 2, tested as one property rather than three halves. + + "The gate resolves no credentials. `check-config` and `pre-commit` read + files only. No code reachable from them may prompt, open a network + connection, or import a credential provider." + +`README.md` states the same thing under the heading "These are constraints, +not aspirations — each has a test", and adds the consequence users actually +care about: a commit "cannot be blocked by an expired session or a flaky +connection". + +Two reviews found that claim under-tested in three separate ways, and this +module exists because each gap was invisible from inside the test that was +supposed to cover it: + +* `tests/test_model_net.py` checked `cryptography` and `stackward.store`, but + imported the two command modules **directly**, bypassing `cli.py` — which + imported `commands.check_passphrase` (-> `cryptography`) and + `commands.session` (-> `store` -> `crypto`) at its own module scope. Every + real invocation goes through `cli.py`. +* `tests/test_providers.py` did go through `cli.main`, but grepped only for + `stackward.providers*`, so it saw none of that. +* Nothing at all tested "never prompt" or "never touch the network". + Inserting a real `store.resolve_credentials(...)` call into + `cmd_check_config` survived the entire suite, and blocked on a `getpass` + prompt at a terminal. + +So the import check is one assertion over the union of every forbidden +module, taken through the dispatch path a `git commit` really takes, and the +prompting and network checks are their own tests over both gate commands. +""" + +from __future__ import annotations + +import getpass +import socket +import subprocess +import sys +from pathlib import Path + +import pytest + +from stackward.cli import main + +MINIMAL_POLICY = '[check]\nmodel_net = "none"\n' + +# Every module the gate must not reach, as prefixes. `stackward.providers` +# and `cryptography` are matched with their submodules too, so a provider or +# a backend added later is covered without editing this list. +FORBIDDEN_PREFIXES = ( + "stackward.providers", + "stackward.store", + "stackward.crypto", + "cryptography", +) + +_PROBE = """ +import sys +from stackward.cli import main +code = main(["check-config", {target!r}]) +forbidden = sorted( + module + for module in sys.modules + for prefix in {prefixes!r} + if module == prefix or module.startswith(prefix + ".") +) +print("exit", code) +print("forbidden", forbidden) +""" + + +def test_the_gate_path_reaches_no_credential_or_provider_code(tmp_path): + """One assertion over the union, through `cli.main`. + + A subprocess, because this test process has already imported half the + tool through other test modules in the same session — asserting against + its own `sys.modules` would prove nothing. + + The exit code is asserted alongside the module list on purpose: a + `check-config` that refused before doing any work would import nothing + either, and would pass this test while proving the opposite of what it + claims. + """ + (tmp_path / ".stackward.toml").write_text(MINIMAL_POLICY) + target = tmp_path / "Pulumi.dev.yaml" + target.write_text("name: myproject\n") + + proc = subprocess.run( + [sys.executable, "-c", _PROBE.format(target=str(target), prefixes=FORBIDDEN_PREFIXES)], + cwd=tmp_path, + capture_output=True, + text=True, + check=True, + ) + assert "exit 0" in proc.stdout + assert "forbidden []" in proc.stdout + + +class GateViolation(BaseException): + """Raised by the `sealed` fixture when the gate does something Global + Constraint 2 forbids. See that fixture for why it is not an + `Exception`.""" + + +@pytest.fixture +def sealed(monkeypatch): + """Make prompting and networking impossible, loudly. + + `GateViolation` derives from `BaseException`, not `Exception`, and that + is the whole trick: `check_config.fail_closed` converts any `Exception` + into `return 2`, so a control raised as an `AssertionError` inside a + gate command is silently swallowed and the test then asserts on an exit + code that looks like an ordinary refusal — it would pass whether the + forbidden call happened or not. (That trap has already caught one + reviewer on this project.) + + `KeyboardInterrupt` would also survive the boundary, but pytest treats + it as "the human wants out" and aborts the whole session instead of + failing the one test, which turns a precise signal into a confusing + one. A private `BaseException` subclass survives `fail_closed` *and* is + reported as an ordinary failure naming this test. + """ + + def refuse(reason): + def refusing(*_args, **_kwargs): + raise GateViolation(reason) + + return refusing + + monkeypatch.setattr(getpass, "getpass", refuse("the gate prompted for a password")) + monkeypatch.setattr("builtins.input", refuse("the gate prompted on stdin")) + monkeypatch.setattr(socket, "socket", refuse("the gate opened a socket")) + monkeypatch.setattr( + socket, "create_connection", refuse("the gate opened a connection") + ) + + +def test_check_config_neither_prompts_nor_connects(sealed, tmp_path, monkeypatch): + (tmp_path / ".stackward.toml").write_text(MINIMAL_POLICY) + target = tmp_path / "Pulumi.dev.yaml" + target.write_text("name: myproject\n") + monkeypatch.chdir(tmp_path) + + assert main(["check-config", str(target)]) == 0 + + +def test_check_config_neither_prompts_nor_connects_on_a_finding( + sealed, tmp_path, monkeypatch +): + """The finding path, not only the clean one: reporting a credential is + where a credential-resolving call would most plausibly be added.""" + (tmp_path / ".stackward.toml").write_text(MINIMAL_POLICY) + target = tmp_path / "Pulumi.dev.yaml" + target.write_text("config:\n myproject:dbPassword: hunter2\n") + monkeypatch.chdir(tmp_path) + + assert main(["check-config", str(target)]) == 1 + + +def _repo_with_policy(tmp_path: Path) -> Path: + """A real repository with a minimal policy, committed. + + Every caller takes the `isolated_git` fixture (see `tests/conftest.py`) + before calling this: `git commit` below runs whatever hook the machine's + own `core.hooksPath` points at otherwise, which fails these two tests + for a reason that has nothing to do with the gate. Taken as a fixture by + the tests rather than as a parameter here, since what it does is set + environment variables for the duration of the test, not return a value + this function needs. + """ + repo = tmp_path / "repo" + repo.mkdir() + for args in ( + ("init", "-q"), + ("config", "user.email", "test@example.com"), + ("config", "user.name", "Test"), + ): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + (repo / ".stackward.toml").write_text(MINIMAL_POLICY) + subprocess.run( + ["git", "add", ".stackward.toml"], cwd=repo, check=True, capture_output=True + ) + subprocess.run( + ["git", "commit", "-q", "-m", "init"], cwd=repo, check=True, capture_output=True + ) + return repo + + +def test_pre_commit_neither_prompts_nor_connects( + sealed, tmp_path, monkeypatch, isolated_git +): + """The half that matters most: `pre-commit` is the command a `git + commit` runs, so a prompt here blocks the commit on a terminal and hangs + it everywhere else.""" + repo = _repo_with_policy(tmp_path) + (repo / "Pulumi.dev.yaml").write_text("name: myproject\n") + subprocess.run( + ["git", "add", "Pulumi.dev.yaml"], cwd=repo, check=True, capture_output=True + ) + monkeypatch.chdir(repo) + + assert main(["pre-commit"]) == 0 + + +def test_pre_commit_neither_prompts_nor_connects_on_a_finding( + sealed, tmp_path, monkeypatch, isolated_git +): + repo = _repo_with_policy(tmp_path) + (repo / "Pulumi.dev.yaml").write_text("config:\n myproject:dbPassword: hunter2\n") + subprocess.run( + ["git", "add", "Pulumi.dev.yaml"], cwd=repo, check=True, capture_output=True + ) + monkeypatch.chdir(repo) + + assert main(["pre-commit"]) == 1 diff --git a/tests/test_heuristic.py b/tests/test_heuristic.py new file mode 100644 index 0000000..5d39afe --- /dev/null +++ b/tests/test_heuristic.py @@ -0,0 +1,671 @@ +"""Tests for the heuristic net (`find_plaintext_credentials`) and the +`check-config` command built on top of it. + +This is the gate itself: everything else in the project is scaffolding +around this check being correct. Each subtle rule in the brief — what +"encrypted" and "empty" mean, exactly — gets its own test, named for the +wrong implementation it would catch. +""" + +from __future__ import annotations + +import pytest +import yaml + +from stackward.cli import main +from stackward.commands import check_config as check_config_module +from stackward.commands.check_config import CheckError, fail_closed, scan_file +from stackward.config import CheckConfig +from stackward.nets.heuristic import DocumentError, find_plaintext_credentials + +# Characters that flow through PyYAML's `%r` interpolation into a scanner +# or parser message. `_YAML_TRIGGER_CHARACTERS`'s point is exactly that it +# is a *set*, not one instance: the apostrophe is the one character whose +# `repr()` switches delimiter (`"'"`, not `'''`), and a redaction pattern +# that only matched `'...'` passed every test built from the others. +_YAML_TRIGGER_CHARACTERS = [ + pytest.param("'", id="apostrophe"), + pytest.param('"', id="double_quote"), + pytest.param("\\", id="backslash"), + pytest.param("\n", id="newline"), + pytest.param("\x01", id="non_printable"), +] + + +def _marked_yaml_error(problem: str) -> yaml.MarkedYAMLError: + """A `MarkedYAMLError` shaped like the ones PyYAML's scanner/parser + actually raise, with a fixed, recognisable line/column so a test can + assert position survives redaction alongside the interpolated + character not surviving it.""" + mark = yaml.Mark("", 0, 4, 17, None, None) + return yaml.MarkedYAMLError(problem=problem, problem_mark=mark) + +# --------------------------------------------------------------------------- +# find_plaintext_credentials — the content-level net. +# --------------------------------------------------------------------------- + + +def test_clean_config_has_no_findings(): + document = { + "name": "myproject", + "runtime": "nodejs", + "config": {"placeholder:region": "placeholder-region"}, + } + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_plaintext_value_flagged_by_key_match(): + """The leaf's own key matches `sensitive_keys` (case-insensitive + substring) — no parent policy needed.""" + document = {"config": {"myproject:dbPassword": "hunter2"}} + assert find_plaintext_credentials(document, CheckConfig()) == [ + "config.myproject:dbPassword" + ] + + +def test_plaintext_value_flagged_by_sensitive_parent(): + """The leaf's own key ("DB_USER") matches no built-in sensitive key — + only a declared `sensitive_parents` ancestor makes it a finding.""" + check = CheckConfig(sensitive_parents=frozenset({"placeholder_parent"})) + document = {"placeholder_parent": {"DB_USER": "admin"}} + assert find_plaintext_credentials(document, check) == ["placeholder_parent.DB_USER"] + + +def test_encrypted_leaf_passes(): + """A leaf made sensitive only by its `sensitive_parents` ancestor, whose + parent mapping is exactly `{"secure": ...}`, must not be flagged.""" + check = CheckConfig(sensitive_parents=frozenset({"placeholder_parent"})) + document = {"placeholder_parent": {"DB_PASS": {"secure": "v1:AAAA"}}} + assert find_plaintext_credentials(document, check) == [] + + +def test_malformed_secure_wrapper_with_extra_sibling_is_flagged(): + """`{"secure": ..., "other": ...}` is NOT the one-key encryption shape — + both children must be flagged. A `path.endswith(".secure")` check would + wrongly exclude the first of the two; asserting it is present is the + point of this test.""" + check = CheckConfig(sensitive_parents=frozenset({"placeholder_parent"})) + document = { + "placeholder_parent": { + "DB_PASS": {"secure": "v1:AAAA", "other": "leaked-plaintext"} + } + } + findings = find_plaintext_credentials(document, check) + assert findings == [ + "placeholder_parent.DB_PASS.other", + "placeholder_parent.DB_PASS.secure", + ] + + +# --------------------------------------------------------------------------- +# Ruling: a sensitive key propagates to its subtree. Without this, the +# malformed-envelope case above is unreachable under the default policy — +# see the module docstring for why. +# --------------------------------------------------------------------------- + + +def test_encrypted_leaf_passes_under_default_policy_via_key_propagation(): + """`apiToken` matches `sensitive_keys` directly and propagates to its + subtree; the leaf `apiToken.secure` inherits that sensitivity, but its + parent mapping is exactly `{"secure": ...}` — still exempt. The + exemption keeps working through propagation, exactly as it did through + `sensitive_parents` above.""" + document = {"apiToken": {"secure": "v1:AAAA"}} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_malformed_secure_wrapper_flagged_under_default_policy(): + """Regression for the propagation ruling: under the shipped default + policy (no `sensitive_parents` declared, which is every repository's + starting policy), this returned `[]` before propagation — neither + "secure" nor "other" matches a built-in key, and no ancestor was + declared a sensitive parent, so `is_encrypted`'s parent-shape check + never even ran. A sensitive key now makes its whole subtree sensitive, + so both leaves are correctly flagged.""" + document = {"apiToken": {"secure": "v1:AAAA", "other": "leaked-plaintext"}} + findings = find_plaintext_credentials(document, CheckConfig()) + assert findings == ["apiToken.other", "apiToken.secure"] + + +def test_sensitive_key_propagates_through_several_levels_of_nesting(): + document = {"credential": {"a": {"b": {"c": "leaked-plaintext"}}}} + assert find_plaintext_credentials(document, CheckConfig()) == ["credential.a.b.c"] + + +def test_empty_value_under_a_sensitive_key_still_passes(): + """Empty beats propagation: a sensitive key's subtree still exempts + `None`/`""`/`True`/`False` leaves, same as a directly-matched key + would.""" + document = {"credential": {"nested": {"value": None}}} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_propagation_does_not_leak_to_a_sibling_non_sensitive_mapping(): + """A sensitive key's subtree is sensitive; an unrelated sibling + subtree, whose own keys match nothing, is not — propagation flows + down a branch, never sideways.""" + document = { + "credential": {"a": "leaked-plaintext"}, + "unrelated": {"b": "not-flagged-by-this-policy"}, + } + assert find_plaintext_credentials(document, CheckConfig()) == ["credential.a"] + + +def test_propagation_does_not_leak_to_a_sibling_non_sensitive_mapping_reversed_order(): + """Same document as above with insertion order reversed. `_walk` + computes each key's contribution fresh from the `ancestor_sensitive` + value it was called with, never by mutating a variable shared across + loop iterations — so this must pass regardless of which sibling a dict + happens to iterate first. A bug that leaked sensitivity across sibling + branches via shared mutable state, instead of a fresh value per + branch, could otherwise hide behind dict ordering.""" + document = { + "unrelated": {"b": "not-flagged-by-this-policy"}, + "credential": {"a": "leaked-plaintext"}, + } + assert find_plaintext_credentials(document, CheckConfig()) == ["credential.a"] + + +def test_self_referential_mapping_terminates_instead_of_recursing_forever(): + """A YAML anchor/alias pair like `credential: &x\\n b: *x\\n` produces + a dict that contains itself. Without a cycle guard, `_walk` recurses + forever — each cycle produces a longer, distinct rendered path, so a + depth cap would only delay the crash, not avoid it.""" + cyclic: dict = {"b": None} + cyclic["b"] = cyclic + document = {"credential": cyclic} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_self_referential_sequence_terminates_instead_of_recursing_forever(): + """The same cycle, one level further down inside a list rather than a + dict.""" + cyclic: list = ["placeholder"] + cyclic[0] = cyclic + document = {"credential": cyclic} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_shared_non_cyclic_object_is_scanned_at_each_occurrence(): + """Two YAML anchors pointing at the same (non-cyclic) mapping from two + unrelated branches is not a cycle — the cycle guard is scoped to the + current descent path, not "every id ever seen", so both occurrences + must still be scanned at their own distinct path.""" + shared = {"password": "leaked-plaintext"} + document = {"copy1": shared, "copy2": shared} + assert find_plaintext_credentials(document, CheckConfig()) == [ + "copy1.password", + "copy2.password", + ] + + +def test_none_and_empty_string_pass(): + document = {"password": None, "token": ""} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_true_and_false_pass(): + """Identity, not the `value in (None, "", False, True)` containment + test this rule replaces: booleans are excluded regardless.""" + document = {"password": True, "token": False} + assert find_plaintext_credentials(document, CheckConfig()) == [] + + +def test_integers_zero_one_and_larger_are_flagged(): + """`0 == False` and `1 == True` in Python, so a containment test for + emptiness wrongly swallows these — they must still be findings.""" + document = {"password": 0, "token": 1, "secret": 42} + assert find_plaintext_credentials(document, CheckConfig()) == [ + "password", + "secret", + "token", + ] + + +def test_dict_key_containing_dot_is_reported_with_bracket_quoting(): + """The path is produced by `paths.render`, never by joining segments + with `.` — a key containing a literal `.` must come back bracket-quoted, + not run together with its parent.""" + document = {"config": {"my.password": "hunter2"}} + assert find_plaintext_credentials(document, CheckConfig()) == ['config["my.password"]'] + + +def test_allowed_reference_path_passes(): + """A path listed in `allowed_references` names another secret rather + than holding one, and is never a finding even though it would otherwise + qualify.""" + check = CheckConfig(allowed_references=['config["my.password"]']) + document = {"config": {"my.password": "SOME_OTHER_SECRET_NAME"}} + assert find_plaintext_credentials(document, check) == [] + + +def test_list_document_raises(): + with pytest.raises(DocumentError): + find_plaintext_credentials(["not", "a", "mapping"], CheckConfig()) + + +def test_scalar_document_raises(): + with pytest.raises(DocumentError): + find_plaintext_credentials("just a string", CheckConfig()) + + +def test_empty_document_raises(): + """An empty YAML file parses to `None` — still not a mapping, still an + error rather than "no findings".""" + with pytest.raises(DocumentError): + find_plaintext_credentials(None, CheckConfig()) + + +# --------------------------------------------------------------------------- +# fail_closed — the outer boundary shared by check-config and pre-commit. +# --------------------------------------------------------------------------- + + +def test_fail_closed_converts_an_unhandled_exception_to_exit_2(capsys): + """The property `fail_closed` exists for, isolated from either + command: an exception the wrapped function does not itself catch + becomes exit 2, never left to propagate (which would exit 1 by + Python's own default — the code reserved for "a credential was + found").""" + + @fail_closed + def boom(_args): + raise OSError("simulated failure unrelated to any file's content") + + assert boom(None) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "OSError" in captured.err + # Only the exception's type name is reported, never its own text — + # the same reasoning `describe_yaml_error` uses for never trusting + # `str(exc)`: an unanticipated exception's message is not something + # this boundary can vouch for as free of file content. + assert "simulated failure unrelated to any file's content" not in captured.err + + +def test_fail_closed_does_not_interfere_with_a_normal_return(capsys): + """A wrapped function that returns normally is unaffected — the + decorator only ever intervenes on an exception.""" + + @fail_closed + def clean(_args): + print("normal output") + return 0 + + assert clean(None) == 0 + assert "normal output" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# scan_file / check-config — the file-path wrapper and the CLI it backs. +# --------------------------------------------------------------------------- + + +def write_yaml(tmp_path, name: str, text: str): + path = tmp_path / name + path.write_text(text) + return path + + +@pytest.fixture +def policy_repo(tmp_path, monkeypatch): + """`tmp_path` set up as a repository root that declares a policy, with + the process's working directory inside it. + + Every `check-config` invocation below needs one. A repository with no + `.stackward.toml` is now refused outright (Global Constraint 3: "A + missing policy file is a refusal, not a skip"), so without this fixture + the exit-0 and exit-1 tests here would fail and — worse — every exit-2 + test would pass for the wrong reason, never reaching the condition it + names. Returns `tmp_path` unchanged, so each test's own `write_yaml` + call still builds its fixture file where it always did. + """ + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text('[check]\nmodel_net = "none"\n') + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_scan_file_returns_no_findings_for_a_clean_file(tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") + assert scan_file(path, CheckConfig(), None) == [] + + +def test_scan_file_finds_a_plaintext_credential(tmp_path): + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + assert scan_file(path, CheckConfig(), None) == ["config.myproject:dbPassword"] + + +def test_scan_file_raises_check_error_on_invalid_yaml(tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "key: [unclosed\n") + with pytest.raises(CheckError): + scan_file(path, CheckConfig(), None) + + +def test_invalid_yaml_error_never_prints_a_credential_shaped_value(tmp_path): + """`MarkedYAMLError.__str__` includes a source-line snippet via PyYAML's + `Mark.get_snippet()` — an unescaped colon in a value (entirely + plausible for a real password) produces a parse error whose default + message would otherwise embed that value verbatim. This is the one + file in the whole system expected to contain a credential, so the + reported message must never include it.""" + path = write_yaml( + tmp_path, + "Pulumi.leak.yaml", + "config:\n myproject:dbPassword: hunter2: not-valid-yaml\n", + ) + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig(), None) + assert "hunter2" not in str(exc_info.value) + + +def test_invalid_yaml_bad_escape_error_redacts_the_leaked_character(tmp_path): + """`exc.problem` is not unconditionally free of document content: + PyYAML's scanner interpolates one raw character via `%r` into + "found unknown escape character %r" for a bad backslash escape. A + value like `"hunter\\2ok"` (a plausible fragment of a real password) + would otherwise leak the digit `2` into the error message. The error + kind and position must survive redaction even though the character + does not.""" + path = write_yaml(tmp_path, "Pulumi.leak.yaml", 'password: "hunter\\2ok"\n') + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig(), None) + message = str(exc_info.value) + assert "'2'" not in message + assert "hunter" not in message + assert "" in message + assert "found unknown escape character" in message + assert "line 1" in message + assert "column 19" in message + + +def test_invalid_yaml_reserved_leading_character_error_redacts_the_leaked_character( + tmp_path, +): + """The same `%r` leak, from a different PyYAML message: a character + that cannot start any token — a backtick right before what looks like + a plaintext credential — interpolates that character into + "found character %r that cannot start any token".""" + path = write_yaml(tmp_path, "Pulumi.leak.yaml", "password: `s3cr3tPass99\n") + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig(), None) + message = str(exc_info.value) + assert "'`'" not in message + assert "s3cr3tPass99" not in message + assert "" in message + assert "found character" in message + assert "that cannot start any token" in message + assert "line 1" in message + assert "column 11" in message + + +def test_scan_file_redacts_an_apostrophe_that_flips_reprs_delimiter(tmp_path): + """Regression for the specific gap the two tests above could not + catch: when the character PyYAML interpolates via `%r` is itself an + apostrophe, Python's `repr()` switches to double quotes (`"'"`, not + `'''`) — a redaction pattern that only matched `'...'` left exactly + this one character unredacted, end to end through the real CLI path, + not just a synthetic exception.""" + path = write_yaml(tmp_path, "Pulumi.leak.yaml", "password: \"s3cr3t\\'value\"\n") + with pytest.raises(CheckError) as exc_info: + scan_file(path, CheckConfig(), None) + message = str(exc_info.value) + assert "'" not in message + assert "s3cr3t" not in message + assert "value" not in message + assert "" in message + assert "found unknown escape character" in message + + +@pytest.mark.parametrize("trigger_char", _YAML_TRIGGER_CHARACTERS) +def test_yaml_error_redaction_removes_any_percent_r_escape_character(trigger_char): + """The property, not an instance: redaction must hold for *any* + character PyYAML's scanner interpolates via `%r` into "found unknown + escape character %r" — not just the one or two a hand-picked + reproduction happens to trigger. Two instance-pinned tests (`'2'`, + `` '`' ``) both passed while this exact class of gap — the apostrophe + flipping `repr()`'s delimiter to `"'"` — was still open, which is + precisely what a test quantified over characters, rather than fixed to + one, is for.""" + exc = _marked_yaml_error(f"found unknown escape character {trigger_char!r}") + message = check_config_module.describe_yaml_error(exc) + assert trigger_char not in message + assert "" in message + assert "found unknown escape character" in message + assert "line 5" in message + assert "column 18" in message + + +@pytest.mark.parametrize("trigger_char", _YAML_TRIGGER_CHARACTERS) +def test_yaml_error_redaction_removes_any_percent_r_reserved_character(trigger_char): + """The same property, for the other PyYAML message family that + interpolates a raw character: "found character %r that cannot start + any token".""" + problem = f"found character {trigger_char!r} that cannot start any token" + exc = _marked_yaml_error(problem) + message = check_config_module.describe_yaml_error(exc) + assert trigger_char not in message + assert "" in message + assert "found character" in message + assert "that cannot start any token" in message + assert "line 5" in message + assert "column 18" in message + + +def test_scan_file_raises_check_error_on_non_mapping_document(tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") + with pytest.raises(CheckError): + scan_file(path, CheckConfig(), None) + + +def test_scan_file_raises_check_error_on_missing_file(tmp_path): + with pytest.raises(CheckError): + scan_file(tmp_path / "does-not-exist.yaml", CheckConfig(), None) + + +def test_check_config_exits_0_when_clean(policy_repo, tmp_path, capsys): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") + assert main(["check-config", str(path)]) == 0 + assert capsys.readouterr().out == "" + + +def test_check_config_exits_1_and_prints_the_finding_format(policy_repo, tmp_path, capsys): + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + assert main(["check-config", str(path)]) == 1 + out = capsys.readouterr().out + assert out == f"{path}: plaintext credential at 'config.myproject:dbPassword'\n" + assert "hunter2" not in out + + +def test_check_config_findings_print_sorted(policy_repo, tmp_path, capsys): + """Insertion order in the file is zebra-then-apple; output must be + sorted regardless.""" + path = write_yaml( + tmp_path, + "Pulumi.dev.yaml", + "zebra_password: hunter2\napple_token: abc123\n", + ) + assert main(["check-config", str(path)]) == 1 + lines = capsys.readouterr().out.splitlines() + assert lines == [ + f"{path}: plaintext credential at 'apple_token'", + f"{path}: plaintext credential at 'zebra_password'", + ] + + +def test_check_config_exits_2_on_invalid_yaml(policy_repo, tmp_path, capsys): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "key: [unclosed\n") + assert main(["check-config", str(path)]) == 2 + assert capsys.readouterr().out == "" + + +def test_check_config_exits_2_on_non_mapping_document(policy_repo, tmp_path): + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "- a\n- b\n") + assert main(["check-config", str(path)]) == 2 + + +def test_check_config_exits_2_with_no_files_given(): + """argparse's own usage error for a missing required FILE — exit 2, + same code as any other "could not run" reason, never 1.""" + with pytest.raises(SystemExit) as exit_info: + main(["check-config"]) + assert exit_info.value.code == 2 + + +def test_check_config_exits_2_on_non_utf8_file(policy_repo, tmp_path, capsys): + """`Path.read_text()` raises `UnicodeDecodeError` for a file that is + not valid UTF-8 — not an `OSError`, and previously uncaught, which + would exit 1 (Python's default for an uncaught exception): the code + reserved exclusively for "a credential was found". A file this command + never finished reading is a could-not-run failure, not a finding.""" + path = tmp_path / "Pulumi.dev.yaml" + path.write_bytes(b"password: \xff\xfe not valid utf8\n") + assert main(["check-config", str(path)]) == 2 + assert capsys.readouterr().out == "" + + +def test_check_config_exits_2_on_unanticipated_scan_error(policy_repo, tmp_path, monkeypatch, capsys): + """Any exception a per-file scan raises other than `CheckError` must + still map to exit 2, not propagate and exit 1 (Python's default for an + uncaught exception) — regardless of what raised it. Simulated here + with a directly-injected failure rather than relying on a specific + trigger, so this test does not duplicate the self-referential-YAML or + non-UTF8 regressions covered elsewhere.""" + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\n") + + def boom(_path, _check, _net): + raise RuntimeError("unanticipated failure, not a CheckError") + + monkeypatch.setattr("stackward.commands.check_config.scan_file", boom) + assert main(["check-config", str(path)]) == 2 + assert capsys.readouterr().out == "" + + +def test_check_config_exits_2_when_policy_loading_raises_an_unexpected_error( + policy_repo, tmp_path, monkeypatch, capsys +): + """A bare exception from policy loading -- not `ConfigError`, which + `cmd_check_config`'s own `except ConfigError` already handles -- must + not escape uncaught. Concretely: `find_repo_config` walks upward + through parent directories with plain `.is_file()`/`.exists()` calls + and no try/except of its own, so a `PermissionError` on a + non-traversable parent would surface as a bare `OSError`. Without the + `@fail_closed` decorator on `cmd_check_config`, that would propagate + out of `main()` entirely and exit 1 by Python's own default -- + misreporting "credential found" for an invocation that never got as + far as loading policy, let alone scanning a file.""" + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\n") + + def boom(): + raise OSError("simulated permission error walking parent directories") + + monkeypatch.setattr("stackward.commands.check_config.find_repo_config", boom) + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "OSError" in captured.err + + +def test_check_config_prints_a_real_finding_even_when_another_file_errors( + policy_repo, tmp_path, capsys +): + """A parse error on one file must never hide a genuine finding on + another: the exit code (2, since a check-could-not-run error always + wins) carries the could-not-run signal, but the finding still prints.""" + good = write_yaml(tmp_path, "good.yaml", 'password: "hunter2"\n') + broken = write_yaml(tmp_path, "broken.yaml", "key: [unclosed\n") + assert main(["check-config", str(good), str(broken)]) == 2 + out = capsys.readouterr().out + assert out == f"{good}: plaintext credential at 'password'\n" + + +# --------------------------------------------------------------------------- +# A missing or unloadable policy file is a refusal, not a skip. +# +# Global Constraint 3: "A missing policy file is a refusal, not a skip." The +# plan's Task 3 adds the reason it has to be: heuristic-only is a *declared* +# mode (`model_net = "none"`), never a silent fallback — and an absent config +# defaulting to `CheckConfig()` is precisely that fallback, since `"none"` is +# what `CheckConfig()` carries. +# --------------------------------------------------------------------------- + + +def test_check_config_refuses_when_no_policy_file_is_found(tmp_path, monkeypatch, capsys): + """No `.stackward.toml` anywhere at or above the working directory. Exit + 2, naming the file and the key that has to be in it — a refusal that + does not say what to create is not actionable.""" + (tmp_path / ".git").mkdir() + monkeypatch.chdir(tmp_path) + path = write_yaml(tmp_path, "Pulumi.dev.yaml", "name: myproject\n") + + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert ".stackward.toml" in captured.err + assert "model_net" in captured.err + + +def test_check_config_refuses_rather_than_reporting_when_there_is_no_policy( + tmp_path, monkeypatch, capsys +): + """Exit 2, not 1, even for a file the heuristic net alone would have + flagged. Without a policy this command does not know what the repository + considers sensitive, and exit 1 would claim a complete answer it does + not have — the same reasoning that makes a stale model net exit 2.""" + (tmp_path / ".git").mkdir() + monkeypatch.chdir(tmp_path) + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "hunter2" not in captured.err + + +def test_check_config_exits_2_on_a_policy_file_that_will_not_parse( + tmp_path, monkeypatch, capsys +): + """The `except ConfigError` branch in `cmd_check_config`. Nothing pinned + it: mutating its `return 2` to `return 0` survived the whole suite, so a + repository with a typo in its policy could be reported clean.""" + (tmp_path / ".git").mkdir() + (tmp_path / ".stackward.toml").write_text("this is not valid toml [[[") + monkeypatch.chdir(tmp_path) + path = write_yaml( + tmp_path, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n" + ) + + assert main(["check-config", str(path)]) == 2 + captured = capsys.readouterr() + # Not scanned at all: the finding must not print, or the exit code and + # the output would be telling two different stories. + assert captured.out == "" + assert "invalid TOML" in captured.err + + +def test_check_config_reads_the_policy_from_the_working_tree_by_design( + tmp_path, monkeypatch, capsys +): + """The deliberate asymmetry with `pre-commit`, pinned so a later change + cannot "fix" it into matching. `check-config` scans files from disk and + reads its policy from disk, and needs no git repository at all: it + answers "is this file clean right now?", which is a question about the + working tree. `pre-commit` answers "is what I am about to commit + clean?", and reads both from the index — see tests/test_pre_commit.py. + """ + (tmp_path / ".stackward.toml").write_text( + '[check]\nmodel_net = "none"\nallowed_references = ["password"]\n' + ) + monkeypatch.chdir(tmp_path) + path = write_yaml(tmp_path, "Pulumi.dev.yaml", 'password: "hunter2"\n') + + # No `git init`, nothing staged: the policy still applies. + assert not (tmp_path / ".git").exists() + assert main(["check-config", str(path)]) == 0 + assert capsys.readouterr().out == "" diff --git a/tests/test_install_hooks.py b/tests/test_install_hooks.py new file mode 100644 index 0000000..419a57a --- /dev/null +++ b/tests/test_install_hooks.py @@ -0,0 +1,933 @@ +"""Tests for `hooks install` -- writing the `pre-commit` gate into a +repository's real hooks directory. + +Every test here uses a real temporary git repository, created with `git +init`. This command's entire reason to exist is a handful of git behaviours +that are easy to get wrong silently -- `core.hooksPath` redirection, a +tracked hooks directory, an existing foreign hook -- so, exactly as in +`test_pre_commit.py`, git is never mocked. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from stackward.cli import main +from stackward.commands import install_hooks as install_hooks_module + +# --------------------------------------------------------------------------- +# Real-git-repository fixtures and helpers. +# --------------------------------------------------------------------------- + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True + ) + + +@pytest.fixture +def repo(tmp_path: Path, isolated_git: Path) -> Path: + """A real, minimal git repository with one initial commit that declares + a `.stackward.toml`. + + The policy file is committed rather than merely written because + `pre-commit` -- which the end-to-end tests here drive through a real + `git commit` -- now reads its policy from the index and refuses a + repository that declares none (Global Constraint 3: "A missing policy + file is a refusal, not a skip"). Without it, the commit-blocking tests + below would still see a non-zero exit, but for the wrong reason, and the + clean-commit test would fail outright. `hooks install` itself reads no + policy, so the tests that only assert on installation are unaffected + either way. + """ + path = tmp_path / "repo" + path.mkdir() + _git(path, "init", "-q") + _git(path, "config", "user.email", "test@example.com") + _git(path, "config", "user.name", "Test") + (path / ".stackward.toml").write_text('[check]\nmodel_net = "none"\n') + _git(path, "add", ".stackward.toml") + _git(path, "commit", "-q", "-m", "init") + return path + + +def install(repo: Path, monkeypatch) -> int: + monkeypatch.chdir(repo) + return main(["hooks", "install"]) + + +def backups(hooks_dir: Path) -> list[Path]: + return sorted(hooks_dir.glob("pre-commit.backup.*")) + + +# --------------------------------------------------------------------------- +# Default location: `.git/hooks`, resolved rather than hardcoded. +# --------------------------------------------------------------------------- + + +def test_default_location_installs_into_git_hooks(repo, monkeypatch, capsys): + assert install(repo, monkeypatch) == 0 + hook_path = repo / ".git" / "hooks" / "pre-commit" + assert hook_path.is_file() + assert install_hooks_module._MARKER in hook_path.read_text() + assert str(hook_path) in capsys.readouterr().out + + +def test_installed_hook_actually_blocks_a_real_commit_with_a_credential( + repo, monkeypatch, real_executable +): + """A file existing at the expected path is not proof git runs it -- + that gap is exactly the "silent no-op" the brief warns about. This + drives a real `git commit`, through the real `stackward` this test + interpreter has installed, against the real default hooks directory.""" + assert install(repo, monkeypatch) == 0 + + (repo / "Pulumi.dev.yaml").write_text("config:\n myproject:dbPassword: hunter2\n") + _git(repo, "add", "Pulumi.dev.yaml") + result = subprocess.run( + ["git", "commit", "-m", "add credential"], + cwd=repo, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "plaintext credential" in result.stdout + result.stderr + + +def test_installed_hook_allows_a_real_clean_commit(repo, monkeypatch, real_executable): + assert install(repo, monkeypatch) == 0 + + (repo / "Pulumi.dev.yaml").write_text("name: myproject\n") + _git(repo, "add", "Pulumi.dev.yaml") + result = subprocess.run( + ["git", "commit", "-m", "add clean config"], + cwd=repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + +# --------------------------------------------------------------------------- +# core.hooksPath redirection: never hardcode `.git/hooks`. +# --------------------------------------------------------------------------- + + +def test_redirected_hooks_path_installs_there_not_in_dot_git(repo, monkeypatch): + (repo / "myhooks").mkdir() + _git(repo, "config", "core.hooksPath", "myhooks") + + assert install(repo, monkeypatch) == 0 + + redirected_hook = repo / "myhooks" / "pre-commit" + default_hook = repo / ".git" / "hooks" / "pre-commit" + assert redirected_hook.is_file() + assert not default_hook.exists() + + +def test_redirected_hooks_path_directory_is_created_if_missing(repo, monkeypatch): + """The redirected directory need not exist yet -- e.g. a fresh clone + that has set core.hooksPath but never run any install step.""" + _git(repo, "config", "core.hooksPath", "myhooks") + assert not (repo / "myhooks").exists() + + assert install(repo, monkeypatch) == 0 + assert (repo / "myhooks" / "pre-commit").is_file() + + +def test_hooks_path_absolute_and_outside_the_worktree_is_treated_as_untracked( + tmp_path, repo, monkeypatch +): + """`git ls-files --error-unmatch` on a path outside the repository + fails with "is outside repository", a different git error than the + in-repo untracked case (`did not match any file(s)`) exercised + elsewhere in this file. `_is_tracked` recognises both messages as + "not tracked" -- this proves that answer is still correct for this + differently-shaped git failure, not merely right by accident of a + single error message this suite happens to have checked.""" + external = tmp_path / "external-hooks" + external.mkdir() + _git(repo, "config", "core.hooksPath", str(external)) + + assert install(repo, monkeypatch) == 0 + assert (external / "pre-commit").is_file() + + +def test_unrecognised_ls_files_failure_exits_2_rather_than_installing( + repo, monkeypatch, capsys +): + """`_is_tracked` must not treat *every* non-zero `git ls-files` exit + as "not tracked" -- only the two recognised, specifically-worded + failures. A `git ls-files` failure for an unrelated reason (a corrupt + index, a permissions problem -- simulated here) leaves the question + genuinely unanswered, and installing anyway would be exactly the + silent bypass of a mandated refusal `_is_tracked` exists to prevent. + This must exit 2 (could not determine), never 0 (installed) or 1 + (the different, definitive "is tracked" refusal).""" + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:2] == ["git", "ls-files"]: + return subprocess.CompletedProcess( + args, + returncode=128, + stdout="", + stderr="fatal: index file corrupt (simulated)\n", + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert install(repo, monkeypatch) == 2 + + captured = capsys.readouterr() + assert captured.out == "" + assert "index file corrupt" in captured.err + # Refused to guess either way: no hook was written. + assert not (repo / ".git" / "hooks" / "pre-commit").exists() + + +def test_is_tracked_raises_install_error_for_an_unrecognised_failure( + tmp_path, monkeypatch +): + """Direct unit test of the boundary `_is_tracked` itself draws, + independent of the command-level wiring covered above.""" + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:2] == ["git", "ls-files"]: + return subprocess.CompletedProcess( + args, returncode=128, stdout="", stderr="fatal: something else\n" + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + with pytest.raises(install_hooks_module.InstallError, match="something else"): + install_hooks_module._is_tracked(tmp_path) + + +def test_redirected_hooks_path_hook_actually_runs_on_a_real_commit( + repo, monkeypatch, real_executable +): + """The specific case "don't hardcode `.git/hooks`" exists for: proves + the installed hook does anything at all when git is configured to + look somewhere else entirely.""" + (repo / "myhooks").mkdir() + _git(repo, "config", "core.hooksPath", "myhooks") + assert install(repo, monkeypatch) == 0 + + (repo / "Pulumi.dev.yaml").write_text("config:\n myproject:dbPassword: hunter2\n") + _git(repo, "add", "Pulumi.dev.yaml") + result = subprocess.run( + ["git", "commit", "-m", "add credential"], + cwd=repo, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "plaintext credential" in result.stdout + result.stderr + + +# --------------------------------------------------------------------------- +# Refusal on a tracked hooks directory. +# --------------------------------------------------------------------------- + + +def test_refuses_when_hooks_directory_is_tracked_by_git(repo, monkeypatch, capsys): + (repo / "githooks").mkdir() + (repo / "githooks" / "pre-commit").write_text("#!/bin/sh\necho tracked\n") + _git(repo, "add", "githooks") + _git(repo, "commit", "-q", "-m", "commit shared hooks") + _git(repo, "config", "core.hooksPath", "githooks") + + original = (repo / "githooks" / "pre-commit").read_text() + assert install(repo, monkeypatch) == 1 + + captured = capsys.readouterr() + assert "tracked" in captured.err.lower() + assert "githooks" in captured.err + # Refused, not silently overwritten or backed up. + assert (repo / "githooks" / "pre-commit").read_text() == original + assert backups(repo / "githooks") == [] + + +def test_tracked_refusal_suggests_chaining_from_the_tracked_hook(repo, monkeypatch, capsys): + (repo / "githooks").mkdir() + (repo / "githooks" / "pre-commit").write_text("#!/bin/sh\n") + _git(repo, "add", "githooks") + _git(repo, "commit", "-q", "-m", "commit shared hooks") + _git(repo, "config", "core.hooksPath", "githooks") + + install(repo, monkeypatch) + err = capsys.readouterr().err + assert "chain" in err.lower() + assert "pre-commit" in err # names what to chain to: `stackward pre-commit` + + +# --------------------------------------------------------------------------- +# An existing foreign hook is backed up, never refused or overwritten blind. +# --------------------------------------------------------------------------- + + +def test_existing_foreign_hook_is_backed_up(repo, monkeypatch, capsys): + hooks_dir = repo / ".git" / "hooks" + hook_path = hooks_dir / "pre-commit" + foreign_content = "#!/bin/sh\necho this is somebody else's hook\n" + hook_path.write_text(foreign_content) + hook_path.chmod(0o755) + + assert install(repo, monkeypatch) == 0 + + found = backups(hooks_dir) + assert len(found) == 1 + assert found[0].read_text() == foreign_content + # The installed hook now carries this tool's marker. + assert install_hooks_module._MARKER in hook_path.read_text() + assert str(found[0]) in capsys.readouterr().out + + +def backup_stamp(hooks_dir: Path) -> str: + [backup] = backups(hooks_dir) + assert backup.name.startswith("pre-commit.backup.") + return backup.name.removeprefix("pre-commit.backup.") + + +@pytest.fixture +def local_time_far_from_utc(): + """Move this process's *local* time twelve hours away from UTC. + + `XXX-12` is a POSIX `TZ` string -- a made-up zone abbreviation and a + numeric offset, no geography and no place name -- so nothing here depends + on the tz database or on where the suite happens to run. `time.tzset()` + is what makes `datetime.now()` (which reads local time) actually observe + it. + + Restored by hand rather than through `monkeypatch`, because `tzset()` + caches the zone in the C library: undoing the variable without calling + `tzset()` again would leave every later test in the session running with + a shifted local clock. + """ + previous = os.environ.get("TZ") + os.environ["TZ"] = "XXX-12" + time.tzset() + try: + yield + finally: + if previous is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = previous + time.tzset() + + +def test_backup_filename_matches_hook_dot_backup_dot_timestamp(repo, monkeypatch): + """The suffix parses as the full timestamp, not merely "starts with + eight digits". + + `stamp[:8].isdigit()` was satisfied by any date-shaped prefix, so + neither the microseconds (what stops two installs in the same second + from colliding) nor the format was pinned by it. + """ + hooks_dir = repo / ".git" / "hooks" + (hooks_dir / "pre-commit").write_text("#!/bin/sh\necho foreign\n") + + before = datetime.now(timezone.utc) + install(repo, monkeypatch) + after = datetime.now(timezone.utc) + + stamp = backup_stamp(hooks_dir) + parsed = datetime.strptime(stamp, "%Y%m%dT%H%M%S.%fZ").replace(tzinfo=timezone.utc) + assert before <= parsed <= after + assert parsed.microsecond or "." in stamp # sub-second resolution is present + + +def test_backup_timestamp_is_utc_and_not_local_time( + repo, monkeypatch, local_time_far_from_utc +): + """The `Z` in the filename has to mean UTC. + + Nothing pinned it: every existing assertion on this name held equally + for `datetime.now()`, and on a machine whose local zone *is* UTC the two + are indistinguishable -- which is most CI images, so the gap would never + have surfaced there either. With local time twelve hours away, a + timestamp taken in local time cannot fall inside a UTC bracket around + the call. + + It matters because these names sort, and because two clones on machines + in different zones would otherwise produce backups whose apparent order + is wrong -- for files whose whole job is to be the recoverable copy of + somebody else's hook. + """ + hooks_dir = repo / ".git" / "hooks" + (hooks_dir / "pre-commit").write_text("#!/bin/sh\necho foreign\n") + + before = datetime.now(timezone.utc) + install(repo, monkeypatch) + after = datetime.now(timezone.utc) + + parsed = datetime.strptime(backup_stamp(hooks_dir), "%Y%m%dT%H%M%S.%fZ").replace( + tzinfo=timezone.utc + ) + assert before <= parsed <= after + # Guard against the fixture silently not taking effect, which would make + # the assertion above prove nothing. + assert abs((datetime.now() - datetime.now(timezone.utc).replace(tzinfo=None)).total_seconds()) > 3600 + + +# --------------------------------------------------------------------------- +# Idempotence: installing twice leaves one hook and creates no second backup. +# --------------------------------------------------------------------------- + + +def test_installing_twice_over_a_foreign_hook_creates_only_one_backup(repo, monkeypatch): + hooks_dir = repo / ".git" / "hooks" + (hooks_dir / "pre-commit").write_text("#!/bin/sh\necho foreign\n") + + assert install(repo, monkeypatch) == 0 + assert install(repo, monkeypatch) == 0 + + assert len(backups(hooks_dir)) == 1 + assert (hooks_dir / "pre-commit").is_file() + + +def test_installing_twice_from_a_clean_repo_leaves_exactly_one_hook(repo, monkeypatch): + assert install(repo, monkeypatch) == 0 + assert install(repo, monkeypatch) == 0 + + hooks_dir = repo / ".git" / "hooks" + assert backups(hooks_dir) == [] + assert sum(1 for p in hooks_dir.iterdir() if p.name == "pre-commit") == 1 + + +def test_second_install_recognises_its_own_marker_and_overwrites_in_place( + repo, monkeypatch +): + """The mechanism idempotence depends on: a hook already carrying + `_MARKER` is treated as ours and overwritten directly, not backed up + as though it were foreign.""" + install(repo, monkeypatch) + hook_path = repo / ".git" / "hooks" / "pre-commit" + assert install_hooks_module._is_stackward_hook(hook_path) is True + + install(repo, monkeypatch) + assert backups(repo / ".git" / "hooks") == [] + + +# --------------------------------------------------------------------------- +# Mode 0o755, set explicitly. +# --------------------------------------------------------------------------- + + +def test_installed_hook_mode_is_0o755(repo, monkeypatch): + install(repo, monkeypatch) + hook_path = repo / ".git" / "hooks" / "pre-commit" + mode = stat.S_IMODE(hook_path.stat().st_mode) + assert mode == 0o755 + + +def test_hook_mode_is_0o755_even_when_replacing_a_non_executable_foreign_hook( + repo, monkeypatch +): + hooks_dir = repo / ".git" / "hooks" + hook_path = hooks_dir / "pre-commit" + hook_path.write_text("#!/bin/sh\necho foreign\n") + hook_path.chmod(0o644) + + install(repo, monkeypatch) + + mode = stat.S_IMODE(hook_path.stat().st_mode) + assert mode == 0o755 + + +# --------------------------------------------------------------------------- +# The hook body embeds an absolute path to the running executable. +# --------------------------------------------------------------------------- + + +def test_hook_body_contains_an_absolute_path(repo, monkeypatch): + install(repo, monkeypatch) + hook_path = repo / ".git" / "hooks" / "pre-commit" + body = hook_path.read_text() + + stackward_line = next( + line for line in body.splitlines() if line.startswith("STACKWARD=") + ) + quoted_path = stackward_line.removeprefix("STACKWARD=") + raw_path = quoted_path[1:-1].replace("'\\''", "'") # undo shell single-quoting + assert Path(raw_path).is_absolute() + + +def test_running_executable_returns_an_absolute_path(): + assert Path(install_hooks_module._running_executable()).is_absolute() + + +def test_running_executable_falls_back_to_path_when_argv0_is_not_executable( + tmp_path, monkeypatch +): + """`python -m stackward` sets `sys.argv[0]` to `.../stackward/__main__.py` + -- a plain source file, with no execute bit and no shebang. Embedding + that path verbatim would make the hook silently fall through to its + own `PATH` fallback on *every* run, which is exactly the dependency + this whole scheme exists to avoid relying on -- so a non-executable + `argv[0]` must be recognised and a real `PATH` lookup used instead.""" + non_executable = tmp_path / "__main__.py" + non_executable.write_text("# not executable\n") + monkeypatch.setattr(sys, "argv", [str(non_executable)]) + + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + real_stub = fake_bin / "stackward" + real_stub.write_text("#!/bin/sh\n") + real_stub.chmod(0o755) + monkeypatch.setenv("PATH", str(fake_bin)) + + assert install_hooks_module._running_executable() == str(real_stub) + + +def test_running_executable_falls_back_to_argv0_when_nothing_else_is_found( + tmp_path, monkeypatch +): + """No usable fallback exists either: better to embed the best guess + available (which the hook's own `PATH` lookup can still recover from + at runtime) than to raise here and block installation entirely.""" + non_executable = tmp_path / "__main__.py" + non_executable.write_text("# not executable\n") + monkeypatch.setattr(sys, "argv", [str(non_executable)]) + + empty_bin = tmp_path / "empty-bin" + empty_bin.mkdir() + monkeypatch.setenv("PATH", str(empty_bin)) + + assert install_hooks_module._running_executable() == str(non_executable.resolve()) + + +# --------------------------------------------------------------------------- +# The hook actually locates and execs `stackward pre-commit` -- proven by +# running the generated shell script directly, not merely inspecting text. +# --------------------------------------------------------------------------- + + +def _make_stub(path: Path, out_file: Path) -> None: + path.write_text( + "#!/bin/sh\n" + f"echo \"invoked with: $*\" > {out_file}\n" + "exit 0\n" + ) + path.chmod(0o755) + + +def test_hook_body_execs_the_embedded_absolute_path_with_pre_commit(tmp_path): + stub = tmp_path / "stackward-stub" + out_file = tmp_path / "out.txt" + _make_stub(stub, out_file) + + hook_script = tmp_path / "generated-hook" + hook_script.write_text(install_hooks_module._hook_body(str(stub))) + hook_script.chmod(0o755) + + result = subprocess.run([str(hook_script)], capture_output=True, text=True) + assert result.returncode == 0 + assert out_file.read_text().strip() == "invoked with: pre-commit" + + +def test_hook_body_falls_back_to_path_when_embedded_path_is_missing(tmp_path): + """A bare `exec stackward` from an environment with a minimal `PATH` + is what this fallback exists to avoid -- but when the embedded path + genuinely no longer exists (the binary moved, or this hook was + installed by a different clone's layout), falling back to `PATH` + keeps the hook working rather than failing with exit 127.""" + missing_path = tmp_path / "does" / "not" / "exist" / "stackward" + + fake_bin = tmp_path / "fake-bin" + fake_bin.mkdir() + out_file = tmp_path / "out.txt" + _make_stub(fake_bin / "stackward", out_file) + + hook_script = tmp_path / "generated-hook" + hook_script.write_text(install_hooks_module._hook_body(str(missing_path))) + hook_script.chmod(0o755) + + env = {"PATH": str(fake_bin)} + result = subprocess.run( + [str(hook_script)], capture_output=True, text=True, env=env + ) + assert result.returncode == 0 + assert out_file.read_text().strip() == "invoked with: pre-commit" + + +def test_hook_body_fails_clearly_when_nothing_is_found(tmp_path): + missing_path = tmp_path / "does" / "not" / "exist" / "stackward" + hook_script = tmp_path / "generated-hook" + hook_script.write_text(install_hooks_module._hook_body(str(missing_path))) + hook_script.chmod(0o755) + + empty_bin = tmp_path / "empty-bin" + empty_bin.mkdir() + result = subprocess.run( + [str(hook_script)], capture_output=True, text=True, env={"PATH": str(empty_bin)} + ) + assert result.returncode == 1 + assert "could not find the stackward executable" in result.stderr + + +# --------------------------------------------------------------------------- +# The marker line: fixed, and how "ours" is told apart from "foreign". +# --------------------------------------------------------------------------- + + +def test_marker_is_a_whole_line_in_the_generated_body(): + body = install_hooks_module._hook_body("/usr/bin/stackward") + assert install_hooks_module._MARKER in body.splitlines() + + +def test_a_hook_without_the_marker_is_not_recognised_as_ours(tmp_path): + foreign = tmp_path / "pre-commit" + foreign.write_text("#!/bin/sh\necho not stackward\n") + assert install_hooks_module._is_stackward_hook(foreign) is False + + +# --------------------------------------------------------------------------- +# Fail-closed: not a git repository, or git itself unavailable. +# --------------------------------------------------------------------------- + + +def test_outside_a_git_repository_exits_2(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + assert main(["hooks", "install"]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err != "" + + +def test_git_executable_missing_exits_2(repo, monkeypatch, capsys): + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:1] == ["git"]: + raise OSError("git not found (simulated)") + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert install(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "git not found" in captured.err + + +def test_git_rev_parse_failure_exits_2(repo, monkeypatch, capsys): + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:3] == ["git", "rev-parse", "--git-path"]: + return subprocess.CompletedProcess( + args, returncode=128, stdout="", stderr="fatal: simulated failure\n" + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert install(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "simulated failure" in captured.err + + +def test_unanticipated_exception_exits_2_not_1(repo, monkeypatch, capsys): + """Python's default exit code for an uncaught exception is 1 -- the + code `pre-commit` reserves for "a credential was found" elsewhere in + this tool. An unrelated bug here must not exit 1, which `@fail_closed` + (shared with `check_config`/`pre_commit`) is what prevents.""" + + def boom(_hooks_dir, _executable_path): + raise RuntimeError("unanticipated failure") + + monkeypatch.setattr(install_hooks_module, "_install_hook", boom) + assert install(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "RuntimeError" in captured.err + + +# --------------------------------------------------------------------------- +# Git is invoked under a fixed locale, and the fixtures are hermetic. +# --------------------------------------------------------------------------- + + +def test_every_git_invocation_runs_under_a_fixed_locale(repo, monkeypatch): + """`LC_ALL=C` on every git call, with the environment merged, not + replaced. + + Hardening for `_run_git`, which branches on the exit code -- but a live + bug for `_is_tracked`, which branches on git's *message text*. Both + strings it recognises (`did not match any file`, `is outside + repository`) are gettext-marked in git's own source, so under any + non-English locale neither matches, `_is_tracked` raises, and `hooks + install` exits 2 in **every** repository, not only one with a redirected + `core.hooksPath`. The command that installs the gate stops working. + + The merge half matters as much as the override: `git` sets `GIT_DIR` and + friends for a hook process, and a bare `env={"LC_ALL": "C"}` would drop + them and point this command at a different repository than the one it + was invoked for. + """ + monkeypatch.setenv("STACKWARD_TEST_MARKER", "inherited") + seen: list[dict[str, str] | None] = [] + real_run = subprocess.run + + def recording_run(args, **kwargs): + if args and args[0] == "git": + seen.append(kwargs.get("env")) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", recording_run) + assert install(repo, monkeypatch) == 0 + + assert seen, "no git invocation was recorded" + for env in seen: + assert env is not None, "a git invocation inherited the ambient locale" + assert env.get("LC_ALL") == "C" + assert env.get("STACKWARD_TEST_MARKER") == "inherited" + + +def test_hooks_install_survives_a_git_that_translates_its_diagnostics( + repo, monkeypatch, capsys +): + """The live half of the bug above, at the level where it actually bites. + + `_is_tracked` recognises "not tracked" by matching two English phrases, + and both are gettext-marked in git's own source. Simply exporting a + non-English `LC_ALL` here would prove nothing on a machine with no git + translations installed -- which is most CI images, and this one -- so + the translation is modelled at the subprocess boundary instead, exactly + as gettext behaves: git's stderr comes back translated **unless** the + invocation asked for the C locale. + + Deterministic everywhere, and it goes red the moment the `LC_ALL=C` + override is dropped: `_is_tracked` then matches neither phrase, raises, + and `hooks install` exits 2 in an ordinary repository with an ordinary + `.git/hooks` -- the command that installs the gate, refusing to install + it, for a reason that has nothing to do with the repository. + """ + real_run = subprocess.run + translations = { + "did not match any file": "ne correspond a aucun fichier", + "is outside repository": "est en dehors du depot", + } + + def translating_run(args, **kwargs): + proc = real_run(args, **kwargs) + env = kwargs.get("env") or os.environ + if list(args[:1]) == ["git"] and env.get("LC_ALL") != "C": + stderr = proc.stderr + if isinstance(stderr, str): + for english, translated in translations.items(): + stderr = stderr.replace(english, translated) + return subprocess.CompletedProcess( + args, proc.returncode, proc.stdout, stderr + ) + return proc + + monkeypatch.setattr(subprocess, "run", translating_run) + + assert install(repo, monkeypatch) == 0 + assert (repo / ".git" / "hooks" / "pre-commit").is_file() + assert capsys.readouterr().err == "" + + +def test_the_repo_fixture_ignores_a_global_core_hookspath(repo, isolated_git): + """The fixture's isolation, asserted rather than assumed. + + A global `core.hooksPath` -- husky, lefthook, `pre-commit`, or an + org-wide `/etc/gitconfig` -- silently moves where git looks for hooks, + so without isolation the tests in this file examine a directory the + command was never pointed at, and sixty-odd tests across this file and + `test_pre_commit.py` error out. + + The positive control is the point: it proves git *would* honour the file + written below, so the first assertion is about the fixture suppressing + it and not about the file being ineffective. + """ + hostile = isolated_git / ".gitconfig" + hostile.write_text("[core]\n\thooksPath = /nonexistent/global-hooks\n") + + def hooks_path(env: dict[str, str]) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "config", "--get", "core.hooksPath"], + cwd=repo, + capture_output=True, + text=True, + env=env, + ) + + control = hooks_path({**os.environ, "GIT_CONFIG_GLOBAL": str(hostile)}) + assert control.stdout.strip() == "/nonexistent/global-hooks" + + isolated = hooks_path(dict(os.environ)) + assert isolated.returncode == 1 + assert isolated.stdout.strip() == "" + + +# --------------------------------------------------------------------------- +# No policy the *hook* will find: a warning, never a refusal. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo_without_policy(repo, monkeypatch) -> Path: + """`repo` with its `.stackward.toml` removed from the working tree. + + `find_repo_config` reads the filesystem, not the index -- deliberately, + since `hooks install` is not the gate and does not scan staged content + -- so removing the file is enough to make the repository policy-less + from this command's point of view. + """ + (repo / ".stackward.toml").unlink() + return repo + + +def test_no_policy_warns_on_stderr_and_still_installs(repo_without_policy, monkeypatch, capsys): + """A repository that has not declared a policy yet gets told so, at the + moment it can still be fixed in one line -- rather than at somebody's + first blocked commit, since `pre-commit` refuses a repository that + declares none. + + A warning, not a refusal: installing the hook before writing the policy + is a legitimate order to do things in, and this command's exit 1 and 2 + mean specific things about the *install*, which this is not one of. + """ + assert install(repo_without_policy, monkeypatch) == 0 + assert (repo_without_policy / ".git" / "hooks" / "pre-commit").is_file() + + captured = capsys.readouterr() + assert "warning" in captured.err.lower() + assert ".stackward.toml" in captured.err + # Names the minimal file to write, not merely that one is missing. + for line in install_hooks_module.MINIMAL_POLICY.splitlines(): + assert line in captured.err + # stdout stays the machine-readable "where the hook went" line. + assert "warning" not in captured.out.lower() + assert str(repo_without_policy / ".git" / "hooks" / "pre-commit") in captured.out + + +def test_a_repository_that_declares_a_policy_gets_no_warning(repo, monkeypatch, capsys): + """The other half: the warning must not fire for the normal case, or it + is noise everyone learns to ignore.""" + assert (repo / ".stackward.toml").is_file() + assert install(repo, monkeypatch) == 0 + assert "warning" not in capsys.readouterr().err.lower() + + +@pytest.fixture +def repo_with_unstaged_policy(repo) -> Path: + """`repo` with its `.stackward.toml` still on disk but no longer in the + index. + + `--cached` removes the index entry and leaves the working-tree file + alone, which is the state a repository is in between writing a policy + and staging it -- and that is the moment somebody runs `hooks install`. + """ + _git(repo, "rm", "-q", "--cached", ".stackward.toml") + return repo + + +def test_an_unstaged_policy_warns_that_it_is_unstaged( + repo_with_unstaged_policy, monkeypatch, capsys +): + """The warning has to answer the question the hook will ask. + + `pre-commit` reads its policy from the index, so a file sitting + unstaged in the working tree is no policy to it -- and this repository + used to be told nothing at all, then block every commit. Saying + "declares none" here would be worse than silence: it is visibly false + to someone looking straight at the file, which is how a warning teaches + people to stop reading warnings. + """ + assert install(repo_with_unstaged_policy, monkeypatch) == 0 + assert (repo_with_unstaged_policy / ".git" / "hooks" / "pre-commit").is_file() + + err = capsys.readouterr().err + assert "warning" in err.lower() + assert "not staged" in err + assert f"git add {install_hooks_module.CONFIG_FILENAME}" in err + # Not the absent-policy wording: the file is right there. + assert "declares no" not in err + + +def test_the_policy_warning_does_not_change_the_exit_code_of_a_refusal( + repo_without_policy, monkeypatch, capsys +): + """A tracked hooks directory is still exit 1, and the hook was not + written -- so there is nothing to warn about and the refusal is not + diluted by a second message about a different problem.""" + (repo_without_policy / "githooks").mkdir() + (repo_without_policy / "githooks" / "pre-commit").write_text("#!/bin/sh\n") + _git(repo_without_policy, "add", "githooks") + _git(repo_without_policy, "commit", "-q", "-m", "commit shared hooks") + _git(repo_without_policy, "config", "core.hooksPath", "githooks") + + assert install(repo_without_policy, monkeypatch) == 1 + assert "no .stackward.toml" not in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# A repository that demands a newer stackward than this one. +# --------------------------------------------------------------------------- + + +def test_an_unmet_min_version_floor_refuses_before_any_hook_is_written( + repo, monkeypatch, capsys +): + """`hooks install` is the command with the most to lose from a floor + check that quietly gives up. + + Every other command refuses a `.stackward.toml` it cannot validate, so + an unreported floor still costs them only the wrong *message*. This one + reads no policy at all: with the refusal silenced it installs the hook + and exits 0, and the repository is then gated by a binary its own + policy says is too old to understand the policy. The floor is what + catches that, and it has to survive the config carrying a key this + binary does not know -- which is the shape a repository demanding a + newer stackward actually has. + + Staged, not merely written, so that the missing-policy warning has + nothing to say here and the only thing under test is the floor. + """ + # Top-level keys before the `[check]` table -- after it they would be + # members of it, and this would test something else entirely. + (repo / ".stackward.toml").write_text( + 'min_version = "9.9.9"\n' + 'future_key = "a key only a newer stackward knows"\n' + '[check]\nmodel_net = "none"\n' + ) + _git(repo, "add", ".stackward.toml") + + assert install(repo, monkeypatch) == 2 + assert not (repo / ".git" / "hooks" / "pre-commit").exists() + assert "requires stackward >= 9.9.9" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Wiring. +# --------------------------------------------------------------------------- + + +def test_hooks_install_is_registered_and_dispatches(repo, monkeypatch): + assert install(repo, monkeypatch) == 0 + + +def test_hooks_with_no_subcommand_prints_help_and_exits_2(capsys): + assert main(["hooks"]) == 2 + assert "usage:" in capsys.readouterr().out + + +def test_unexpected_argument_is_a_usage_error_exiting_2(): + with pytest.raises(SystemExit) as exit_info: + main(["hooks", "install", "unexpected-argument"]) + assert exit_info.value.code == 2 diff --git a/tests/test_model_net.py b/tests/test_model_net.py new file mode 100644 index 0000000..7c549a0 --- /dev/null +++ b/tests/test_model_net.py @@ -0,0 +1,2246 @@ +"""Tests for the model net: the declared-secrets graph, its generator, and +the freshness check that decides whether the committed one may be trusted. + +Three layers, deliberately separate: + +- **the matcher**, driven by hand-written graphs. These need neither pydantic + nor git, and they are where the semantics that make the net worth having + are pinned: prefix coverage, structural (not glob) key handling, and + matching at unbounded depth through a recursive model. +- **the generator**, run for real under this interpreter against synthetic + pydantic models written into a temporary repository. Nothing is mocked: + the marks are read from real `model_fields`, the MRO walk crosses real + module files, and the blob ids come from a real index. A mocked generator + would prove the artifact's shape and nothing about whether a mark in a + repository actually reaches it. +- **the freshness check**, on real git repositories, because "read the index, + not the working tree" is a claim about git and cannot be tested without it. + +Every model here is synthetic and named for its role in the test. +""" + +from __future__ import annotations + +import argparse +import inspect +import json +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from stackward import cli as cli_module +from stackward.nets import model as model_module +from stackward.bootstrap import generator_source +from stackward.cli import cmd_doctor +from stackward.commands import check_config as check_config_module +from stackward.commands import pre_commit as pre_commit_module +from stackward.cli import main +from stackward.commands.sync_declared import SyncError, build_artifact, serialise +from stackward.config import CheckConfig +from stackward.nets.heuristic import DocumentError +from stackward.nets.model import ( + ARTIFACT_PATH, + ARTIFACT_VERSION, + DictOf, + ListOf, + ModelNetError, + ModelRef, + find_declared_credentials, + parse_net, +) + +# --------------------------------------------------------------------------- +# Matcher fixtures: graphs written by hand, so a matcher bug cannot be masked +# by a generator bug that happens to cancel it out. +# --------------------------------------------------------------------------- + + +def build_net(models: dict, roots: dict): + """A `ModelNet` through the real artifact parser, not around it. + + Constructing `ModelNet` directly would skip every validation the parser + performs, and the parser is what a malformed committed artifact meets + first. + """ + net, _sources = parse_net( + { + "version": ARTIFACT_VERSION, + "models": models, + "roots": roots, + "sources": {"declared.py": "0" * 40}, + } + ) + return net + + +def match(document, net, allowed: list[str] | None = None) -> list[str]: + return find_declared_credentials( + document, net, CheckConfig(allowed_references=allowed or []) + ) + + +SELF_REFERENTIAL = build_net( + models={ + "declared:Node": { + "token": {"secret": True}, + "plain": {}, + "children": { + "child": { + "kind": "list", + "item": {"kind": "model", "model": "declared:Node"}, + } + }, + } + }, + roots={"app:app": "declared:Node"}, +) + +MUTUALLY_RECURSIVE = build_net( + models={ + "declared:Even": { + "odd": {"child": {"kind": "model", "model": "declared:Odd"}}, + }, + "declared:Odd": { + "token": {"secret": True}, + "even": {"child": {"kind": "model", "model": "declared:Even"}}, + }, + }, + roots={"app:app": "declared:Even"}, +) + +MARKED_CONTAINER = build_net( + models={"declared:Root": {"peer": {"secret": True}, "open": {}}}, + roots={"app:app": "declared:Root"}, +) + +MAP_OF_MODELS = build_net( + models={ + "declared:Root": { + "entries": { + "child": { + "kind": "dict", + "value": {"kind": "model", "model": "declared:Entry"}, + } + } + }, + "declared:Entry": {"token": {"secret": True}}, + }, + roots={"app:app": "declared:Root"}, +) + + +# --------------------------------------------------------------------------- +# The matcher: the semantics a flat pattern list cannot express. +# --------------------------------------------------------------------------- + + +def test_a_self_referential_model_matches_a_mark_at_depth(): + """The counterexample the graph form exists for. + + A generator emitting flat patterns needs a cycle guard, so it emits + `token` and stops — while the data it describes is unbounded. Here the + third level down must be found, which no finite pattern list produced + from this model would contain. + """ + document = { + "config": { + "app:app": { + "token": "level0", + "children": [ + {"token": "level1", "children": [{"token": "level2"}]}, + ], + } + } + } + assert match(document, SELF_REFERENTIAL) == [ + "config.app:app.children[0].children[0].token", + "config.app:app.children[0].token", + "config.app:app.token", + ] + + +def test_mutual_recursion_matches_through_both_models(): + """Neither model refers to itself; each refers to the other. The walk has + to follow the cycle as far as the *data* goes and no further.""" + document = { + "config": { + "app:app": { + "odd": { + "token": "first", + "even": {"odd": {"token": "second"}}, + } + } + } + } + assert match(document, MUTUALLY_RECURSIVE) == [ + "config.app:app.odd.even.odd.token", + "config.app:app.odd.token", + ] + + +def test_a_marked_container_covers_every_descendant(): + """Prefix, not exact: a mark covers everything beneath it whatever the + field's annotation was, and however deeply the data nests.""" + document = { + "config": { + "app:app": { + "peer": {"url": "u", "nested": {"k": "v", "list": ["a", "b"]}}, + "open": "not-declared", + } + } + } + assert match(document, MARKED_CONTAINER) == [ + "config.app:app.peer.nested.k", + "config.app:app.peer.nested.list[0]", + "config.app:app.peer.nested.list[1]", + "config.app:app.peer.url", + ] + + +def test_a_dict_key_containing_a_dot_is_reported_with_bracket_quoting(): + """Structural, not string-glob. + + The key `a.b` is one segment. A matcher that joined segments into a + dotted string and matched patterns against it would read this as two + segments and lose the leaf; the rendered finding is asserted in full + because the rendering is exactly what such an implementation gets wrong. + """ + document = {"config": {"app:app": {"entries": {"a.b": {"token": "value"}}}}} + assert match(document, MAP_OF_MODELS) == ['config.app:app.entries["a.b"].token'] + + +def test_a_dict_key_containing_a_bracket_is_reported_with_bracket_quoting(): + """The same for `[`, which a glob translating `*` to `[^.\\[]+` also + loses.""" + document = {"config": {"app:app": {"entries": {"a[0]": {"token": "value"}}}}} + assert match(document, MAP_OF_MODELS) == ['config.app:app.entries["a[0]"].token'] + + +def test_a_mapping_key_is_never_matched_against_a_field_name(): + """Under a `dict[str, Model]` the keys are data, not field names. + + The key here is literally `token`, the name of the marked field one level + further down. A matcher that consulted the model at a mapping position + would treat it as that field and cover its whole subtree — so the value + beneath it carries an *undeclared* leaf, which is what makes the + assertion discriminate rather than merely hold. + """ + document = { + "config": {"app:app": {"entries": {"token": {"undeclared": "ordinary"}}}} + } + assert match(document, MAP_OF_MODELS) == [] + + +def test_an_undeclared_field_is_not_reported(): + document = {"config": {"app:app": {"plain": "ordinary-value"}}} + assert match(document, SELF_REFERENTIAL) == [] + + +def test_a_namespace_with_no_declared_root_is_left_to_the_heuristic_net(): + document = {"config": {"other:other": {"token": "value"}}} + assert match(document, SELF_REFERENTIAL) == [] + + +def test_a_declared_field_holding_an_encryption_envelope_is_not_reported(): + """`{"secure": ...}` is Pulumi's encrypted form. Reporting it would make + the net fire on every value it had already succeeded in protecting.""" + document = {"config": {"app:app": {"token": {"secure": "v1:ciphertext"}}}} + assert match(document, SELF_REFERENTIAL) == [] + + +def test_a_marked_map_whose_only_key_is_literally_secure_is_an_accepted_false_negative(): + """The adversarial case the plan names, pinned as **accepted**, not fixed. + + A declared field may legitimately hold a mapping whose single key happens + to be the word `secure` and whose value is an ordinary plaintext string. + That is byte-for-byte the shape of Pulumi's encryption envelope, so + `heuristic.is_encrypted` suppresses it and the credential goes unreported. + + This is a deliberate trade, not an oversight. The alternative is to decide + what a ciphertext looks like -- a version prefix, a length, an alphabet -- + and every such rule reports *genuinely encrypted* values as findings the + day Pulumi changes its envelope format. A gate that fires on values it has + already succeeded in protecting is a gate that gets switched off, which + costs more than this one blind spot does. + + So the test exists to say that out loud, and to fail if someone + "fixes" it: tightening `is_encrypted` to inspect the wrapped value is + exactly the change this docstring rules out, and the sibling test above + (whose value reads like a real ciphertext) would not notice it. + """ + document = {"config": {"app:app": {"token": {"secure": "not-really-encrypted"}}}} + assert match(document, SELF_REFERENTIAL) == [] + + +def test_a_malformed_encryption_envelope_is_still_reported(): + """A sibling key beside `secure` means the mapping is not the envelope, + and nothing tells a reader the sibling was not the value that leaked.""" + document = { + "config": {"app:app": {"token": {"secure": "v1:ciphertext", "other": "leak"}}} + } + assert match(document, SELF_REFERENTIAL) == [ + "config.app:app.token.other", + "config.app:app.token.secure", + ] + + +@pytest.mark.parametrize("value", [None, "", True, False]) +def test_an_empty_declared_field_is_not_reported(value): + document = {"config": {"app:app": {"token": value}}} + assert match(document, SELF_REFERENTIAL) == [] + + +@pytest.mark.parametrize("value", [0, 1]) +def test_the_integers_zero_and_one_are_not_treated_as_empty(value): + """`value in (None, "", False, True)` would exclude these, because + `0 == False` and `1 == True`.""" + document = {"config": {"app:app": {"token": value}}} + assert match(document, SELF_REFERENTIAL) == ["config.app:app.token"] + + +def test_an_allowed_reference_is_not_reported(): + document = {"config": {"app:app": {"token": "names-another-secret"}}} + assert match(document, SELF_REFERENTIAL, ["config.app:app.token"]) == [] + + +def test_a_self_referencing_document_does_not_recurse_forever(): + """A YAML anchor/alias pair can make a mapping contain itself. The walk + must stop, and must still report the credential sitting beside the back + edge rather than abandoning the branch.""" + node: dict = {"token": "found-me"} + node["children"] = [node] + document = {"config": {"app:app": node}} + assert match(document, SELF_REFERENTIAL) == ["config.app:app.token"] + + +def test_a_document_that_is_not_a_mapping_is_refused(): + with pytest.raises(DocumentError): + match(["not", "a", "mapping"], SELF_REFERENTIAL) + + +def test_a_document_without_a_config_section_matches_nothing(): + assert match({"name": "project"}, SELF_REFERENTIAL) == [] + + +def test_a_scalar_where_a_model_was_declared_matches_nothing(): + assert match({"config": {"app:app": "scalar"}}, SELF_REFERENTIAL) == [] + + +# --------------------------------------------------------------------------- +# The artifact parser: a committed file this tool cannot fully account for is +# a refusal, never a partial walk. +# --------------------------------------------------------------------------- + + +def parse_should_fail(payload) -> str: + with pytest.raises(ModelNetError) as excinfo: + parse_net(payload) + return str(excinfo.value) + + +@pytest.mark.parametrize("version", [ARTIFACT_VERSION + 1, ARTIFACT_VERSION - 1]) +def test_an_artifact_version_this_matcher_does_not_read_is_refused(version): + """Both directions refuse, and the older one is the one that matters in + practice: an artifact written by a previous generator is fresh against + every source it records and internally consistent, while quietly covering + less than the repository declares. Only the version can say so.""" + message = parse_should_fail( + {"version": version, "models": {}, "roots": {}, "sources": {"a": "b"}} + ) + assert "unsupported version" in message + assert "sync-declared-secrets" in message + + +def test_an_artifact_written_by_the_version_1_generator_is_refused(): + """The literal version, pinned rather than expressed relative to the + constant -- a test written as `ARTIFACT_VERSION - 1` moves with the + constant and would pass just as well if the bump were reverted. + + Version 1's generator answered "this cannot hold a model" for any class it + did not recognise, so a pydantic dataclass or `TypedDict` holding a marked + field left the graph with no refusal. Such an artifact is fresh against + every source it records and internally consistent; nothing but the version + can say it covers less than the repository declares. + """ + message = parse_should_fail( + {"version": 1, "models": {}, "roots": {}, "sources": {"a": "b"}} + ) + assert "unsupported version 1" in message + assert "sync-declared-secrets" in message + + +def test_an_artifact_naming_an_undefined_model_is_refused(): + assert "undefined model" in parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": {}, + "roots": {"app:app": "declared:Missing"}, + "sources": {"a": "b"}, + } + ) + + +def test_an_artifact_with_an_unknown_child_kind_is_refused(): + assert "unknown child kind" in parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": {"declared:Root": {"f": {"child": {"kind": "tuple"}}}}, + "roots": {}, + "sources": {"a": "b"}, + } + ) + + +def test_a_non_boolean_secret_mark_is_refused_rather_than_silently_unmarking(): + """`"secret": 0` where `false` belongs -- and, just as importantly, where + `true` belonged. + + The artifact records no blob id for **itself** (see + `sync_declared.build_artifact`: a self-reference would make every artifact + stale the moment it was written), so a hand edit or a bad three-way merge + in the committed JSON is checked by nothing except this parser. That makes + every guard here load-bearing rather than defensive. + + This one in particular: `0` is falsy, so an accepted `"secret": 0` leaves + the field unmarked and every credential at or beneath it unreported -- + while reading, in a diff, as a one-character change to a line that still + says `secret`. + """ + message = parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": {"declared:Root": {"token": {"secret": 0}}}, + "roots": {"app:app": "declared:Root"}, + "sources": {"declared.py": "0" * 40}, + } + ) + assert "must be a boolean" in message + assert "token" in message + + +def test_a_null_container_interior_is_refused_rather_than_dropping_the_subtree(): + """`{"kind": "list", "item": null}`. + + The generator never writes this: a container that cannot hold a model is + written as an ordinary scalar field, so a null interior can only come from + an edit. Accepted, it produces `ListOf(None)` and the walk has nowhere to + go -- every model below that field disappears from the net, silently, and + the same document that used to be refused now reports clean. + """ + message = parse_should_fail( + { + "version": ARTIFACT_VERSION, + "models": { + "declared:Root": {"peers": {"child": {"kind": "list", "item": None}}} + }, + "roots": {}, + "sources": {"declared.py": "0" * 40}, + } + ) + assert "must not be null" in message + + +def test_an_artifact_with_no_sources_is_refused(): + """An artifact recording no sources can never be found stale, so it would + be trusted forever.""" + assert "freshness cannot be verified" in parse_should_fail( + {"version": ARTIFACT_VERSION, "models": {}, "roots": {}, "sources": {}} + ) + + +def test_a_parsed_graph_keeps_the_child_node_shapes(): + SELF = {"kind": "model", "model": "declared:Root"} + net = build_net( + models={ + "declared:Root": { + "a": {"child": SELF}, + "b": {"child": {"kind": "list", "item": SELF}}, + "c": {"child": {"kind": "dict", "value": SELF}}, + } + }, + roots={}, + ) + fields = net.models["declared:Root"] + assert fields["a"].child == ModelRef("declared:Root") + assert fields["b"].child == ListOf(ModelRef("declared:Root")) + assert fields["c"].child == DictOf(ModelRef("declared:Root")) + + +# --------------------------------------------------------------------------- +# Real repositories, real models, real git. +# --------------------------------------------------------------------------- + + +def git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True + ) + + +@pytest.fixture +def repo(tmp_path: Path, isolated_git: Path) -> Path: + """A real git repository, isolated from the machine's own git config. + + `isolated_git` (see `tests/conftest.py`) is not optional decoration + here. Without it, a global `core.hooksPath` -- husky, lefthook, the + `pre-commit` framework -- makes every `git commit` below inherit that + hook, which errored sixty-five tests in this file; and the tests that + run `hooks install` wrote a stackward hook into that global directory, + outside `tmp_path`, where it stayed after the run. + """ + path = tmp_path / "repo" + path.mkdir() + git(path, "init", "-q") + git(path, "config", "user.email", "test@example.com") + git(path, "config", "user.name", "Test") + git(path, "commit", "-q", "--allow-empty", "-m", "init") + return path + + +def write(repo: Path, name: str, content: str) -> Path: + target = repo / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(textwrap.dedent(content)) + return target + + +def declare(repo: Path, models: str = 'declared:Root', extra: str = "") -> None: + """Write a `.stackward.toml` naming this interpreter and one root model.""" + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{ "app:app" = "{models}" }} + {extra} + """, + ) + + +def sync(repo: Path, monkeypatch) -> int: + monkeypatch.chdir(repo) + return main(["sync-declared-secrets"]) + + +def check(repo: Path, monkeypatch, name: str = "Pulumi.dev.yaml") -> int: + monkeypatch.chdir(repo) + return main(["check-config", name]) + + +def artifact(repo: Path) -> dict: + return json.loads((repo / ARTIFACT_PATH).read_text()) + + +def sync_and_commit(repo: Path, monkeypatch, capsys) -> dict: + assert sync(repo, monkeypatch) == 0 + capsys.readouterr() + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "declare") + return artifact(repo) + + +RECURSIVE_MODEL = """ + from __future__ import annotations + + from pydantic import BaseModel, Field + + + class Root(BaseModel): + token: str = Field(default="", json_schema_extra={"secret": True}) + plain: str = "" + children: list[Root] = [] +""" + +# The same model with one more field. Used to change a *source* file after the +# artifact was written, which is what the freshness check has to notice. +RECURSIVE_MODEL_EDITED = RECURSIVE_MODEL + ' added_later: str = ""\n' + +# A marked field whose name matches none of the heuristic net's built-in +# patterns (password, passphrase, token, secret, apikey, api_key, jwt, +# credential, private_key, access_key_id, secret_access_key). The model net is +# the only thing that can name this one, which is what the pair of tests using +# it exists to demonstrate. +HEURISTIC_BLIND_MODEL = """ + from __future__ import annotations + + from pydantic import BaseModel, Field + + + class Root(BaseModel): + binding: str = Field(default="", json_schema_extra={"secret": True}) + plain: str = "" + children: list[Root] = [] +""" + +# Two independently declarable roots, for the tests about what happens when +# `.stackward.toml` names a namespace the artifact does not cover. +TWO_MODELS = """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + binding: str = Field(default="", json_schema_extra={"secret": True}) + + + class Other(Root): + pass +""" + +# A credential the heuristic net cannot reach: an unremarkable key name, at a +# depth no finite pattern list produced from the model would enumerate. +BLIND_DOCUMENT = ( + "config:\n" + " app:app:\n" + " children:\n" + " - children:\n" + " - binding: deeply-nested\n" +) + + +# --------------------------------------------------------------------------- +# Generation: what reaches the artifact, and what is refused rather than +# silently omitted from it. +# --------------------------------------------------------------------------- + + +def test_a_self_referential_model_generates_a_graph_not_a_pattern_list( + repo, monkeypatch, capsys +): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + + fields = written["models"]["declared:Root"] + assert fields["token"] == {"secret": True} + # The reference points back at the same model, which is what makes the + # unbounded case expressible in a finite file. + assert fields["children"]["child"] == { + "kind": "list", + "item": {"kind": "model", "model": "declared:Root"}, + } + + +def test_a_mark_inherited_from_a_base_class_in_another_module_is_detected( + repo, monkeypatch, capsys +): + write( + repo, + "ancestry.py", + """ + from pydantic import BaseModel, Field + + + class Ancestor(BaseModel): + handed_down: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write( + repo, + "declared.py", + """ + from ancestry import Ancestor + + + class Root(Ancestor): + own: str = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["handed_down"] == {"secret": True} + + +def test_a_base_class_in_another_module_is_recorded_as_a_source( + repo, monkeypatch, capsys +): + """`getsourcefile(Derived)` names only the subclass's own file, so the + freshness check must walk the MRO. Without that, removing the mark from + the base class would never register as a change.""" + write( + repo, + "ancestry.py", + """ + from pydantic import BaseModel, Field + + + class Ancestor(BaseModel): + handed_down: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write( + repo, + "declared.py", + """ + from ancestry import Ancestor + + + class Root(Ancestor): + own: str = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert "ancestry.py" in written["sources"] + + +def test_a_mark_carried_by_an_annotated_alias_defined_elsewhere_is_detected( + repo, monkeypatch, capsys +): + write( + repo, + "conventions.py", + """ + from typing import Annotated + + from pydantic import Field + + Guarded = Annotated[str, Field(json_schema_extra={"secret": True})] + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from conventions import Guarded + + + class Root(BaseModel): + borrowed: Guarded = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["borrowed"] == {"secret": True} + + +def test_the_module_defining_an_annotated_alias_is_recorded_as_a_source( + repo, monkeypatch, capsys +): + """pydantic resolves the alias's mark onto the field and discards the + wrapper, so nothing on the field says where the alias was written. The + alias module is found by identity instead — and if it were not recorded, + editing the mark out of it would leave the artifact silently wrong.""" + write( + repo, + "conventions.py", + """ + from typing import Annotated + + from pydantic import Field + + Guarded = Annotated[str, Field(json_schema_extra={"secret": True})] + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from conventions import Guarded + + + class Root(BaseModel): + borrowed: Guarded = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert "conventions.py" in written["sources"] + + +def test_the_artifact_never_records_itself_as_a_source(repo, monkeypatch, capsys): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert ARTIFACT_PATH not in written["sources"] + + +def test_regenerating_unchanged_models_produces_identical_bytes( + repo, monkeypatch, capsys +): + """The sound closure for staleness is regenerating in CI and failing on + any diff, which a generator with unstable output would turn into noise.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 0 + first = (repo / ARTIFACT_PATH).read_bytes() + assert sync(repo, monkeypatch) == 0 + capsys.readouterr() + assert (repo / ARTIFACT_PATH).read_bytes() == first + + +def test_the_artifact_is_serialised_with_sorted_keys(): + """Byte-determinism across *environments*, which regenerating twice on one + machine cannot demonstrate: dictionary insertion order here follows the + order classes happened to be walked in, and `sort_keys` is the only thing + that makes the committed bytes independent of it. + """ + text = serialise( + { + "version": ARTIFACT_VERSION, + "sources": {"zebra.py": "b" * 40, "alpha.py": "a" * 40}, + "roots": {}, + "models": {}, + } + ).decode("utf-8") + assert text.index('"models"') < text.index('"roots"') < text.index('"sources"') + assert text.index("alpha.py") < text.index("zebra.py") + + +def test_the_artifact_ends_with_exactly_one_newline(repo, monkeypatch, capsys): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 0 + capsys.readouterr() + assert (repo / ARTIFACT_PATH).read_bytes().endswith(b"}\n") + + +UNWALKABLE = { + "union_of_two_models": "peer: Other | Third = Other()", + "abstract_sequence_of_models": "peer: Sequence[Other] = []", + "abstract_mapping_of_models": "peer: Mapping[str, Other] = {}", + "bare_dict": "peer: dict = {}", + "any": "peer: Any = None", + "set_of_models": "peer: set[Other] = set()", +} + + +@pytest.mark.parametrize("name", sorted(UNWALKABLE)) +def test_an_unwalkable_annotation_is_refused_rather_than_silently_skipped( + name, repo, monkeypatch, capsys +): + """Emitting nothing for an annotation this tool cannot walk is the + fail-open the whole design exists to prevent: the artifact would look + complete while covering less than the repository declared.""" + write( + repo, + "declared.py", + f""" + from typing import Any, Mapping, Sequence + + from pydantic import BaseModel, Field + + + class Other(BaseModel): + token: str = Field(default="", json_schema_extra={{"secret": True}}) + + + class Third(BaseModel): + token: str = Field(default="", json_schema_extra={{"secret": True}}) + + + class Root(BaseModel): + {UNWALKABLE[name]} + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + assert "declared:Root.peer" in capsys.readouterr().err + + +# A class whose fields the walk cannot reach, in each of the three shapes a +# repository actually writes one. None is a `BaseModel` subclass, none has a +# `get_origin`, and none is a bare container -- so each reaches the very last +# branch of `_may_hold_model`, which is where a blanket "no" would let a +# marked field leave the graph with no refusal at all. +STRUCTURED_LEAF = { + "pydantic_dataclass": """ + from pydantic import Field + from pydantic.dataclasses import dataclass + + + @dataclass + class Peer: + handshake: str = Field(default="", json_schema_extra={"secret": True}) + """, + # `typing_extensions.TypedDict`, not `typing`'s: pydantic refuses the + # stdlib one below 3.12, so on 3.11 the import fails and the command + # refuses for pydantic's reason instead of the walkability reason this + # test exists to pin. The exit code is 2 either way -- only the + # assertion on the message tells the two apart, which is why this + # surfaced in CI and not locally. + "typed_dict": """ + from typing import Annotated + + from pydantic import Field + from typing_extensions import TypedDict + + + class Peer(TypedDict): + handshake: Annotated[str, Field(json_schema_extra={"secret": True})] + """, + "named_tuple": """ + from typing import NamedTuple + + + class Peer(NamedTuple): + handshake: str = "" + """, +} +# A plain annotated class is deliberately absent: pydantic refuses to build a +# model with such a field at all (`PydanticSchemaGenerationError`), so the walk +# never sees it. It still fails closed -- with pydantic's message rather than +# this tool's -- which is why the rule in `_declares_fields_anywhere` is stated +# as a property rather than as this list. + + +@pytest.mark.parametrize("shape", sorted(STRUCTURED_LEAF)) +def test_a_class_whose_fields_cannot_be_walked_is_refused( + shape, repo, monkeypatch, capsys +): + """The last branch of the walkability question must not answer "no". + + None of these is a `BaseModel`, a parameterised generic or a bare + container, so each falls through every earlier branch to the final one. + Answering "no" there emits an *empty* model and the field vanishes from + the graph silently -- the whole category, not one annotation -- which is + R1 inverted: an unanswerable question answered "no". + """ + write( + repo, + "peers.py", + textwrap.dedent(STRUCTURED_LEAF[shape]), + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from peers import Peer + + + class Root(BaseModel): + peer: Peer + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + assert "declared:Root.peer" in capsys.readouterr().err + + +def test_a_scalar_class_is_still_a_leaf(repo, monkeypatch, capsys): + """The other side of the same rule, and the reason it is about *public* + annotations rather than any annotations at all. + + `Enum`, `Decimal`, `UUID` and `datetime` declare nothing; pydantic's own + scalar wrappers declare only underscore-prefixed private attributes, which + pydantic itself does not collect as fields. Refusing these would make the + rule unusable, and the remedy for them ("narrow the annotation") does not + even exist. + """ + write( + repo, + "declared.py", + """ + import datetime + import decimal + import enum + import uuid + + from pydantic import AnyUrl, BaseModel, SecretStr + + + class Mode(enum.Enum): + FAST = "fast" + + + class Root(BaseModel): + mode: Mode = Mode.FAST + amount: decimal.Decimal = decimal.Decimal(0) + ident: uuid.UUID = uuid.UUID(int=0) + when: datetime.datetime = datetime.datetime(2000, 1, 1) + endpoint: AnyUrl = AnyUrl("https://example.invalid") + hidden: SecretStr = SecretStr("") + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"] == {} + + +def test_an_out_of_repo_base_declaring_no_fields_needs_no_lockfile( + repo, monkeypatch, capsys +): + """The shortcut the MRO fix must not take. + + Recording *every* base except `object` and `BaseModel` would make + `class Config(BaseModel, ABC)` record `abc` as an out-of-repo contributor + and turn a tracked lockfile into a universal precondition -- the exact + cost the `BaseModel` exclusion exists to avoid. `ABC` declares no fields, + so it contributes nothing and is not recorded. No lockfile exists in this + repository, so generation would refuse if it were. + """ + write( + repo, + "declared.py", + """ + from abc import ABC + + from pydantic import BaseModel, Field + + + class Root(BaseModel, ABC): + binding: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert set(written["sources"]) == {"declared.py", ".stackward.toml"} + + +# The same two colliding fields in both declaration orders. `plain` is an +# unmarked scalar, which emits no entry of its own; `token` is marked and +# aliased onto that same data key. +KEY_COLLISION = { + "unmarked_field_first": """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + plain: str = "" + token: str = Field( + default="", alias="plain", json_schema_extra={"secret": True} + ) + """, + "aliased_field_first": """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + token: str = Field( + default="", alias="plain", json_schema_extra={"secret": True} + ) + plain: str = "" + """, +} + + +@pytest.mark.parametrize("order", sorted(KEY_COLLISION)) +def test_a_data_key_claimed_by_two_fields_is_refused_in_either_order( + order, repo, monkeypatch, capsys +): + """The refusal must not depend on declaration order. + + An unmarked scalar emits no entry, so a check keyed on *emitted* entries + never saw it claim its key: with the unmarked field first, the marked one + took that key silently, while the same two fields written the other way + round did refuse. Both orders are asserted, because either alone passes + against the order-dependent version. + """ + write(repo, "declared.py", textwrap.dedent(KEY_COLLISION[order])) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "claimed by more than one field" in capsys.readouterr().err + + +def test_a_mark_inherited_from_a_plain_mixin_records_the_mixins_file( + repo, monkeypatch, capsys +): + """pydantic collects a marked field from a base that is not itself a + `BaseModel`, so a source filter keyed on `issubclass(base, BaseModel)` + takes the mark and discards the file it came from. The mixin is tracked + and already contributing when the artifact is written, so this is not the + "newly added file" boundary -- the MRO walk sees it and must record it. + """ + write( + repo, + "mixin_mod.py", + """ + from pydantic import Field + + + class Marks: + binding: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from mixin_mod import Marks + + + class Root(Marks, BaseModel): + plain: str = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["binding"] == {"secret": True} + assert "mixin_mod.py" in written["sources"] + + +def test_editing_a_plain_mixin_makes_the_artifact_stale(repo, monkeypatch, capsys): + """The consequence of the test above, and the thing that actually fails + open without it: a mark changing in the mixin with no refusal.""" + write( + repo, + "mixin_mod.py", + """ + from pydantic import Field + + + class Marks: + binding: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + from mixin_mod import Marks + + + class Root(Marks, BaseModel): + plain: str = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write( + repo, + "mixin_mod.py", + """ + from pydantic import Field + + + class Marks: + binding: str = Field(default="", json_schema_extra={"secret": True}) + added_later: str = Field(default="", json_schema_extra={"secret": True}) + """, + ) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n added_later: leaked\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + assert "mixin_mod.py has changed" in capsys.readouterr().err + + +def test_the_repository_config_is_a_recorded_source(repo, monkeypatch, capsys): + """`.stackward.toml` supplies `roots` and `declared_paths_fn`, so it + decides part of the artifact's content as surely as any model file does. + Leaving it out means a declaration can be added to it and never checked. + """ + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert ".stackward.toml" in written["sources"] + + +def test_adding_a_namespace_to_the_config_makes_the_artifact_stale( + repo, monkeypatch, capsys +): + """The fail-open the recorded config file closes: a second namespace + declared in `.stackward.toml` and staged, with the artifact untouched, so + the namespace is simply skipped and its credential is never looked at.""" + write(repo, "declared.py", TWO_MODELS) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{ "app:app" = "declared:Root", "app:other" = "declared:Other" }} + """, + ) + write(repo, "Pulumi.dev.yaml", "config:\n app:other:\n binding: leaked\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "sync-declared-secrets" in captured.err + # Nothing reported: before the fix this exited 0, having silently skipped + # the namespace the policy declared and the artifact did not cover. + assert captured.out == "" + + +def test_a_root_the_config_declares_and_the_artifact_lacks_is_refused( + repo, monkeypatch, capsys +): + """The second, independent guard on the same class of drift, needing no + blob id: the namespaces the artifact starts from must be exactly the ones + the policy declares. Key sets only -- a model id is `module:QualName` and + diverges from the config's `module:Class` target for a nested class.""" + net, _sources = parse_net( + { + "version": ARTIFACT_VERSION, + "models": {"declared:Root": {"token": {"secret": True}}}, + "roots": {"app:app": "declared:Root"}, + "sources": {"declared.py": "0" * 40}, + } + ) + monkeypatch.setattr(model_module, "repo_toplevel", lambda: repo) + monkeypatch.setattr(model_module, "read_artifact_from_index", lambda _root: {}) + monkeypatch.setattr(model_module, "parse_net", lambda _payload: (net, {})) + monkeypatch.setattr(model_module, "verify_sources", lambda _root, _sources: None) + + with pytest.raises(ModelNetError) as excinfo: + model_module.load_model_net( + CheckConfig( + model_net="artifact", + stack_models={"app:app": "declared:Root", "app:other": "declared:Other"}, + ) + ) + assert "app:other" in str(excinfo.value) + + +def test_a_root_model_is_refused_rather_than_covering_nothing( + repo, monkeypatch, capsys +): + """A RootModel appears in a document as its inner value, with no `root` + key, so walking it as an ordinary model would look up a field name that + can never occur and produce no coverage while saying nothing about it. + By R1's own principle, unknowable coverage refuses.""" + write( + repo, + "declared.py", + """ + from pydantic import BaseModel, Field, RootModel + + + class Wrapped(RootModel[str]): + root: str = Field(default="", json_schema_extra={"secret": True}) + + + class Root(BaseModel): + wrapped: Wrapped = Wrapped(root="") + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + error = capsys.readouterr().err + assert "declared:Wrapped" in error + assert "RootModel" in error + + +def test_a_root_model_declared_as_a_stack_root_is_refused_too( + repo, monkeypatch, capsys +): + """The refusal lives where every model passes through, so a root model + reached as a declared root and one reached through a field are refused by + the same line rather than by two that could drift apart.""" + write( + repo, + "declared.py", + """ + from pydantic import RootModel + + + class Root(RootModel[dict[str, str]]): + pass + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "RootModel" in capsys.readouterr().err + + +def test_a_refusal_names_the_field_the_class_the_annotation_and_the_way_out( + repo, monkeypatch, capsys +): + """A refusal a reader cannot act on is a refusal that gets silenced with + `model_net = "none"`, which loses the whole net -- and first adoption is + exactly when several of these arrive at once.""" + write( + repo, + "declared.py", + """ + from typing import Any + + from pydantic import BaseModel + + + class Root(BaseModel): + settings: dict[str, Any] = {} + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + error = capsys.readouterr().err + assert "settings" in error # the field + assert "declared:Root" in error # the class + # The annotation *as written*. Asserting only that "Any" appears would + # pass against a message that reported the inner type alone and sent the + # reader looking for a line saying `Any`, which their source does not have. + assert "dict[str, " in error and "Any]" in error + assert "narrow the annotation" in error # remedy 1 + assert "json_schema_extra" in error # remedy 2 + assert "declared_paths_fn" in error # remedy 3 + + +def test_a_marked_field_may_carry_an_annotation_that_cannot_be_walked( + repo, monkeypatch, capsys +): + """Coverage is prefix-based, so a marked field's annotation stops + mattering — refusing one would refuse a declaration that is already + complete.""" + write( + repo, + "declared.py", + """ + from typing import Any + + from pydantic import BaseModel, Field + + + class Root(BaseModel): + anything: Any = Field(default=None, json_schema_extra={"secret": True}) + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["anything"] == {"secret": True} + + +def test_an_annotation_that_cannot_hold_a_model_is_a_leaf(repo, monkeypatch, capsys): + """`Sequence[str]` is refused only when it could carry a model. A + container of scalars has nothing beneath it to declare, so it is not a + refusal — otherwise the rule would be unusable.""" + write( + repo, + "declared.py", + """ + from typing import Literal, Sequence + + from pydantic import BaseModel + + + class Root(BaseModel): + names: Sequence[str] = [] + mode: Literal["a", "b"] = "a" + either: str | int = "" + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"] == {} + + +def test_a_graph_the_matcher_could_not_read_is_refused_before_it_is_written( + repo, monkeypatch, capsys +): + """The generator and the matcher are two halves of one schema running in + two processes. Reading the artifact back through the matcher's own parser + before writing it turns a drift between them into a failure where it was + introduced, rather than a refusal in someone else's repository later.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + monkeypatch.chdir(repo) + with pytest.raises(SyncError) as excinfo: + build_artifact( + repo, + { + "roots": {"app:app": "declared:NeverDefined"}, + "models": {}, + "files": [str(repo / "declared.py")], + "external": [], + }, + repo / ".stackward.toml", + ) + assert "not readable" in str(excinfo.value) + assert "undefined model" in str(excinfo.value) + + +def CONFIG(repo: Path) -> Path: + """`.stackward.toml`, which `build_artifact` records as a source like any + other -- see `test_the_repository_config_is_a_recorded_source`.""" + return repo / ".stackward.toml" + + +def external_result(repo: Path) -> dict: + """A generator result whose marks came partly from outside the repository + — a base model or a marking alias imported from a dependency.""" + return { + "roots": {}, + "models": {}, + "files": [str(repo / "declared.py")], + "external": ["outside.package.conventions"], + } + + +def test_an_out_of_repo_contributor_is_pinned_by_the_dependency_lockfile(repo): + """`git show` cannot reach site-packages, so the lockfile that decides + which version is installed is the closest thing to that module's identity + the index holds.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + declare(repo) + git(repo, "add", "-A") + + sources = build_artifact(repo, external_result(repo), CONFIG(repo))["sources"] + assert "uv.lock" in sources + # Recorded by blob id, exactly like every other source. + expected = git(repo, "rev-parse", ":uv.lock").stdout.strip() + assert sources["uv.lock"] == expected + + +def test_every_tracked_lockfile_is_recorded_not_only_the_first(repo): + """A repository can carry two. Pinning one while ignoring the other would + leave a real dependency change invisible.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + write(repo, "requirements.txt", "somepackage==1.0\n") + declare(repo) + git(repo, "add", "-A") + + sources = build_artifact(repo, external_result(repo), CONFIG(repo))["sources"] + assert "uv.lock" in sources + assert "requirements.txt" in sources + + +def test_an_untracked_lockfile_does_not_pin_an_out_of_repo_contributor(repo): + """A lockfile on disk but not in the index has no blob id to compare + against later, so it pins nothing and must not be treated as though it + did.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + declare(repo) + git(repo, "add", "declared.py", ".stackward.toml") # lockfile left out + + with pytest.raises(SyncError) as excinfo: + build_artifact(repo, external_result(repo), CONFIG(repo)) + assert "outside.package.conventions" in str(excinfo.value) + + +def test_an_out_of_repo_contributor_with_no_lockfile_is_refused(repo): + """There would be nothing at all pinning that module's identity, so the + artifact could never be found stale on account of it.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + + with pytest.raises(SyncError) as excinfo: + build_artifact(repo, external_result(repo), CONFIG(repo)) + message = str(excinfo.value) + assert "outside.package.conventions" in message + assert "uv.lock" in message # the message names what it looked for + + +def test_no_lockfile_is_recorded_when_nothing_came_from_outside_the_repo(repo): + """The lockfile is evidence about a dependency, and a repository whose + marks are all its own has no dependency to pin.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write(repo, "uv.lock", "# a lockfile\n") + declare(repo) + git(repo, "add", "-A") + + sources = build_artifact( + repo, + {"roots": {}, "models": {}, "files": [str(repo / "declared.py")], "external": []}, + CONFIG(repo), + )["sources"] + assert set(sources) == {"declared.py", ".stackward.toml"} + + +def test_an_untracked_contributing_file_is_refused(repo, monkeypatch, capsys): + """A file git does not track has no blob id, so its marks could change + forever without the freshness check seeing anything.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", ".stackward.toml") + assert sync(repo, monkeypatch) == 2 + assert "not tracked by git" in capsys.readouterr().err + + +def test_a_non_string_validation_alias_is_refused(repo, monkeypatch, capsys): + """Without a determinable data key there is no position at which to apply + coverage, so a mark on such a field would cover nothing at all.""" + write( + repo, + "declared.py", + """ + from pydantic import AliasChoices, BaseModel, Field + + + class Root(BaseModel): + token: str = Field( + default="", + validation_alias=AliasChoices("token", "tok"), + json_schema_extra={"secret": True}, + ) + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "validation_alias" in capsys.readouterr().err + + +def test_a_field_is_matched_under_its_string_alias(repo, monkeypatch, capsys): + """Keying only by field name would miss an aliased field silently, which + is a fail-open; the field is emitted under every key it could appear + under.""" + write( + repo, + "declared.py", + """ + from pydantic import BaseModel, Field + + + class Root(BaseModel): + token: str = Field( + default="", alias="apiToken", json_schema_extra={"secret": True} + ) + """, + ) + declare(repo) + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"]["apiToken"] == {"secret": True} + assert written["models"]["declared:Root"]["token"] == {"secret": True} + + +def test_a_callable_json_schema_extra_is_refused(repo, monkeypatch, capsys): + """Its result depends on a schema-generation context that does not exist + here, so "cannot determine" must not become "not marked".""" + write( + repo, + "declared.py", + """ + from pydantic import BaseModel, Field + + + def annotate(schema: dict) -> None: + schema["secret"] = True + + + class Root(BaseModel): + token: str = Field(default="", json_schema_extra=annotate) + """, + ) + declare(repo) + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "callable" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# `declared_paths_fn`: the escape hatch for a non-standard marking convention. +# --------------------------------------------------------------------------- + + +def test_a_declared_paths_fn_replaces_the_marking_convention( + repo, monkeypatch, capsys +): + write( + repo, + "declared.py", + """ + from pydantic import BaseModel + + + class Root(BaseModel): + handled: str = "" + ordinary: str = "" + """, + ) + write( + repo, + "convention.py", + """ + def marks(model): + return [name for name in model.model_fields if name == "handled"] + """, + ) + declare(repo, extra='declared_paths_fn = "convention:marks"') + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + assert written["models"]["declared:Root"] == {"handled": {"secret": True}} + # The hook decides the marks, so its own module is a source too. + assert "convention.py" in written["sources"] + + +def test_a_declared_paths_fn_returning_a_path_is_refused(repo, monkeypatch, capsys): + """A hook returning flat paths would reintroduce the enumeration the + graph replaced, so it is refused rather than interpreted.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write( + repo, + "convention.py", + """ + def marks(model): + return ["children[0].token"] + """, + ) + declare(repo, extra='declared_paths_fn = "convention:marks"') + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "field names, not paths" in capsys.readouterr().err + + +def test_a_declared_paths_fn_naming_an_unknown_field_is_refused( + repo, monkeypatch, capsys +): + """A typo that marked nothing would be indistinguishable from a field + deliberately left unmarked.""" + write(repo, "declared.py", RECURSIVE_MODEL) + write( + repo, + "convention.py", + """ + def marks(model): + return ["toekn"] + """, + ) + declare(repo, extra='declared_paths_fn = "convention:marks"') + git(repo, "add", "-A") + assert sync(repo, monkeypatch) == 2 + assert "not a field of this model" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# The gate: what `check-config` does with the artifact it finds. +# --------------------------------------------------------------------------- + + +def prepare_gate(repo: Path, monkeypatch, capsys, config: str) -> None: + """A repository whose models are declared, generated, and committed.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", config) + + +def test_the_heuristic_net_alone_cannot_name_this_credential( + repo, monkeypatch, capsys +): + """Half of a pair, and the half that makes the other half mean something. + + With the model net switched off, this document is clean as far as the + gate is concerned: `binding` matches no built-in key pattern. If this + ever starts failing, the companion test below has stopped proving that + the model net is what found it. + """ + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", "-A") + assert check(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_a_declared_secret_the_heuristic_cannot_name_is_found( + repo, monkeypatch, capsys +): + """The whole point of the second net: an unremarkable key name, at a depth + no finite pattern list produced from this model would enumerate.""" + write(repo, "declared.py", HEURISTIC_BLIND_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + + assert check(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert ( + "Pulumi.dev.yaml: plaintext credential at " + "'config.app:app.children[0].children[0].binding'" in out + ) + assert "deeply-nested" not in out + + +def test_a_leaf_both_nets_name_is_reported_once(repo, monkeypatch, capsys): + """`token` matches a built-in heuristic pattern *and* carries a mark. Two + nets finding one leaf is one problem, not two.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + assert check(repo, monkeypatch) == 1 + lines = [ + line + for line in capsys.readouterr().out.splitlines() + if "config.app:app.token" in line + ] + assert len(lines) == 1 + + +def test_a_clean_config_under_a_declared_model_exits_0(repo, monkeypatch, capsys): + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n plain: ordinary\n" + ) + assert check(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# Freshness. +# --------------------------------------------------------------------------- + + +def test_a_stale_artifact_exits_2_not_1(repo, monkeypatch, capsys): + """Exit 1 means "a credential was found". A refusal to run is a different + answer and must not be filed under that one.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + git(repo, "add", "declared.py") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "declared.py has changed" in captured.err + assert "sync-declared-secrets" in captured.err + # And no finding was printed: the command does not know what was declared. + assert captured.out == "" + + +def test_an_unstaged_regeneration_does_not_rescue_a_stale_artifact( + repo, monkeypatch, capsys +): + """The artifact is read from the index, not the working tree — otherwise + a regeneration nobody staged would let the stale one that is actually + about to be committed pass.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + git(repo, "add", "declared.py") + assert sync(repo, monkeypatch) == 0 # working tree only; never staged + capsys.readouterr() + + assert check(repo, monkeypatch) == 2 + assert "sync-declared-secrets" in capsys.readouterr().err + + +def test_a_staged_regeneration_clears_the_staleness(repo, monkeypatch, capsys): + """The other direction, so the test above is not passing for the trivial + reason that nothing ever clears.""" + prepare_gate( + repo, monkeypatch, capsys, "config:\n app:app:\n token: value\n" + ) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + git(repo, "add", "declared.py") + assert sync(repo, monkeypatch) == 0 + git(repo, "add", ARTIFACT_PATH) + capsys.readouterr() + + assert check(repo, monkeypatch) == 1 + + +def test_a_missing_artifact_under_artifact_mode_exits_2(repo, monkeypatch, capsys): + """`"artifact"` never degrades to running without the net.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + error = capsys.readouterr().err + assert "is not in the git index" in error + assert "sync-declared-secrets" in error + + +def test_a_malformed_artifact_in_the_index_exits_2(repo, monkeypatch, capsys): + declare(repo) + write(repo, ARTIFACT_PATH, "{not json") + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + assert "invalid JSON" in capsys.readouterr().err + + +def test_a_recorded_source_removed_from_the_index_is_refused_and_named( + repo, monkeypatch, capsys +): + """The sibling of "blob changed": a recorded source that is not in the + index at all. + + Both branches of `verify_sources` end in exit 2 naming the regeneration + command, so an exit-code assertion cannot tell them apart -- and with the + `None` branch removed the comparison below it fires instead, reporting a + file as *changed* when it has actually been deleted or unstaged. So the + wording is what this test asserts: the reader is told which of the two + things happened, because the fix differs. + """ + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + git(repo, "add", "Pulumi.dev.yaml") + git(repo, "rm", "-q", "--cached", "declared.py") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "declared.py" in captured.err + assert "no longer in the index" in captured.err + assert "sync-declared-secrets" in captured.err + assert captured.out == "" + + +def test_an_empty_stack_models_is_refused_rather_than_covering_nothing( + repo, monkeypatch, capsys +): + """`model_net = "artifact"` with nothing declared to walk. + + Without this refusal the whole chain reads as success: `sync` writes an + artifact whose `roots` is `{}`, `_verify_roots` compares `set() == set()` + and passes, and `check-config` exits 0 having scanned with a net that + covers nothing -- a silent fallback to no model net at all, in a + repository that explicitly asked for one. That is the single outcome this + tool's fail-closed rule exists to forbid, and it is invisible. + + The artifact's *absence*, not the exit code, is what proves the refusal + happened before anything was written: a run that generated an empty net + and then failed for some other reason would exit 2 as well. + """ + write(repo, "declared.py", HEURISTIC_BLIND_MODEL) + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{}} + """, + ) + git(repo, "add", "-A") + + assert sync(repo, monkeypatch) == 2 + assert not (repo / ARTIFACT_PATH).exists() + error = capsys.readouterr().err + assert "stack_models is empty" in error + assert 'model_net = "none"' in error + + # And the consequence, end to end, on a credential only the model net + # could ever name: with no artifact there is nothing to scan with, and + # `check-config` says so instead of reporting clean. + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", "-A") + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert ARTIFACT_PATH in captured.err + assert captured.out == "" + + +def test_a_hand_edited_artifact_that_drops_a_root_is_refused_by_the_roots_check( + repo, monkeypatch, capsys +): + """The scenario `_verify_roots` is the only remaining defence for. + + Ordinarily it is unreachable: `build_artifact` records `.stackward.toml` + as a source, so any change to `check.stack_models` trips the blob-id check + in `verify_sources` first -- which is exactly why `return` as this guard's + first statement costs nothing but its own unit test. + + The artifact records no blob id for itself, though, so a hand edit or a + bad merge in the committed JSON meets nothing but the parser. Drop + `.stackward.toml` from `sources` *and* a namespace from `roots`, and + `verify_sources` has nothing left to disagree with: every source still + recorded matches the index exactly. Only the roots comparison can still + say the artifact covers less than the repository declares -- and the + credential in the dropped namespace is one the heuristic net cannot name. + """ + write(repo, "declared.py", TWO_MODELS) + write( + repo, + ".stackward.toml", + f""" + python = "{sys.executable}" + + [check] + model_net = "artifact" + stack_models = {{ "app:app" = "declared:Root", "app:other" = "declared:Other" }} + """, + ) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + + edited = artifact(repo) + del edited["sources"][".stackward.toml"] + del edited["roots"]["app:other"] + (repo / ARTIFACT_PATH).write_text(json.dumps(edited, sort_keys=True, indent=2) + "\n") + write(repo, "Pulumi.dev.yaml", "config:\n app:other:\n binding: leaked\n") + git(repo, "add", "-A") + + assert check(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert "app:other" in captured.err + assert "sync-declared-secrets" in captured.err + # Nothing reported: without this guard the namespace is simply skipped and + # its credential never looked at, and the command exits 0. + assert captured.out == "" + + +def test_a_root_declared_through_a_module_level_alias_still_verifies( + repo, monkeypatch, capsys +): + """Why `_verify_roots` compares **key sets** and not `(namespace, model)` + pairs. + + A root's model id is `module:QualName` -- what the class says about itself + -- while `check.stack_models` names whatever attribute the operator + actually imported it as. A module-level alias, or a class re-exported from + a package's `__init__`, makes the two diverge for a completely correct + artifact. Comparing pairs would refuse it, and would call it staleness, + which is not what happened; regenerating would produce the identical file + and the refusal would repeat forever. + + (The docstring here used to justify the same choice by nested classes. + That case is unreachable: `bootstrap.regen._import_object` resolves a + target with a flat `getattr`, so `declared:Outer.Inner` is refused at sync + time and never reaches an artifact. An alias is the form that does occur.) + """ + write(repo, "declared.py", TWO_MODELS + "\n Alias = Root\n") + declare(repo, models="declared:Alias") + git(repo, "add", "-A") + written = sync_and_commit(repo, monkeypatch, capsys) + + # The divergence itself, made explicit: the config asked for `Alias`, the + # artifact records what the class calls itself. + assert written["roots"] == {"app:app": "declared:Root"} + + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n binding: \"\"\n") + assert check(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_none_mode_skips_cleanly_with_no_artifact_present(repo, monkeypatch, capsys): + """A declared mode, not a fallback: nothing is read, so the absence of the + artifact directory entirely cannot rescue a test that would otherwise + have got lucky.""" + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "-A") + assert not (repo / ".stackward").exists() + + assert check(repo, monkeypatch) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_none_mode_still_runs_the_heuristic_net(repo, monkeypatch, capsys): + """Switching off the model net is not switching off the gate.""" + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n dbPassword: value\n") + git(repo, "add", "-A") + assert check(repo, monkeypatch) == 1 + assert "config.app:app.dbPassword" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# The hook: the layer that actually prevents publication. +# +# `check-config` invoked by hand reports a leak that may already be committed; +# only `pre-commit` stops one entering history. A model net that ran only on +# manual invocation would be absent at the one moment it counts, so these +# drive a real `git commit` rather than asserting on command output. +# --------------------------------------------------------------------------- + + +def install_hook(repo: Path, monkeypatch) -> None: + monkeypatch.chdir(repo) + assert main(["hooks", "install"]) == 0 + + +def attempt_commit(repo: Path, message: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "commit", "-m", message], cwd=repo, capture_output=True, text=True + ) + + +def declared_repo(repo: Path, monkeypatch, capsys, model: str) -> None: + """A repository with `model` declared, generated and committed, and the + gate installed as a real git hook.""" + write(repo, "declared.py", model) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + install_hook(repo, monkeypatch) + + +def test_a_model_net_only_finding_blocks_a_real_commit( + repo, monkeypatch, capsys, real_executable +): + """The headline claim of wiring the model net into the hook. + + `binding` matches none of the heuristic net's built-in key patterns, and + it sits two levels down a recursive model. Its companion below proves the + same commit succeeds with the model net switched off, so this test cannot + be passing because something else blocked the commit. + """ + declared_repo(repo, monkeypatch, capsys, HEURISTIC_BLIND_MODEL) + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", "Pulumi.dev.yaml") + + result = attempt_commit(repo, "add a declared secret") + assert result.returncode != 0 + output = result.stdout + result.stderr + assert "config.app:app.children[0].children[0].binding" in output + assert "deeply-nested" not in output + + +def test_the_same_commit_succeeds_with_the_model_net_switched_off( + repo, monkeypatch, capsys, real_executable +): + """The other half of the pair. Without it, the test above would pass just + as well if the hook blocked every commit.""" + declared_repo(repo, monkeypatch, capsys, HEURISTIC_BLIND_MODEL) + write(repo, ".stackward.toml", '[check]\nmodel_net = "none"\n') + write(repo, "Pulumi.dev.yaml", BLIND_DOCUMENT) + git(repo, "add", ".stackward.toml", "Pulumi.dev.yaml") + + assert attempt_commit(repo, "add the same document").returncode == 0 + + +def test_a_stale_artifact_blocks_a_real_commit( + repo, monkeypatch, capsys, real_executable +): + """The hook reads the artifact from the index and refuses when it no + longer matches its sources -- it does not fall back to the heuristic net + alone, which would be a silent downgrade at commit time.""" + declared_repo(repo, monkeypatch, capsys, RECURSIVE_MODEL) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n plain: ordinary\n") + git(repo, "add", "declared.py", "Pulumi.dev.yaml") + + result = attempt_commit(repo, "change a model without regenerating") + assert result.returncode != 0 + assert "sync-declared-secrets" in result.stdout + result.stderr + + +def test_the_hook_exits_2_on_a_stale_artifact_not_1(repo, monkeypatch, capsys): + """Driven in-process, because a `git commit` reports only "the hook said + no" and cannot distinguish the two codes -- and the distinction is the + whole reason exit 2 exists.""" + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "declared.py", RECURSIVE_MODEL_EDITED) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n token: value\n") + git(repo, "add", "declared.py", "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert main(["pre-commit"]) == 2 + captured = capsys.readouterr() + assert "sync-declared-secrets" in captured.err + # Nothing reported: the command does not know what the repository declared. + assert captured.out == "" + + +def test_the_hook_reports_a_leaf_both_nets_name_once(repo, monkeypatch, capsys): + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", "config:\n app:app:\n token: value\n") + git(repo, "add", "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert main(["pre-commit"]) == 1 + lines = [ + line + for line in capsys.readouterr().out.splitlines() + if "plaintext credential at" in line + ] + assert len(lines) == 1 + + +# --------------------------------------------------------------------------- +# Talking to git. +# --------------------------------------------------------------------------- + + +def test_every_git_invocation_runs_under_a_fixed_locale(repo, monkeypatch, capsys): + """`nets.model.run_git` is the third git call site in this tool, and the + last one without a fixed locale -- `commands.pre_commit` and + `commands.install_hooks` have theirs. + + Hardening rather than a live fix here: every caller in this module + branches on `returncode`, never on git's text. But git's diagnostics are + gettext-marked, and this module quotes git's stderr verbatim into a + `ModelNetError` a reader is expected to act on. + + **The merge is the half that matters.** git exports `GIT_DIR` and + `GIT_INDEX_FILE` to a hook process, and this module's `ls-files`/`show` + calls are what read the index the commit is being gated on. A bare + `env={"LC_ALL": "C"}` would silently point them at a different index than + `pre-commit` is checking -- a fail-open that no assertion about output + could catch, which is why the ambient marker is asserted alongside the + locale. + + Driven through `check-config` under `model_net = "artifact"` rather than + by calling `run_git` directly, so it covers every invocation this module + makes -- `rev-parse`, `ls-files` and `show` -- rather than the one a + direct call happens to pick. `check-config` shells out to nothing but + git, so every recorded call came from this module. + """ + write(repo, "declared.py", RECURSIVE_MODEL) + declare(repo) + git(repo, "add", "-A") + sync_and_commit(repo, monkeypatch, capsys) + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + + # Recording starts only now: this file's own `git` helper shells out too, + # and it is not what is under test. + monkeypatch.setenv("STACKWARD_TEST_MARKER", "inherited") + seen: list[dict[str, str] | None] = [] + real_run = subprocess.run + + def recording_run(args, **kwargs): + if args and args[0] == "git": + seen.append(kwargs.get("env")) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", recording_run) + assert check(repo, monkeypatch) == 0 + capsys.readouterr() + + assert seen, "no git invocation was recorded" + for env in seen: + assert env is not None, "a git invocation inherited the ambient locale" + assert env.get("LC_ALL") == "C" + assert env.get("STACKWARD_TEST_MARKER") == "inherited" + + +# --------------------------------------------------------------------------- +# Global Constraint 1: pydantic is test-only. +# --------------------------------------------------------------------------- + + +def test_stackward_never_imports_pydantic(): + """The shipped binary must neither import nor bundle pydantic. Checked in + a subprocess because this test process has imported it already — asserting + against `sys.modules` here would assert nothing. + """ + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import stackward.cli; " + "import stackward.commands.sync_declared; " + "print('pydantic' in sys.modules)", + ], + capture_output=True, + text=True, + check=True, + ) + assert proc.stdout.strip() == "False" + + +@pytest.mark.parametrize( + "entry_point", + [check_config_module.scan_file, pre_commit_module._scan_staged_config], +) +def test_neither_scan_entry_point_makes_the_model_net_optional(entry_point): + """`net` must have no default on either scanner. + + A default would make the model net silently opt-in: a future caller that + simply forgot the argument would scan with the heuristic net alone and + report clean, which is the failure this whole net exists to prevent. The + property is a signature, so it is asserted as one -- there is no + behaviour to observe, because the whole point is what happens to a call + site that does not yet exist. + """ + parameter = inspect.signature(entry_point).parameters["net"] + assert parameter.default is inspect.Parameter.empty + + +# The gate-path invariant this net's own imports could have broken — that +# `check-config` and `pre-commit` reach no credential-store or `cryptography` +# code — lives in `tests/test_gate_isolation.py`. It used to live here, and +# it was checked by importing the two command modules *directly*: that +# bypassed `cli.py`, which imported `commands.check_passphrase` and +# `commands.session` at module scope and so pulled `cryptography` and +# `stackward.store` onto the real gate path with this test still green. The +# replacement runs `cli.main(["check-config", ...])`, the path a `git commit` +# actually takes, and asserts the union of every forbidden module. + + +def test_doctor_reports_the_model_walker_as_readable(capsys): + """`regen.py` is carried into the frozen bundle by an explicit + `--add-data` entry rather than by import analysis, so dropping that entry + produces a binary that builds, starts, and then fails only at + `sync-declared-secrets`. `doctor` is the release smoke test, so it checks + the real accessor -- and the release workflow greps for this exact line, + which is why the prefix is asserted verbatim rather than loosely. + """ + assert cmd_doctor(argparse.Namespace()) == 0 + reported = [ + line + for line in capsys.readouterr().out.splitlines() + if line.startswith("model walker regen.py readable") + ] + assert len(reported) == 1 + + +def test_doctor_fails_when_the_model_walker_cannot_be_read(monkeypatch, capsys): + """The check has to discriminate, or the release workflow's grep is + decoration. Verified against a real frozen bundle built without the + `--add-data` entry as well; this is the version that runs every time.""" + monkeypatch.setattr( + cli_module, + "generator_source", + lambda: (_ for _ in ()).throw(FileNotFoundError("regen.py")), + ) + assert cmd_doctor(argparse.Namespace()) == 1 + assert "model walker UNAVAILABLE" in capsys.readouterr().out + + +def test_doctor_fails_when_the_bundled_file_is_not_the_generator( + monkeypatch, capsys +): + """Present but wrong is a distinct failure from absent, and it would + otherwise read as success.""" + monkeypatch.setattr(cli_module, "generator_source", lambda: "# not it\n") + assert cmd_doctor(argparse.Namespace()) == 1 + assert "model walker FAILED" in capsys.readouterr().out + + +def test_the_generator_is_read_through_the_accessor_the_shipped_code_uses(): + """Reading the file by path instead would assert nothing about a frozen + build, where that path does not exist.""" + source = generator_source() + assert "def generate(" in source + assert "from pydantic import BaseModel" in source diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..5048d52 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,280 @@ +"""Tests for the config-path grammar: findings, manifests and matching all +depend on `parse`/`render` agreeing with each other in both directions.""" + +from __future__ import annotations + +import pytest + +from stackward.paths import parse, render + +# Spelled out via concatenation rather than string literals with `\\` runs, +# so the number of actual backslash characters in each fixture is unambiguous +# at the call site. +BACKSLASH = "\\" +TWO_BACKSLASHES = BACKSLASH * 2 + +# Canonical texts: render(parse(x)) must reproduce x exactly. Every entry +# here is already in the form `render` would itself produce. +CANONICAL_TEXTS = [ + "a.b.c", + "a[0]", + "a.b[3].c", + 'a["b.c"]', + 'a["b[c]"]', + 'a["b\\"c"]', + 'a[""]', + "a.1", + "[0]", + "a[0][1]", + '["a\\\\b"]', # a literal backslash, doubled +] + +# Segment lists: parse(render(y)) must reproduce y exactly. Includes forms +# `render` would not itself choose (e.g. a quoted digit key), which is fine — +# the round trip only requires equality, not that the text be canonical. +SEGMENT_LISTS: list[list[str | int]] = [ + ["a", "b", "c"], + ["a", 0], + ["a", "b", 3, "c"], + ["a", "b.c"], + ["a", "b[c]"], + ['a', 'b"c'], + ["a", ""], + ["a", "1"], + [0], + ["1"], + [1], + ["a", 0, "b"], # dot must be restored after a bracket + ["a", 0, 1], # two indices in a row, no dot between brackets + ["a", 0, "b.c"], # a quoted bracket right after another bracket + ["a" + BACKSLASH + "b"], # one backslash, no other reason to quote + ["a." + BACKSLASH], # quoted for the dot, then ends in one backslash + ["a." + TWO_BACKSLASHES], # quoted for the dot, then ends in two backslashes +] + + +def test_parses_a_plain_dotted_path(): + assert parse("a.b.c") == ["a", "b", "c"] + + +def test_parses_a_list_index_as_int(): + segments = parse("a[0]") + assert segments == ["a", 0] + assert type(segments[1]) is int + + +def test_key_containing_a_dot_round_trips_through_bracket_form(): + segments = ["a", "b.c"] + text = render(segments) + assert text == 'a["b.c"]' + assert parse(text) == segments + + +def test_key_containing_a_bracket_round_trips_through_bracket_form(): + segments = ["a", "b[c]"] + text = render(segments) + assert text == 'a["b[c]"]' + assert parse(text) == segments + + +def test_key_containing_a_quote_is_escaped_as_backslash_quote(): + segments = ["a", 'b"c'] + text = render(segments) + assert text == 'a["b\\"c"]' + assert parse(text) == segments + + +def test_empty_string_key_round_trips_through_bracket_form(): + segments = ["a", ""] + text = render(segments) + assert text == 'a[""]' + assert parse(text) == segments + + +def test_quoted_string_digit_key_and_unquoted_list_index_do_not_collapse_on_parse(): + """`["1"]` is the string "1"; `[1]` is the index 1 — parsing must keep + them apart, not just the segment lists' equality but their types too.""" + string_segments = parse('a["1"]') + index_segments = parse("a[1]") + + assert string_segments == ["a", "1"] + assert type(string_segments[1]) is str + + assert index_segments == ["a", 1] + assert type(index_segments[1]) is int + + assert string_segments != index_segments + + +def test_string_digit_key_and_list_index_do_not_collapse_on_render(): + """The same distinction, the other way: rendering a string "1" must not + produce the same text as rendering the index 1.""" + string_text = render(["a", "1"]) + index_text = render(["a", 1]) + + assert string_text == "a.1" + assert index_text == "a[1]" + assert string_text != index_text + + # And each parses back to the segment it came from, not the other one. + assert parse(string_text) == ["a", "1"] + assert parse(index_text) == ["a", 1] + + +@pytest.mark.parametrize("text", CANONICAL_TEXTS) +def test_render_of_parse_reproduces_canonical_text(text): + assert render(parse(text)) == text + + +@pytest.mark.parametrize("segments", SEGMENT_LISTS) +def test_parse_of_render_reproduces_segment_list(segments): + assert parse(render(segments)) == segments + + +def test_dot_is_restored_after_a_bracket_for_the_next_bare_segment(): + assert render(["a", 0, "b"]) == "a[0].b" + + +def test_no_dot_between_two_consecutive_indices(): + assert render(["a", 0, 1]) == "a[0][1]" + + +def test_no_dot_before_a_quoted_bracket_that_follows_another_bracket(): + assert render(["a", 0, "b.c"]) == 'a[0]["b.c"]' + + +def test_unterminated_bracket_raises_value_error_naming_the_position(): + with pytest.raises(ValueError) as exc_info: + parse("a[0") + message = str(exc_info.value) + assert "position 1" in message # the unclosed '[' + assert "a[0" not in message + + +def test_empty_key_between_dots_raises_value_error_naming_the_position(): + with pytest.raises(ValueError) as exc_info: + parse("a..b") + message = str(exc_info.value) + assert "position 2" in message + assert "a..b" not in message + + +def test_non_digit_list_index_raises_value_error_naming_the_position(): + with pytest.raises(ValueError) as exc_info: + parse("a[x]") + message = str(exc_info.value) + assert "position 2" in message + assert "a[x]" not in message + + +def test_render_rejects_bool_segment_rather_than_silently_treating_it_as_an_index(): + """bool is an int subclass; letting one through would make `render([True])` + silently produce `[1]`.""" + with pytest.raises(ValueError): + render(["a", True]) + + +def test_render_rejects_negative_index(): + with pytest.raises(ValueError): + render(["a", -1]) + + +def test_render_rejects_empty_segment_list(): + with pytest.raises(ValueError): + render([]) + + +def test_backslash_alone_is_quoted_and_escaped_as_double_backslash(): + """A key with no dot/bracket/quote but containing `\\` still has to be + quoted — `\\` is only safe to leave bare if it never means anything + special, and it does mean something special once it appears inside a + quoted key elsewhere.""" + segments = ["a" + BACKSLASH + "b"] + text = render(segments) + assert text == '["a\\\\b"]' + assert parse(text) == segments + + +def test_quoted_key_ending_in_one_literal_backslash_round_trips(): + """Regression: escaping only `"` (never `\\`) let a trailing backslash + merge with the closing delimiter's quote on decode, so a key quoted for + an unrelated reason (here, the dot) that also ends in `\\` failed to + round-trip — `parse(render(y))` raised instead of reproducing `y`.""" + segments = ["a." + BACKSLASH] + assert parse(render(segments)) == segments + + +def test_quoted_key_ending_in_two_literal_backslashes_round_trips(): + segments = ["a." + TWO_BACKSLASHES] + assert parse(render(segments)) == segments + + +def test_leading_zero_in_list_index_is_rejected_as_malformed(): + """`render` never produces "01" for index 1; tolerating it on parse would + give the same index two silently-equivalent spellings.""" + with pytest.raises(ValueError) as exc_info: + parse("a[01]") + message = str(exc_info.value) + assert "position 2" in message + assert "a[01]" not in message + + +def test_invalid_escape_sequence_raises_value_error_naming_the_position(): + """A `\\` inside a quoted key that isn't followed by `"` or `\\` is + malformed, not a literal backslash silently passed through.""" + with pytest.raises(ValueError) as exc_info: + parse('a["b\\nc"]') + message = str(exc_info.value) + assert "position 4" in message # the stray backslash itself + assert "b\\nc" not in message + + +# --------------------------------------------------------------------------- +# The grammar's refusals. Every branch below is a `ValueError` that no other +# test in this file reaches, and `set_secrets.parse_project_manifest` wraps +# `parse` in its own `try`, so none of them can ever surface as a traceback a +# reader would notice. A refusal that has never executed is where a fail-open +# hides: the branch could have been deleted, or could raise something the +# caller does not catch, and the suite would stay green either way. +# +# Each case asserts the position as well as the wording, because the position +# is what makes a refusal actionable, and asserts the offending text is absent +# from the message, which is the promise `parse`'s own docstring makes. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("text", "position", "fragment"), + [ + # A path may not begin with the separator: `.a` names no first segment. + (".a", 0, "cannot start with"), + # A bare key may not contain a character that only means something + # inside the bracket form -- here the closing bracket itself. + ("a]b", 1, "unexpected character in key"), + # A quoted key whose closing `"` never arrives. + ('a["b', 3, "unterminated quoted key"), + # A quoted key that closes, followed by anything other than `]`. + ('a["b"c', 5, "expected ']' after quoted key"), + # A bare character resuming straight after a bracket, with no + # separator -- the `b` in `a[0]b`. + ("a[0]b", 4, "expected '.' or '['"), + ], +) +def test_a_malformed_path_is_refused_naming_the_position(text, position, fragment): + with pytest.raises(ValueError) as exc_info: + parse(text) + message = str(exc_info.value) + assert f"position {position}" in message + assert fragment in message + assert text not in message + + +def test_the_empty_path_is_refused(): + """Its own test rather than a parametrised case: there is no offending + text to assert the absence of, and `""` is a substring of every string, + so the shared `text not in message` assertion would be vacuous for it.""" + with pytest.raises(ValueError) as exc_info: + parse("") + message = str(exc_info.value) + assert "position 0" in message + assert "empty path" in message diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py new file mode 100644 index 0000000..bf4e8de --- /dev/null +++ b/tests/test_pre_commit.py @@ -0,0 +1,944 @@ +"""Tests for `pre-commit` -- the gate run as a git hook body over staged +content. + +Every test here uses a real temporary git repository, created with `git +init`. This command's entire reason to exist is a handful of git behaviours +that are easy to get subtly wrong (a rename-plus-edit staging as `R`, an +unmerged path vanishing from a plain `--diff-filter=ACMR` listing, a +non-ASCII or space-containing path being quoted without `-z`) -- mocking +git would hide exactly the bugs this suite exists to catch. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from stackward.cli import main +from stackward.commands import pre_commit as pre_commit_module + +# --------------------------------------------------------------------------- +# Real-git-repository fixtures and helpers. +# --------------------------------------------------------------------------- + + +def _git(repo: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", *args], cwd=repo, capture_output=True, text=True, check=True + ) + + +# The smallest policy that satisfies the gate: an explicit `model_net`, which +# is the whole minimum, because it is the one key whose default silently +# drops a net. +MINIMAL_POLICY = '[check]\nmodel_net = "none"\n' + + +@pytest.fixture +def bare_repo(tmp_path: Path, isolated_git: Path) -> Path: + """A real git repository with one empty commit and **no** policy file. + + What every repository looked like before `.stackward.toml` was a + refusal; kept as its own fixture so the tests that assert the refusal + cannot be quietly rescued by a fixture that starts declaring one. + """ + path = tmp_path / "repo" + path.mkdir() + _git(path, "init", "-q") + _git(path, "config", "user.email", "test@example.com") + _git(path, "config", "user.name", "Test") + _git(path, "commit", "-q", "--allow-empty", "-m", "init") + return path + + +@pytest.fixture +def repo(bare_repo: Path) -> Path: + """`bare_repo` with a minimal `.stackward.toml` **committed**. + + Committed, not merely written: this command reads its policy out of the + index exactly as it reads the content it scans, so a policy sitting only + in the working tree is not a policy as far as the gate is concerned and + the whole command refuses (exit 2) before reaching whatever the test was + actually about. Every test below that is not itself about policy + discovery therefore needs one in the index. + """ + write(bare_repo, ".stackward.toml", MINIMAL_POLICY) + stage(bare_repo, ".stackward.toml") + _git(bare_repo, "commit", "-q", "-m", "declare policy") + return bare_repo + + +def write(repo: Path, name: str, content: str) -> Path: + target = repo / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + return target + + +def write_bytes(repo: Path, name: str, content: bytes) -> Path: + target = repo / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return target + + +def stage(repo: Path, *names: str) -> None: + _git(repo, "add", *names) + + +def run_pre_commit(repo: Path, monkeypatch) -> int: + monkeypatch.chdir(repo) + return main(["pre-commit"]) + + +def numbered_yaml(count: int = 200) -> str: + """A YAML mapping large enough that git's default rename-similarity + threshold (50%) still classifies a one-line edit as a rename, not a + delete-plus-add.""" + return "\n".join(f"key{i}: value{i}" for i in range(count)) + "\n" + + +# --------------------------------------------------------------------------- +# Clean and simple credential-finding cases. +# --------------------------------------------------------------------------- + + +def test_clean_stack_config_exits_0_with_no_output(repo, monkeypatch, capsys): + write(repo, "Pulumi.dev.yaml", "name: myproject\nruntime: nodejs\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_plaintext_credential_exits_1_with_finding_and_remediation( + repo, monkeypatch, capsys +): + write(repo, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.dev.yaml: plaintext credential at 'config.myproject:dbPassword'" in out + assert "pulumi config set --secret --path 'config.myproject:dbPassword'" in out + assert "--no-verify" in out + # The value itself must never appear anywhere in the output. + assert "hunter2" not in out + + +# --------------------------------------------------------------------------- +# R matters: a rename plus an edit stages as `R`, not `A`/`M`. +# --------------------------------------------------------------------------- + + +def test_rename_plus_edit_staged_as_r_is_caught(repo, monkeypatch, capsys): + """The exact bug `--diff-filter=ACMR` (not `ACM`) exists to prevent: + renaming a stack config to a new name while also introducing a + credential stages as a single `R` change, not `A`+`D` or `M`. Omitting + `R` from the filter would let this change bypass the gate entirely.""" + write(repo, "Pulumi.old.yaml", numbered_yaml()) + stage(repo, "Pulumi.old.yaml") + _git(repo, "commit", "-q", "-m", "add old config") + + _git(repo, "mv", "Pulumi.old.yaml", "Pulumi.dev.yaml") + edited = (repo / "Pulumi.dev.yaml").read_text().replace( + "key5: value5", "password: hunter2" + ) + write(repo, "Pulumi.dev.yaml", edited) + stage(repo, "Pulumi.dev.yaml") + + # Prove the premise: git really did record this as a rename, not a + # plain modify or an add+delete pair. Without this assertion the test + # could pass for the wrong reason if git's rename heuristic ever + # stopped firing here. + status = _git(repo, "diff", "--cached", "--name-status").stdout + assert status.startswith("R") + + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.dev.yaml: plaintext credential at 'password'" in out + + +def test_diff_filter_without_r_would_have_missed_the_renamed_file(repo, monkeypatch): + """Direct proof of why `R` is in the filter: with it excluded, the + renamed-and-edited path is invisible to the staged-file listing this + command builds everything else on top of.""" + write(repo, "Pulumi.old.yaml", numbered_yaml()) + stage(repo, "Pulumi.old.yaml") + _git(repo, "commit", "-q", "-m", "add old config") + _git(repo, "mv", "Pulumi.old.yaml", "Pulumi.dev.yaml") + edited = (repo / "Pulumi.dev.yaml").read_text().replace( + "key5: value5", "password: hunter2" + ) + write(repo, "Pulumi.dev.yaml", edited) + stage(repo, "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert pre_commit_module._staged_paths("ACM") == [] + assert pre_commit_module._staged_paths("ACMR") == ["Pulumi.dev.yaml"] + + +# --------------------------------------------------------------------------- +# T matters too: converting an already-tracked stack config into a +# symlink stages as a type-change (`T`), which `ACMR` alone excludes just +# as it excludes an unmerged path -- ruled during review as a genuine +# fail-open gap in the brief's own literal `--diff-filter=ACMR`, closed by +# adding `T`. +# --------------------------------------------------------------------------- + + +def test_tracked_config_replaced_by_a_symlink_exits_2_not_0(repo, monkeypatch, capsys): + """A symlink's staged "content" (via `git show ":"`) is its + target path, not YAML -- so once the type-change is actually seen by + the scan, it fails closed (exit 2) rather than passing as clean or + silently vanishing from the listing entirely.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "add config") + + (repo / "Pulumi.dev.yaml").unlink() + (repo / "Pulumi.dev.yaml").symlink_to("/etc/hostname") + stage(repo, "Pulumi.dev.yaml") + + # Prove the premise: git really did record this as a type-change, not + # a plain modify. + status = _git(repo, "diff", "--cached", "--name-status").stdout + assert status.startswith("T") + + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + + +def test_diff_filter_without_t_would_have_missed_the_symlinked_config(repo, monkeypatch): + """Direct proof of why `T` is in the filter: with it excluded, a + tracked stack config converted to a symlink is invisible to the + staged-file listing -- exactly the way an unmerged path is invisible + to a filter that omits `U` -- so the commit would exit 0 instead of + ever reaching the scan that would refuse it.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "add config") + + (repo / "Pulumi.dev.yaml").unlink() + (repo / "Pulumi.dev.yaml").symlink_to("/etc/hostname") + stage(repo, "Pulumi.dev.yaml") + + monkeypatch.chdir(repo) + assert pre_commit_module._staged_paths("ACMR") == [] + assert pre_commit_module._staged_paths("ACMRT") == ["Pulumi.dev.yaml"] + + +# --------------------------------------------------------------------------- +# Pulumi state exports: refused outright, before any content check. +# --------------------------------------------------------------------------- + + +def test_staged_state_json_is_refused_before_any_scan(repo, monkeypatch, capsys): + write(repo, "state.json", '{"deployment": {"resources": []}}') + stage(repo, "state.json") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "state.json" in out + assert "refused" in out + assert "--no-verify" in out + + +def test_staged_stack_export_json_suffix_is_refused(repo, monkeypatch, capsys): + write(repo, "backups/mydeploy.stack-export.json", "{}") + stage(repo, "backups/mydeploy.stack-export.json") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "backups/mydeploy.stack-export.json" in out + + +def test_state_export_refusal_wins_over_a_broken_pulumi_config(repo, monkeypatch, capsys): + """"Before any other check": a state export staged alongside a stack + config that would otherwise fail to parse must still exit 1 (refused), + never 2 (as the parse failure alone would) -- the state-export rule is + the very first gate, not one gate among several evaluated together.""" + write(repo, "state.json", "{}") + write(repo, "Pulumi.dev.yaml", "key: [unclosed\n") + stage(repo, "state.json", "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + assert "state.json" in capsys.readouterr().out + + +def test_state_export_refusal_needs_no_valid_repo_config(repo, monkeypatch, capsys): + """The outright refusal happens before `.stackward.toml` is even + loaded: a state export staged in a repository whose policy file is + broken must still exit 1 (refused), never 2 (as the broken config + alone would if a real credential scan needed to run). + + The broken config is *staged* here. It has to be: this command reads + its policy from the index, so an unstaged edit would be invisible to it + and the test would pass without the ordering it claims to check ever + being exercised. Staging it makes the config genuinely unloadable for + this invocation, which is exactly the condition the state-export rule + must not depend on. + """ + write(repo, ".stackward.toml", "this is not valid toml [[[") + write(repo, "state.json", "{}") + stage(repo, ".stackward.toml", "state.json") + # Premise: this config really would sink the run if policy were loaded + # first -- proved by the companion test below, which stages the same + # broken file without a state export and gets exit 2. + assert run_pre_commit(repo, monkeypatch) == 1 + assert "state.json" in capsys.readouterr().out + + +def test_a_broken_staged_config_alone_exits_2(repo, monkeypatch, capsys): + """The companion that makes the ordering test above mean something: the + same broken `.stackward.toml`, staged, with no state export to short- + circuit the run, is a could-not-run refusal. + + Mutating `cmd_pre_commit`'s policy guard to swallow the error and carry + on with `CheckConfig()` defaults survived the whole suite before this + test existed -- a repository could ship a typo in its policy and be + scanned by the weaker net without anyone being told. + """ + write(repo, ".stackward.toml", "this is not valid toml [[[") + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, ".stackward.toml", "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "invalid TOML" in captured.err + # Named the way a person reproduces it: `git show ":.stackward.toml"`. + assert ":.stackward.toml" in captured.err + + +# --------------------------------------------------------------------------- +# Matching `Pulumi..yaml` by prefix/suffix, not a character class. +# --------------------------------------------------------------------------- + + +def test_project_file_pulumi_yaml_is_not_a_stack_config(repo, monkeypatch, capsys): + """`Pulumi.yaml` (no stack name) is the *project* file, not a stack + config -- a naive `startswith("Pulumi.") and endswith(".yaml")` also + matches it, because the two literals overlap on the shared `.`.""" + write(repo, "Pulumi.yaml", "name: myproject\npassword: hunter2\n") + stage(repo, "Pulumi.yaml") + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_stack_name_containing_dots_is_matched(repo, monkeypatch, capsys): + write(repo, "Pulumi.prod.eu.yaml", "password: hunter2\n") + stage(repo, "Pulumi.prod.eu.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + assert "Pulumi.prod.eu.yaml: plaintext credential at 'password'" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "basename", + [ + "Pulumi.dev.yaml", + "Pulumi.prod.eu.yaml", + # Stack name is a single "." -- "Pulumi." + "." + ".yaml". + "Pulumi...yaml", + ], +) +def test_is_pulumi_stack_config_matches_a_non_empty_stack(basename): + assert pre_commit_module._is_pulumi_stack_config(basename) is True + + +@pytest.mark.parametrize( + "basename", + [ + "Pulumi.yaml", + "NotPulumi.dev.yaml", + "Pulumi.dev.yml", + "readme.md", + # "Pulumi." + "" + ".yaml": the prefix and suffix abut with no + # stack name between them at all -- the empty-stack case, same as + # "Pulumi.yaml" but spelled with the shared "." written out twice + # instead of once. + "Pulumi..yaml", + ], +) +def test_is_pulumi_stack_config_rejects_non_stack_names(basename): + assert pre_commit_module._is_pulumi_stack_config(basename) is False + + +@pytest.mark.parametrize( + "path", ["state.json", "backups/state.json", "x.stack-export.json"] +) +def test_is_state_export_matches(path): + assert pre_commit_module._is_state_export(path) is True + + +@pytest.mark.parametrize( + "path", ["state.json.bak", "notstate.json", "Pulumi.dev.yaml"] +) +def test_is_state_export_rejects_lookalikes(path): + assert pre_commit_module._is_state_export(path) is False + + +# --------------------------------------------------------------------------- +# Staged content, not the working tree. +# --------------------------------------------------------------------------- + + +def test_staged_credential_is_caught_even_after_the_working_tree_is_cleaned( + repo, monkeypatch, capsys +): + """The defining behaviour of this command: it reads `git show + ":"`, so an edit made *after* staging (here, one that removes the + credential from the working tree entirely) must not let the + already-staged credential through.""" + write(repo, "Pulumi.dev.yaml", "config:\n myproject:dbPassword: hunter2\n") + stage(repo, "Pulumi.dev.yaml") + # Overwrite the working tree with a clean version, without staging it. + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "plaintext credential at 'config.myproject:dbPassword'" in out + + +def test_unstaged_credential_in_the_working_tree_does_not_block(repo, monkeypatch, capsys): + """The inverse of the test above: a credential sitting only in the + working tree, never staged, must not fail a commit of the clean staged + content -- this command checks the index, not the working tree.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + # Introduce a credential in the working tree without staging it. + write(repo, "Pulumi.dev.yaml", "password: hunter2\n") + + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# Paths with a space or non-ASCII characters, via -z / NUL splitting. +# --------------------------------------------------------------------------- + + +def test_filename_containing_a_space_is_checked(repo, monkeypatch, capsys): + write(repo, "Pulumi.my stack.yaml", "password: hunter2\n") + stage(repo, "Pulumi.my stack.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.my stack.yaml: plaintext credential at 'password'" in out + + +def test_filename_containing_non_ascii_is_checked(repo, monkeypatch, capsys): + write(repo, "Pulumi.tést.yaml", "password: hunter2\n") + stage(repo, "Pulumi.tést.yaml") + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert "Pulumi.tést.yaml: plaintext credential at 'password'" in out + + +# --------------------------------------------------------------------------- +# Unmerged index entries: an error, not a pass. +# --------------------------------------------------------------------------- + + +def test_unmerged_index_entry_exits_2_even_with_an_unrelated_clean_file_staged( + repo, monkeypatch, capsys +): + """A real merge conflict on a file unrelated to any Pulumi config + still refuses the commit outright: git itself would never let a + `git commit` reach this hook while any path is unmerged, and this + command's own listing (`--diff-filter=ACMR`) silently excludes + unmerged paths, so without a dedicated check, a clean Pulumi config + staged alongside an unresolved conflict elsewhere would wrongly read + as clean.""" + write(repo, "f.txt", "a\n") + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "f.txt", "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "base") + + _git(repo, "checkout", "-q", "-b", "other") + write(repo, "f.txt", "from-other-branch\n") + _git(repo, "commit", "-q", "-am", "change on other") + + _git(repo, "checkout", "-q", "-") + write(repo, "f.txt", "from-main-branch\n") + _git(repo, "commit", "-q", "-am", "change on main") + + subprocess.run( + ["git", "merge", "other"], cwd=repo, capture_output=True, text=True + ) + status = _git(repo, "status", "--porcelain").stdout + assert "UU f.txt" in status + + assert run_pre_commit(repo, monkeypatch) == 2 + err = capsys.readouterr().err + assert "f.txt" in err + assert "unmerged" in err.lower() or "conflict" in err.lower() + + +def test_diff_filter_acmr_excludes_unmerged_paths(repo, monkeypatch): + """The assumption `cmd_pre_commit`'s unmerged check depends on: a + plain `--diff-filter=ACMR` listing does not surface an unmerged path + at all (git reports it as bare status `U`), so relying on that listing + alone -- without the dedicated `U` check -- would silently pass over + it rather than error.""" + write(repo, "f.txt", "a\n") + stage(repo, "f.txt") + _git(repo, "commit", "-q", "-m", "base") + _git(repo, "checkout", "-q", "-b", "other") + write(repo, "f.txt", "from-other\n") + _git(repo, "commit", "-q", "-am", "c1") + _git(repo, "checkout", "-q", "-") + write(repo, "f.txt", "from-main\n") + _git(repo, "commit", "-q", "-am", "c2") + subprocess.run(["git", "merge", "other"], cwd=repo, capture_output=True, text=True) + + monkeypatch.chdir(repo) + assert pre_commit_module._staged_paths("ACMR") == [] + assert pre_commit_module._staged_paths("U") == ["f.txt"] + + +# --------------------------------------------------------------------------- +# Could-not-run conditions: exit 2, never a silent pass or exit 1. +# --------------------------------------------------------------------------- + + +def test_invalid_yaml_exits_2_and_never_leaks_the_credential(repo, monkeypatch, capsys): + write(repo, "Pulumi.dev.yaml", 'password: "hunter2: not-valid-yaml\n') + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + err = capsys.readouterr().err + assert "hunter2" not in err + + +def test_non_mapping_document_exits_2(repo, monkeypatch, capsys): + write(repo, "Pulumi.dev.yaml", "- a\n- b\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + + +def test_non_utf8_staged_content_exits_2(repo, monkeypatch, capsys): + write_bytes(repo, "Pulumi.dev.yaml", b"password: \xff\xfe not valid utf8\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + + +def test_unanticipated_scan_exception_exits_2_not_1(repo, monkeypatch, capsys): + """Python's default exit code for an uncaught exception is 1 -- the + code reserved exclusively for "a credential was found". A bug + somewhere unrelated to a real finding must still map to 2.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + def boom(_path, _check, _net): + raise RuntimeError("unanticipated failure, not a CheckError") + + monkeypatch.setattr(pre_commit_module, "_scan_staged_config", boom) + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "Pulumi.dev.yaml" in captured.err + assert "RuntimeError" in captured.err + + +def test_policy_load_failure_exits_2_not_1(repo, monkeypatch, capsys): + """A bare exception from policy loading -- neither `ConfigError` nor + `CheckError`, which `cmd_pre_commit`'s own guard already handles -- + must not escape uncaught. Without the `@fail_closed` decorator on + `cmd_pre_commit`, it would propagate out of `main()` entirely and exit + 1 by Python's own default -- misreporting "credential found" for an + invocation that never got as far as loading policy, let alone scanning + a file.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + def boom(): + raise OSError("simulated failure inside policy discovery") + + monkeypatch.setattr(pre_commit_module, "_index_config_path", boom) + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "OSError" in captured.err + + +def test_a_real_finding_still_prints_even_when_another_file_errors( + repo, monkeypatch, capsys +): + write(repo, "Pulumi.dev.yaml", 'password: "hunter2"\n') + write(repo, "Pulumi.broken.yaml", "key: [unclosed\n") + stage(repo, "Pulumi.dev.yaml", "Pulumi.broken.yaml") + assert run_pre_commit(repo, monkeypatch) == 2 + out = capsys.readouterr().out + assert "Pulumi.dev.yaml: plaintext credential at 'password'" in out + + +def test_git_show_failure_exits_2(repo, monkeypatch, capsys): + """A `git show` failure that is not "unmerged" -- e.g. the object + somehow being gone -- must still be a could-not-run error, converted + by `_run_git` itself, not left to propagate as an uncaught + `CalledProcessError`. + + The failure injected here is a genuine non-zero exit from a real + subprocess call (`subprocess.run` itself is replaced, not `_run_git`), + so this exercises `_run_git`'s own conversion of a failing `git show` + into `CheckError` -- unlike asserting on a pre-raised `CheckError` + directly, which would prove nothing about that conversion. + """ + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + real_run = subprocess.run + + def failing_run(args, **kwargs): + if args[:2] == ["git", "show"]: + return subprocess.CompletedProcess( + args, returncode=128, stdout=b"", stderr=b"fatal: simulated failure\n" + ) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", failing_run) + assert run_pre_commit(repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "simulated failure" in captured.err + + +def test_pre_commit_is_registered_and_dispatches(repo, monkeypatch): + """Wiring only -- the rules `pre-commit` enforces are covered above.""" + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + assert run_pre_commit(repo, monkeypatch) == 0 + + +def test_unexpected_argument_is_a_usage_error_exiting_2(): + """`pre-commit` takes no arguments -- it always operates on the current + repository's index. argparse's own usage-error exit code (2) is the + same code reserved for every other could-not-run reason, never 1.""" + with pytest.raises(SystemExit) as exit_info: + main(["pre-commit", "unexpected-argument"]) + assert exit_info.value.code == 2 + + +# --------------------------------------------------------------------------- +# The policy comes from the index too, not the working tree. +# +# The gate read *content* from the index and *rules* from the working tree, +# which is a fail-open with a working exploit: relax the rules without +# staging the relaxation and the credential commits under an excuse that is +# not itself committed. Each test below pins one half of the fix, and each +# has a companion proving the knob it uses is a knob at all -- otherwise +# "the relaxation did nothing" would be indistinguishable from "the gate +# ignores this key entirely". +# --------------------------------------------------------------------------- + +CREDENTIAL_DOCUMENT = "config:\n myproject:dbPassword: hunter2\n" +CREDENTIAL_PATH = "config.myproject:dbPassword" + + +def test_a_worktree_only_relaxation_does_not_let_a_staged_credential_through( + repo, monkeypatch, capsys +): + """The exploit, reproduced. A staged credential, then a working-tree + edit to `.stackward.toml` adding an `allowed_references` entry for it, + staging nothing. The commit being made carries the credential and not + the relaxation, so the relaxation must not apply to it.""" + write(repo, "Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, "Pulumi.dev.yaml") + write( + repo, + ".stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + + # Premise: the relaxation really is only in the working tree. + assert " M .stackward.toml" in _git(repo, "status", "--porcelain").stdout + + assert run_pre_commit(repo, monkeypatch) == 1 + out = capsys.readouterr().out + assert f"plaintext credential at '{CREDENTIAL_PATH}'" in out + assert "hunter2" not in out + + +def test_the_same_relaxation_staged_does_take_effect(repo, monkeypatch, capsys): + """The companion. Without it, the test above would pass just as well if + `allowed_references` were ignored altogether -- and then it would be + proving nothing about *where* the policy was read from.""" + write(repo, "Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + write( + repo, + ".stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + stage(repo, "Pulumi.dev.yaml", ".stackward.toml") + + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_a_worktree_only_sensitive_parents_shrink_does_not_weaken_the_gate( + repo, monkeypatch, capsys +): + """The second of the three knobs that weaken from the working tree. + `sensitive_parents` REPLACES rather than extends (`config._replace`), so + a worktree edit can shrink what the index declared -- here from a + category that flags the leaf to one that does not.""" + write(repo, ".stackward.toml", MINIMAL_POLICY + 'sensitive_parents = ["vars"]\n') + write(repo, "Pulumi.dev.yaml", "vars:\n DB_USER: admin\n") + stage(repo, ".stackward.toml", "Pulumi.dev.yaml") + _git(repo, "commit", "-q", "-m", "declare a sensitive parent") + + write(repo, "Pulumi.dev.yaml", "vars:\n DB_USER: admin\n# changed\n") + stage(repo, "Pulumi.dev.yaml") + # Shrink the declared category in the working tree only. + write(repo, ".stackward.toml", MINIMAL_POLICY + 'sensitive_parents = ["other"]\n') + + assert run_pre_commit(repo, monkeypatch) == 1 + assert "plaintext credential at 'vars.DB_USER'" in capsys.readouterr().out + + +def test_the_same_shrink_staged_does_take_effect(repo, monkeypatch, capsys): + """Companion to the test above, for the same reason as the pair before + it: proves `sensitive_parents` is a knob, so that "still flagged" above + can only mean "read from the index".""" + write(repo, ".stackward.toml", MINIMAL_POLICY + 'sensitive_parents = ["other"]\n') + write(repo, "Pulumi.dev.yaml", "vars:\n DB_USER: admin\n") + stage(repo, ".stackward.toml", "Pulumi.dev.yaml") + + assert run_pre_commit(repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_a_policy_deleted_from_the_working_tree_still_applies(repo, monkeypatch, capsys): + """The same rule from the other direction: deleting `.stackward.toml` + from the working tree without staging the deletion cannot switch the + gate off. A worktree-reading implementation would find no config at all + here and (before this change) scan under defaults, or (after it) refuse + -- either way, the wrong answer for a commit whose index still declares + a policy.""" + write(repo, ".stackward.toml", MINIMAL_POLICY + 'allowed_references = ["a.b"]\n') + stage(repo, ".stackward.toml") + _git(repo, "commit", "-q", "-m", "declare policy with a reference") + + write(repo, "Pulumi.dev.yaml", "config:\n a:\n b: not-a-credential\n") + stage(repo, "Pulumi.dev.yaml") + (repo / ".stackward.toml").unlink() + + assert run_pre_commit(repo, monkeypatch) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +# --------------------------------------------------------------------------- +# A missing policy file is a refusal, not a skip (Global Constraint 3). +# --------------------------------------------------------------------------- + + +def test_no_policy_in_the_index_is_a_refusal_naming_the_file( + bare_repo, monkeypatch, capsys +): + """Defaulting to `CheckConfig()` would make `model_net = "none"` -- a + mode the plan requires to be *declared* -- a silent fallback, and would + scan a repository under rules nobody chose. Exit 2, and the message has + to name the minimal file, or the refusal is not actionable.""" + write(bare_repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(bare_repo, "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert ".stackward.toml" in captured.err + assert "model_net" in captured.err + + +def test_a_staged_credential_is_refused_not_reported_when_there_is_no_policy( + bare_repo, monkeypatch, capsys +): + """Exit 2, not 1, even though the heuristic net alone would have found + this leaf. The command does not know what the repository considers + sensitive, so it cannot claim a complete answer -- reporting one net's + findings and exiting 1 would announce exactly that.""" + write(bare_repo, "Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(bare_repo, "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "hunter2" not in captured.err + + +def test_a_policy_only_in_the_working_tree_is_not_a_policy( + bare_repo, monkeypatch, capsys +): + """Written but never staged. The same rule this command applies to + every stack config it scans, applied to the file that says how to scan + them: if it is not in the index, it is not part of this commit.""" + write(bare_repo, ".stackward.toml", MINIMAL_POLICY) + write(bare_repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(bare_repo, "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 2 + err = capsys.readouterr().err + assert ".stackward.toml" in err + # The fix is nameable: stage it. + assert "git add" in err + + +def test_staging_the_policy_is_enough_it_need_not_be_committed( + bare_repo, monkeypatch, capsys +): + """The index, not HEAD. A repository declaring its policy in the very + commit it is making must pass -- otherwise the first commit that adds a + policy could never be made.""" + write(bare_repo, ".stackward.toml", MINIMAL_POLICY) + write(bare_repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(bare_repo, ".stackward.toml", "Pulumi.dev.yaml") + + assert run_pre_commit(bare_repo, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +# --------------------------------------------------------------------------- +# Policy discovery walks upward, in the index, the way `find_repo_config` +# walks upward in the working tree. +# --------------------------------------------------------------------------- + + +def test_the_root_policy_applies_when_invoked_from_a_subdirectory( + repo, monkeypatch, capsys +): + """`git ls-files`' pathspecs resolve against the caller's directory, not + the repository root, so a discovery that forgot `:(top)` would look for + `deploy/.stackward.toml` from inside `deploy/` and find nothing.""" + write(repo, "deploy/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, "deploy/Pulumi.dev.yaml") + + assert run_pre_commit(repo / "deploy", monkeypatch) == 1 + assert f"plaintext credential at '{CREDENTIAL_PATH}'" in capsys.readouterr().out + + +def test_the_nearest_policy_wins_over_the_repository_root_one( + repo, monkeypatch, capsys +): + """Same precedence as `config.find_repo_config`'s working-tree walk: the + file beside the caller beats the one at the root. Here the nested policy + allows the reference the root policy does not.""" + write( + repo, + "deploy/.stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + write(repo, "deploy/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, "deploy/.stackward.toml", "deploy/Pulumi.dev.yaml") + + # From the root, the root policy applies and the leaf is a finding. + assert run_pre_commit(repo, monkeypatch) == 1 + capsys.readouterr() + # From the subdirectory, the nearer policy applies. + assert run_pre_commit(repo / "deploy", monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_discovery_survives_a_directory_named_with_glob_characters( + repo, monkeypatch, capsys +): + """A directory name is not something this tool gets to constrain, and + `[`, `*` and `?` are all pathspec metacharacters. + + Note what this does *not* prove: `:(literal)` is not what saves it. + Verified directly against git -- a pathspec is compared literally before + wildmatch is tried, so `cache[1]*/.stackward.toml` matches its own + directory under `:(literal)`, `:(glob)` and no magic alike. What makes + the metacharacters harmless is that `_index_config_path` accepts a + candidate only by exact string equality, never by taking whatever git + printed -- and that is what this goes red for (proved by replacing the + match loop with `sorted(present)[0]`).""" + subdir = "cache[1]*" + write( + repo, + f"{subdir}/.stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + write(repo, f"{subdir}/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, f"{subdir}/.stackward.toml", f"{subdir}/Pulumi.dev.yaml") + + assert run_pre_commit(repo / subdir, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_discovery_survives_a_non_ascii_directory_name(repo, monkeypatch, capsys): + """`git rev-parse --show-prefix` is what says where the caller is + standing, and its bytes are the path's own -- not `core.quotePath` + escapes. A discovery that mis-decoded it would silently fall back to the + repository root's policy, which here would report a finding.""" + subdir = "déploy" + write( + repo, + f"{subdir}/.stackward.toml", + MINIMAL_POLICY + f'allowed_references = ["{CREDENTIAL_PATH}"]\n', + ) + write(repo, f"{subdir}/Pulumi.dev.yaml", CREDENTIAL_DOCUMENT) + stage(repo, f"{subdir}/.stackward.toml", f"{subdir}/Pulumi.dev.yaml") + + assert run_pre_commit(repo / subdir, monkeypatch) == 0 + assert capsys.readouterr().out == "" + + +def test_git_runs_under_a_fixed_locale(repo, monkeypatch): + """Every git invocation gets `LC_ALL=C`, with the environment merged + rather than replaced. + + Nothing in this module branches on git's message text, so this is + hardening rather than a live fix here -- but git's diagnostics are + gettext-marked, and a message quoted verbatim into a `CheckError` should + read the same in a bug report as it did on the machine that hit it. + + Scope, so this does not become a trap for whoever changes the fixture + next: the loop covers every git invocation *this command* makes, and + that holds only because `repo` declares `model_net = "none"`. Under + `model_net = "artifact"` a second call site runs as well + (`nets.model.run_git`), and `commands.install_hooks._git` is a third. + This docstring used to record both as gaps -- and `install_hooks`, which + matches git's *translated* strings, as a live bug rather than + hardening. Both were closed afterwards: each sets `LC_ALL=C` with the + environment merged, and each is pinned by a test in its own file that + goes red if the override is dropped: `test_model_net.py` and + `test_install_hooks.py` each carry their own + `test_every_git_invocation_runs_under_a_fixed_locale`, and the live half + is pinned at the level where it bit by + `test_hooks_install_survives_a_git_that_translates_its_diagnostics`. + What must stay true is that a *new* call site brings its own file's + test, not that this loop grows to reach across modules. + + The merge half matters more than the override: git sets `GIT_INDEX_FILE` + and `GIT_DIR` for a hook process, and passing a bare `env={"LC_ALL": + "C"}` would make this command read a different index than the commit it + is gating -- a fail-open that no output assertion elsewhere would catch. + """ + write(repo, "Pulumi.dev.yaml", "name: myproject\n") + stage(repo, "Pulumi.dev.yaml") + + # Recording starts only now: this file's own `_git` helper shells out to + # git too, and it is not what is under test. + monkeypatch.setenv("STACKWARD_TEST_MARKER", "inherited") + seen: list[dict[str, str] | None] = [] + real_run = subprocess.run + + def recording_run(args, **kwargs): + if args and args[0] == "git": + seen.append(kwargs.get("env")) + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", recording_run) + assert run_pre_commit(repo, monkeypatch) == 0 + + assert seen, "no git invocation was recorded" + for env in seen: + assert env is not None, "a git invocation inherited the ambient locale" + assert env.get("LC_ALL") == "C" + assert env.get("STACKWARD_TEST_MARKER") == "inherited" diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..1273064 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,890 @@ +"""Tests for the provider interface (Task 11). + +**The centerpiece is the contract test.** The whole point of this task is +that swapping `provider = "..."` changes nothing a caller can observe — +`test_dotenv_and_a_fake_remote_produce_identical_dry_run_outcomes`, +`test_dotenv_and_a_fake_remote_produce_identical_pulumi_invocations` and +`test_file_and_a_fake_remote_produce_identical_child_environments` seed a +`dotenv`/`file` provider and a fake **remote** provider (`providers.command`, +driven by a real subprocess script — never a Python object standing in for +"remote", which would prove nothing about the shell-out requirement) with +the same values and assert the real `set-secrets`/`exec` entry points behave +identically: same printed outcomes, the same `pulumi` argv *and* stdin +byte-for-byte, the same injected environment. + +**The gate-path invariant is enforced, not merely stated.** +`test_the_check_config_entry_point_never_loads_a_provider_module` (and its +stronger sibling that runs the command through `cli.main`) checks +`sys.modules` in a subprocess — this test process has already imported half +the tool by the time either test runs, so asserting against its own +`sys.modules` would prove nothing. + +**No test ever prints a credential value.** Every marker below is a +placeholder distinctive enough that an accidental substring match in +captured output would be implausible (GC4); assertions are on absence, on +exception types, or on values compared to a known constant, never on a +value's appearance in output being treated as proof of anything. +""" + +from __future__ import annotations + +import json +import sys +import textwrap +from pathlib import Path + +import pytest + +from stackward import store +from stackward.cli import main +from stackward.commands import session, set_secrets +from stackward.commands.session import ( + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + CREDENTIAL_NAMES, + ENV_STORE_PASSWORD, + PULUMI_BACKEND_URL, + PULUMI_CONFIG_PASSPHRASE, + SessionError, +) +from stackward.commands.set_secrets import SetSecretsError +from stackward.config import find_repo_config, load_config +from stackward.providers import CredentialStore, ProviderError, SecretSource +from stackward.providers.command import CommandCredentialStore, CommandSecretSource +from stackward.providers.dotenv import DotenvSecretSource +from stackward.providers.env import EnvCredentialStore, EnvSecretSource +from stackward.providers.file import FileCredentialStore +from stackward.store import Profile, StoreError + +from leakcheck import assert_no_leak + +PASSWORD = "the store password" +WRONG_PASSWORD = "not the store password" + +# Placeholder values only -- see GC4. +# +# Opaque rather than descriptive, and deliberately so: the absence assertions +# below go through `leakcheck.assert_no_leak`, which fails on any +# eight-character run of the value as well as on the whole string. A marker +# spelled `marker-db-password-...` shares the run `password` with the config +# path `db.password` that the same output legitimately prints, so the check +# would fire on output disclosing nothing -- and a leak check that cries wolf +# is a leak check that gets deleted. No two of these share an eight-character +# run with each other either. `tests/test_set_secrets.py` carries the same +# constraint for the same reason. +MARKER_KEY = "kp2v9s-4x7b1m6e-8t3j5c0z" +MARKER_SECRET = "vn6h3q-9d1f8k4r-2y7l5w0a" +MARKER_PASSPHRASE = "bz4j8t-6m2c9p1s-5g7v3n0k" +MARKER_DB_PASSWORD = "rf6t2y-8w4q1n7v-3m5k9b0x" +MARKER_API_TOKEN = "sl8g4d-1c7z5j2p-6h3n0r9e" + +# The one backend URL every profile in the three-provider contract test +# carries. Named so the contract assertion can be anchored to it rather than +# to whichever environment happened to be captured first. +STAGING_BACKEND_URL = "file:///staging-backend" + +FULL_CREDENTIALS = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + PULUMI_CONFIG_PASSPHRASE: MARKER_PASSPHRASE, +} + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only -- see `test_store.py`'s + identical fixture. `FileCredentialStore` performs real seal/open round + trips.""" + from stackward import crypto + + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture(autouse=True) +def no_real_pulumi_state(monkeypatch, tmp_path): + """Point `PULUMI_HOME` at an empty, per-test directory, and clear + `PULUMI_BACKEND_URL` -- see `test_session.py`'s identical fixture. No + test in this file may read or depend on this machine's real Pulumi + state.""" + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) + monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) + + +# --------------------------------------------------------------------------- +# A real "remote" CLI, driven entirely by environment variables -- never a +# Python object standing in for "remote". `STUB_MODE` picks the wire shape: +# "secret" (one name in, one value or nothing on stdout -- `CommandSecretSource`) +# or "credentials" (one profile in, a JSON object out -- `CommandCredentialStore`). +# --------------------------------------------------------------------------- + +_REMOTE_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys, time + + sleep_seconds = os.environ.get("STUB_SLEEP_SECONDS") + if sleep_seconds: + time.sleep(float(sleep_seconds)) + + exit_code = int(os.environ.get("STUB_EXIT_CODE", "0")) + if exit_code != 0: + sys.exit(exit_code) + + mode = os.environ.get("STUB_MODE", "secret") + arg = sys.argv[1] if len(sys.argv) > 1 else "" + + if mode == "credentials": + values = json.loads(os.environ.get("STUB_CREDENTIALS", "{}")) + sys.stdout.write(json.dumps(values)) + elif mode == "raw": + sys.stdout.write(os.environ.get("STUB_RAW_OUTPUT", "")) + else: + values = json.loads(os.environ.get("STUB_VALUES", "{}")) + sys.stdout.write(values.get(arg, "")) + """ +) + + +@pytest.fixture +def remote_stub(tmp_path) -> Path: + path = tmp_path / "remote-stub.py" + path.write_text(_REMOTE_STUB) + path.chmod(0o755) + return path + + +def remote_command(stub: Path) -> list[str]: + """The `command` list a `[secrets.source]`/`[profile..credentials]` + table would declare -- `sys.executable` first, since the stub is a + Python script and not directly executable on every platform this suite + might run on.""" + return [sys.executable, str(stub)] + + +# A minimal `pulumi` stand-in: records every invocation's argv and stdin, +# then reports success for a `set`. No `get` support -- no test in this file +# declares a `drift_pairs` entry, so `_check_drift` never calls one. +_PULUMI_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys + + argv = sys.argv[1:] + dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") + if dump_argv_to: + with open(dump_argv_to, "a") as f: + f.write(json.dumps(argv) + "\\n") + + if "set" in argv: + data = sys.stdin.buffer.read() + dump_stdin_to = os.environ.get("STUB_DUMP_STDIN_TO") + if dump_stdin_to: + with open(dump_stdin_to, "ab") as f: + f.write(data + b"\\n") + sys.exit(0) + + sys.exit(1) + """ +) + + +@pytest.fixture +def pulumi_stub(tmp_path) -> Path: + path = tmp_path / "pulumi-stub.py" + path.write_text(_PULUMI_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path) -> None: + monkeypatch.setattr( + set_secrets.shutil, "which", lambda name: str(executable) if name == "pulumi" else None + ) + + +# A real child, dumping its own environment -- see `test_session.py`'s `_STUB`. +_ENV_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys + + dump_env_to = os.environ.get("STUB_DUMP_ENV_TO") + if dump_env_to: + with open(dump_env_to, "w") as f: + json.dump(dict(os.environ), f) + sys.exit(0) + """ +) + + +@pytest.fixture +def env_stub(tmp_path) -> Path: + path = tmp_path / "env-stub.py" + path.write_text(_ENV_STUB) + path.chmod(0o755) + return path + + +# --------------------------------------------------------------------------- +# Repos and stores -- mirrors `test_set_secrets.py`/`test_session.py`. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(tmp_path) -> Path: + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + return root + + +def write_repo_config(repo: Path, text: str) -> Path: + path = repo / ".stackward.toml" + path.write_text(text) + return path + + +@pytest.fixture +def store_directory(tmp_path) -> Path: + return tmp_path / "stackward" + + +def write_store_config(directory: Path, text: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / store.CONFIG_FILENAME + path.write_text(text) + path.chmod(store.CONFIG_MODE) + return path + + +# --------------------------------------------------------------------------- +# `FileCredentialStore` -- the thin adapter over `store.resolve_credentials` +# --------------------------------------------------------------------------- + + +def test_file_credential_store_resolves_exactly_what_the_store_holds(store_directory): + store.init_store(PASSWORD, directory=store_directory) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=store_directory) + + provider = FileCredentialStore(PASSWORD, directory=store_directory) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_file_credential_store_propagates_store_error_for_a_wrong_password(store_directory): + """Deliberately *not* wrapped in `ProviderError` -- see `providers.file`'s + own docstring on why `StoreError` propagates unchanged.""" + store.init_store(PASSWORD, directory=store_directory) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=store_directory) + + provider = FileCredentialStore(WRONG_PASSWORD, directory=store_directory) + with pytest.raises(StoreError): + provider.resolve("staging") + + +def test_file_credential_store_satisfies_the_credential_store_protocol(store_directory): + store.init_store(PASSWORD, directory=store_directory) + provider = FileCredentialStore(PASSWORD, directory=store_directory) + assert isinstance(provider, CredentialStore) + + +# --------------------------------------------------------------------------- +# `EnvCredentialStore` +# --------------------------------------------------------------------------- + + +def test_env_credential_store_reads_only_the_requested_names(): + environ = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + "UNRELATED_VARIABLE": "should never appear", + } + provider = EnvCredentialStore(CREDENTIAL_NAMES, environ) + resolved = provider.resolve("any-profile-name") + assert resolved == {AWS_ACCESS_KEY_ID: MARKER_KEY, AWS_SECRET_ACCESS_KEY: MARKER_SECRET} + assert "UNRELATED_VARIABLE" not in resolved + + +def test_env_credential_store_ignores_the_profile_argument(): + """A CI runner's environment is not partitioned by profile -- whichever + profile name is asked for gets the same answer.""" + environ = {AWS_ACCESS_KEY_ID: MARKER_KEY} + provider = EnvCredentialStore((AWS_ACCESS_KEY_ID,), environ) + assert provider.resolve("staging") == provider.resolve("anything-else") + + +def test_env_credential_store_omits_a_name_it_does_not_have_rather_than_erroring(): + provider = EnvCredentialStore((AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), {}) + assert provider.resolve("staging") == {} + + +def test_env_credential_store_satisfies_the_credential_store_protocol(): + assert isinstance(EnvCredentialStore((), {}), CredentialStore) + + +# --------------------------------------------------------------------------- +# `EnvSecretSource` +# --------------------------------------------------------------------------- + + +def test_env_secret_source_distinguishes_unset_from_empty(): + """Presence, not truthiness -- see `SecretSource.resolve`'s own + contract. `LocalSecretSource`'s environment tier makes the identical + distinction; this provider must not weaken it.""" + provider = EnvSecretSource({"DB_PASSWORD": ""}) + assert provider.resolve("DB_PASSWORD") == "" + assert provider.resolve("DB_PASSWORD") is not None + assert provider.resolve("NEVER_SET") is None + + +def test_env_secret_source_never_consults_a_file(tmp_path): + """`env` and `dotenv` must not be indistinguishable (the exact trap Task + 2's review caught for `sensitive_parents`): given the same environment + and a file that *would* answer the name, `EnvSecretSource` still says + "not set" while `DotenvSecretSource` (below) resolves it from the file. + `EnvSecretSource` has no `files` constructor parameter at all -- there + is no way to hand it the file even by mistake.""" + env_file = tmp_path / "secrets.env" + env_file.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + environ: dict[str, str] = {} + assert EnvSecretSource(environ).resolve("DB_PASSWORD") is None + assert DotenvSecretSource(environ, [env_file]).resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_env_secret_source_satisfies_the_secret_source_protocol(): + assert isinstance(EnvSecretSource({}), SecretSource) + + +# --------------------------------------------------------------------------- +# `DotenvSecretSource` -- the adapter over `LocalSecretSource` +# --------------------------------------------------------------------------- + + +def test_dotenv_secret_source_prefers_the_environment_over_a_file(tmp_path): + env_file = tmp_path / "secrets.env" + env_file.write_text("DB_PASSWORD=from-the-file\n") + env_file.chmod(0o600) + provider = DotenvSecretSource({"DB_PASSWORD": MARKER_DB_PASSWORD}, [env_file]) + assert provider.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_dotenv_secret_source_falls_back_to_a_file_when_unset_in_the_environment(tmp_path): + env_file = tmp_path / "secrets.env" + env_file.write_text(f"API_TOKEN={MARKER_API_TOKEN}\n") + env_file.chmod(0o600) + provider = DotenvSecretSource({}, [env_file]) + assert provider.resolve("API_TOKEN") == MARKER_API_TOKEN + + +def test_dotenv_secret_source_ignores_a_file_that_does_not_exist(tmp_path): + missing = tmp_path / "does-not-exist.env" + provider = DotenvSecretSource({}, [missing]) + assert provider.resolve("ANYTHING") is None + + +def test_dotenv_secret_source_returns_none_for_an_unresolved_name(): + provider = DotenvSecretSource({}, []) + assert provider.resolve("NEVER_SET") is None + + +def test_dotenv_secret_source_satisfies_the_secret_source_protocol(): + assert isinstance(DotenvSecretSource({}, []), SecretSource) + + +# --------------------------------------------------------------------------- +# `CommandSecretSource` / `CommandCredentialStore` -- the generic remote shape +# --------------------------------------------------------------------------- + + +def test_command_secret_source_reads_the_value_from_a_real_subprocess(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_VALUES", json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD})) + provider = CommandSecretSource(remote_command(remote_stub)) + assert provider.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_command_secret_source_empty_output_means_not_set(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_VALUES", json.dumps({})) + provider = CommandSecretSource(remote_command(remote_stub)) + assert provider.resolve("NEVER_PUBLISHED") is None + + +def test_command_secret_source_a_nonzero_exit_is_a_provider_error_not_none( + monkeypatch, remote_stub +): + """The failure this whole task's brief calls out: a broken remote must + never be indistinguishable from "not set".""" + monkeypatch.setenv("STUB_EXIT_CODE", "1") + provider = CommandSecretSource(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("DB_PASSWORD") + + +def test_command_secret_source_a_timeout_is_a_provider_error(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_SLEEP_SECONDS", "5") + provider = CommandSecretSource(remote_command(remote_stub), timeout=0.2) + with pytest.raises(ProviderError): + provider.resolve("DB_PASSWORD") + + +def test_command_secret_source_a_missing_executable_is_a_provider_error(tmp_path): + provider = CommandSecretSource([str(tmp_path / "does-not-exist-anywhere")]) + with pytest.raises(ProviderError): + provider.resolve("DB_PASSWORD") + + +def test_command_secret_source_refuses_an_empty_command(): + with pytest.raises(ProviderError): + CommandSecretSource([]) + + +def test_command_secret_source_never_prints_the_resolved_value(monkeypatch, remote_stub, capfd): + """A fetched value is held in memory for the call that resolved it and + handed back to the caller -- never printed by this provider itself.""" + monkeypatch.setenv("STUB_VALUES", json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD})) + provider = CommandSecretSource(remote_command(remote_stub)) + provider.resolve("DB_PASSWORD") + assert_no_leak(capfd.readouterr().out, MARKER_DB_PASSWORD, what="the fetched value") + + +def test_command_credential_store_parses_a_json_object_from_stdout(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_CREDENTIALS", json.dumps(FULL_CREDENTIALS)) + provider = CommandCredentialStore(remote_command(remote_stub)) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_command_credential_store_a_nonzero_exit_is_a_provider_error(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_EXIT_CODE", "1") + provider = CommandCredentialStore(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("staging") + + +def test_command_credential_store_rejects_output_that_is_not_a_json_object( + monkeypatch, remote_stub +): + monkeypatch.setenv("STUB_MODE", "raw") + monkeypatch.setenv("STUB_RAW_OUTPUT", "not json at all") + provider = CommandCredentialStore(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("staging") + + +def test_command_credential_store_rejects_a_json_array(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "raw") + monkeypatch.setenv("STUB_RAW_OUTPUT", json.dumps(["not", "an", "object"])) + provider = CommandCredentialStore(remote_command(remote_stub)) + with pytest.raises(ProviderError): + provider.resolve("staging") + + +def test_command_providers_satisfy_their_protocols(remote_stub): + assert isinstance(CommandSecretSource(remote_command(remote_stub)), SecretSource) + assert isinstance(CommandCredentialStore(remote_command(remote_stub)), CredentialStore) + + +# --------------------------------------------------------------------------- +# Config wiring: `[profile..credentials]` +# --------------------------------------------------------------------------- + + +def test_a_profile_with_no_credentials_table_defaults_to_the_file_provider(store_directory): + write_store_config(store_directory, '[profile.staging]\nbackend_url = "file:///x"\n') + profile = store.load_store_config(store_directory).profiles["staging"] + assert profile.credentials.get("provider", "file") == "file" + + +def test_a_profile_can_declare_a_non_default_credentials_provider(store_directory): + write_store_config( + store_directory, + '[profile.staging]\nbackend_url = "file:///x"\n\n' + '[profile.staging.credentials]\nprovider = "env"\n', + ) + profile = store.load_store_config(store_directory).profiles["staging"] + assert profile.credentials["provider"] == "env" + + +def test_a_non_table_credentials_value_is_refused(store_directory): + write_store_config( + store_directory, + '[profile.staging]\nbackend_url = "file:///x"\ncredentials = "not a table"\n', + ) + with pytest.raises(StoreError): + store.load_store_config(store_directory) + + +def test_a_non_string_credentials_provider_is_refused(store_directory): + write_store_config( + store_directory, + '[profile.staging]\nbackend_url = "file:///x"\n\n' + "[profile.staging.credentials]\nprovider = 1\n", + ) + with pytest.raises(StoreError): + store.load_store_config(store_directory) + + +# --------------------------------------------------------------------------- +# `session._build_credential_store` -- provider dispatch +# --------------------------------------------------------------------------- + + +def test_build_credential_store_defaults_to_file(store_directory, monkeypatch): + store.init_store(PASSWORD, directory=store_directory) + store.set_credentials("staging", FULL_CREDENTIALS, PASSWORD, directory=store_directory) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + profile = Profile(name="staging") + provider = session._build_credential_store(profile, directory=store_directory) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_build_credential_store_selects_env(monkeypatch): + monkeypatch.setenv(AWS_ACCESS_KEY_ID, MARKER_KEY) + profile = Profile(name="staging", credentials={"provider": "env"}) + provider = session._build_credential_store(profile, directory=None) + assert provider.resolve("staging")[AWS_ACCESS_KEY_ID] == MARKER_KEY + + +def test_build_credential_store_selects_command(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_CREDENTIALS", json.dumps(FULL_CREDENTIALS)) + profile = Profile( + name="staging", credentials={"provider": "command", "command": remote_command(remote_stub)} + ) + provider = session._build_credential_store(profile, directory=None) + assert provider.resolve("staging") == FULL_CREDENTIALS + + +def test_build_credential_store_command_without_a_command_list_is_refused(): + profile = Profile(name="staging", credentials={"provider": "command"}) + with pytest.raises(SessionError, match="command"): + session._build_credential_store(profile, directory=None) + + +def test_build_credential_store_unknown_provider_is_refused(): + profile = Profile(name="staging", credentials={"provider": "not-a-real-provider"}) + with pytest.raises(SessionError, match="not-a-real-provider"): + session._build_credential_store(profile, directory=None) + + +# --------------------------------------------------------------------------- +# `set_secrets._build_secret_source` -- provider dispatch +# --------------------------------------------------------------------------- + + +def test_build_secret_source_defaults_to_dotenv(repo, monkeypatch): + write_repo_config(repo, '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n') + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + config = load_config(find_repo_config(repo)) + source = set_secrets._build_secret_source(config, repo, None) + assert source.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_build_secret_source_env_ignores_a_configured_files_list(repo, monkeypatch): + """The Task 2 trap this codebase already learned from: an `env` + provider that happened to also honour `files` would be indistinguishable + from `dotenv` in a test that never populates a file. This one does.""" + env_file = repo / "secrets.env" + env_file.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.source]\nprovider = "env"\nfiles = ["secrets.env"]\n', + ) + monkeypatch.delenv("DB_PASSWORD", raising=False) + config = load_config(find_repo_config(repo)) + source = set_secrets._build_secret_source(config, repo, None) + assert source.resolve("DB_PASSWORD") is None + + +def test_build_secret_source_selects_command(repo, monkeypatch, remote_stub): + monkeypatch.setenv("STUB_VALUES", json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD})) + command_toml = json.dumps(remote_command(remote_stub)) + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + f'[secrets.source]\nprovider = "command"\ncommand = {command_toml}\n', + ) + config = load_config(find_repo_config(repo)) + source = set_secrets._build_secret_source(config, repo, None) + assert source.resolve("DB_PASSWORD") == MARKER_DB_PASSWORD + + +def test_build_secret_source_command_without_a_command_list_is_refused(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.source]\nprovider = "command"\n', + ) + config = load_config(find_repo_config(repo)) + with pytest.raises(SetSecretsError, match="command"): + set_secrets._build_secret_source(config, repo, None) + + +def test_build_secret_source_unknown_provider_is_refused(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.source]\nprovider = "not-a-real-provider"\n', + ) + config = load_config(find_repo_config(repo)) + with pytest.raises(SetSecretsError, match="not-a-real-provider"): + set_secrets._build_secret_source(config, repo, None) + + +# --------------------------------------------------------------------------- +# A provider failure is reported, never a silent empty value +# --------------------------------------------------------------------------- + + +def test_publish_entries_reports_a_failing_provider_as_failed_not_skipped(monkeypatch, remote_stub): + monkeypatch.setenv("STUB_EXIT_CODE", "1") + manifest = set_secrets.ProjectManifest(secret={"db.password": "DB_PASSWORD"}) + source = CommandSecretSource(remote_command(remote_stub)) + outcomes = set_secrets._publish_entries( + manifest, source, pulumi=None, stack=None, dry_run=True, timeout=5.0 + ) + assert len(outcomes) == 1 + assert outcomes[0].status == "failed" + assert outcomes[0].status != "skipped" + + +def test_check_drift_reports_a_failing_provider_as_a_warning_not_silence( + monkeypatch, remote_stub, capsys +): + """A provider failure resolving `bootstrap` must not fall into the same + silent `continue` a legitimately-unresolved bootstrap value gets -- the + three silent states `_check_drift`'s own docstring names are all + absences, and this is not one. `pulumi` is a non-`None` placeholder: the + provider raises before `_pulumi_config_get` would ever be reached, so no + real `pulumi` binary is needed for this path to be exercised.""" + monkeypatch.setenv("STUB_EXIT_CODE", "1") + manifest = set_secrets.ProjectManifest( + secret={"db.password": "DB_PASSWORD"}, + drift_pairs=( + set_secrets.DriftPair(bootstrap="DB_PASSWORD", managed="DB_PASSWORD_MANAGED"), + ), + ) + source = CommandSecretSource(remote_command(remote_stub)) + + set_secrets._check_drift(manifest, source, pulumi="placeholder-pulumi", stack=None, timeout=5.0) + + err = capsys.readouterr().err + assert "warning" in err + assert "DB_PASSWORD" in err + + +def test_exec_reports_a_failing_command_credential_store_rather_than_running_with_nothing( + tmp_path, monkeypatch, remote_stub, env_stub, capsys +): + """A provider failure during credential resolution must stop `exec` + outright -- never run the child with an empty or partial environment + that a caller could mistake for "this profile simply has no + credentials".""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + write_store_config( + home / "stackward", + '[profile.staging]\nbackend_url = "file:///staging-backend"\n\n' + '[profile.staging.credentials]\nprovider = "command"\n' + f"command = {json.dumps(remote_command(remote_stub))}\n", + ) + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_EXIT_CODE", "1") + + code = main(["exec", "--profile", "staging", "--", str(env_stub)]) + + assert code == 2 + assert "error" in capsys.readouterr().err.lower() + + +# --------------------------------------------------------------------------- +# The gate-path invariant +# --------------------------------------------------------------------------- + + +# The two tests that used to live here -- "importing `check-config`'s module +# loads no `stackward.providers*`" and its stronger form through `cli.main` +# -- are now one test in `tests/test_gate_isolation.py`. Splitting the +# property in two is what hid the real defect: this file went through +# `cli.main` but grepped only for `stackward.providers*`, while +# `tests/test_model_net.py` looked for `cryptography` and `stackward.store` +# but imported the command modules directly, bypassing `cli.py` -- which was +# where `cryptography` was reaching the gate path all along. One assertion +# over the union of every forbidden module, taken through the real dispatch +# path, is the only shape that could have caught it. + + +# --------------------------------------------------------------------------- +# The contract test: swapping the provider changes nothing for the caller +# --------------------------------------------------------------------------- + +# The manifest body is identical across both providers under test -- only +# `[secrets.source]` differs -- so any difference in outcome or invocation +# can only come from the provider swap, never from a manifest difference. +_MANIFEST_BODY = ( + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD", "api.token" = "API_TOKEN" }\n' + 'plaintext = { "registry.user" = "REGISTRY_USER" }\n\n' + '[secrets.".".required]\n' + 'paths = ["db.password"]\n\n' +) + + +@pytest.fixture +def dotenv_repo(tmp_path, monkeypatch) -> Path: + """`db.password` resolved via the environment tier, `api.token` via the + file tier, `registry.user` left unresolved -- exercising all three + outcome states through `dotenv`'s layered resolution, which the `command` + run below must match without any layering of its own.""" + root = tmp_path / "dotenv-repo" + (root / ".git").mkdir(parents=True) + env_file = root / "secrets.env" + env_file.write_text(f"API_TOKEN={MARKER_API_TOKEN}\n") + env_file.chmod(0o600) + write_repo_config(root, _MANIFEST_BODY + '[secrets.source]\nfiles = ["secrets.env"]\n') + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + monkeypatch.delenv("REGISTRY_USER", raising=False) + monkeypatch.delenv("API_TOKEN", raising=False) + return root + + +@pytest.fixture +def command_repo(tmp_path, monkeypatch, remote_stub) -> Path: + """The same three logical names, seeded with the same values, answered + entirely by the fake remote CLI -- no file, no environment variable of + the manifest's own names involved at all.""" + root = tmp_path / "command-repo" + (root / ".git").mkdir(parents=True) + command_toml = json.dumps(remote_command(remote_stub)) + write_repo_config( + root, + _MANIFEST_BODY + + f'[secrets.source]\nprovider = "command"\ncommand = {command_toml}\n', + ) + monkeypatch.setenv( + "STUB_VALUES", + json.dumps({"DB_PASSWORD": MARKER_DB_PASSWORD, "API_TOKEN": MARKER_API_TOKEN}), + ) + return root + + +def test_dotenv_and_a_fake_remote_produce_identical_dry_run_outcomes( + dotenv_repo, command_repo, monkeypatch, capfd +): + """The first half of the contract: `set-secrets --dry-run`'s printed + outcomes -- would-set, skip, and (via `required`) failed -- are + byte-identical whether the values came from `dotenv`'s environment+file + layering or from the fake remote, seeded with the same values.""" + monkeypatch.chdir(dotenv_repo) + assert main(["set-secrets", "--dry-run"]) == 0 + dotenv_output = capfd.readouterr().out + + monkeypatch.chdir(command_repo) + assert main(["set-secrets", "--dry-run"]) == 0 + command_output = capfd.readouterr().out + + assert dotenv_output == command_output + assert "would set" in dotenv_output + assert "skip" in dotenv_output + # `assert_no_leak`, not `not in`: a dry run that helpfully printed the + # first few characters of what it would set -- so an operator could + # confirm it was the right value -- passes a whole-string check while + # disclosing the credential. + for marker in (MARKER_DB_PASSWORD, MARKER_API_TOKEN): + assert_no_leak(dotenv_output, marker, what="the resolved value") + assert_no_leak(command_output, marker, what="the resolved value") + + +def test_dotenv_and_a_fake_remote_produce_identical_pulumi_invocations( + dotenv_repo, command_repo, monkeypatch, tmp_path, pulumi_stub +): + """The second half: without `--dry-run`, the real `pulumi config set` + argv *and* stdin this module sends are byte-identical across both + providers -- proving the same value, not merely the same flags, reached + the child either way. `--dry-run` alone cannot prove this: it invokes no + subprocess at all (`_publish_entries` returns before + `_pulumi_config_set`), so this is the operation the brief's "identical + subprocess invocations" actually requires. + """ + fake_pulumi(monkeypatch, pulumi_stub) + + argv_a, stdin_a = tmp_path / "argv-a.log", tmp_path / "stdin-a.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_a)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_a)) + monkeypatch.chdir(dotenv_repo) + assert main(["set-secrets"]) == 0 + + argv_b, stdin_b = tmp_path / "argv-b.log", tmp_path / "stdin-b.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_b)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_b)) + monkeypatch.chdir(command_repo) + assert main(["set-secrets"]) == 0 + + assert argv_a.read_bytes() == argv_b.read_bytes() + assert stdin_a.read_bytes() == stdin_b.read_bytes() + # Two `secret` entries resolved (`db.password`, `api.token`); + # `registry.user` never resolved, so it never reaches `pulumi` at all. + assert len(argv_a.read_text().splitlines()) == 2 + + +def test_file_and_a_fake_remote_produce_identical_child_environments( + tmp_path, monkeypatch, remote_stub, env_stub +): + """The `CredentialStore` half of the contract: `exec` injects the exact + same four names, with the exact same values, whether the profile it + resolves uses `file` (the default), `command` (the fake remote) or + `env` (CI's own no-store-at-all path) -- all three seeded with the same + credentials. `env` gets no encrypted store at all: this is the path a CI + runner actually takes, and it is exercised here through the real `exec` + entry point, not only through `session._build_credential_store` in + isolation.""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + store_dir = home / "stackward" + command_toml = json.dumps(remote_command(remote_stub)) + write_store_config( + store_dir, + f'[profile.staging-file]\nbackend_url = "{STAGING_BACKEND_URL}"\n\n' + f'[profile.staging-remote]\nbackend_url = "{STAGING_BACKEND_URL}"\n\n' + '[profile.staging-remote.credentials]\n' + f'provider = "command"\ncommand = {command_toml}\n\n' + f'[profile.staging-env]\nbackend_url = "{STAGING_BACKEND_URL}"\n\n' + '[profile.staging-env.credentials]\nprovider = "env"\n', + ) + store.init_store(PASSWORD, directory=store_dir) + store.set_credentials("staging-file", FULL_CREDENTIALS, PASSWORD, directory=store_dir) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_MODE", "credentials") + monkeypatch.setenv("STUB_CREDENTIALS", json.dumps(FULL_CREDENTIALS)) + # `staging-env` has no envelope at all -- these are the only place its + # credentials come from, exactly as a CI runner's own secrets would be. + for name, value in FULL_CREDENTIALS.items(): + monkeypatch.setenv(name, value) + + dump_a = tmp_path / "env-a.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump_a)) + assert main(["exec", "--profile", "staging-file", "--", str(env_stub)]) == 0 + env_a = json.loads(dump_a.read_text()) + + dump_b = tmp_path / "env-b.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump_b)) + assert main(["exec", "--profile", "staging-remote", "--", str(env_stub)]) == 0 + env_b = json.loads(dump_b.read_text()) + + dump_c = tmp_path / "env-c.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump_c)) + assert main(["exec", "--profile", "staging-env", "--", str(env_stub)]) == 0 + env_c = json.loads(dump_c.read_text()) + + # Anchored to the seeded constants, not to `env_a`. Deriving `expected` + # from one of the three environments makes the assertion say only "all + # three agree", which a uniform mutation in the shared `_child_env` -- + # dropping a name, or injecting the wrong one under every provider -- + # satisfies just as well as the correct behaviour does. Naming the values + # is what makes this test say what it means. + relevant = (*CREDENTIAL_NAMES, PULUMI_BACKEND_URL) + expected = {**FULL_CREDENTIALS, PULUMI_BACKEND_URL: STAGING_BACKEND_URL} + assert {key: env_a[key] for key in relevant} == expected + assert {key: env_b[key] for key in relevant} == expected + assert {key: env_c[key] for key in relevant} == expected diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..fb010b4 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,1889 @@ +"""Tests for `login`, `exec`, `shell`. + +Two things this file is careful about, for the same reasons `test_store.py` +states them: + +*No assertion depends on a credential value appearing in captured output.* +Where a value must not appear, the assertion is on absence in `capfd` output +(file-descriptor level, so a real child process's own writes are covered, not +only this process's `print` calls) -- never on a value being displayed. + +*Several tests exist to fail against a plausible wrong implementation, not +merely to exercise a right one.* The environment-diff tests fail against an +implementation that replaces the environment instead of overlaying four names +onto it; the backend-guard ordering test fails against an implementation that +prompts for a password before checking the guard; the repo-profile test fails +if `select_profile`'s `repo_profile` parameter is never wired to anything (the +gap `store.py`'s own report flagged as dead code). + +Every real child process spawned here is a single, environment-variable-driven +Python script (`_STUB`, written once per test by the `stub` fixture) rather +than `pulumi` itself: this module must never depend on the `pulumi` binary +being installed to prove exit-status, signal, or environment propagation -- +those are properties of *this* code, not of Pulumi's. +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +from pathlib import Path + +import pytest + +from leakcheck import assert_no_leak +from stackward import store +from stackward.cli import main +from stackward.commands import session +from stackward.commands.session import ( + AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY, + ENV_STORE_PASSWORD, + PULUMI_BACKEND_URL, + PULUMI_CONFIG_PASSPHRASE, + SessionError, +) +from stackward.store import Profile, ProfileError, StoreError + +# Placeholder values only -- see GC4. Deliberately free of any word this +# module itself prints: the leak checks below look for any eight-character +# run of these values, and the previous spelling, `the store password`, +# collides with `_read_store_password`'s own refusal ("no store password +# available: set STACKWARD_PASSWORD...") on the run ` store p`. A placeholder +# that embeds the tool's vocabulary makes a partial-disclosure check +# unusable, which is how such a check ends up deleted. +PASSWORD = "store-unlock-4d7a2f9c1e8b" +WRONG_PASSWORD = "wrong-unlock-6b3e0d5a2f7c" + +# Placeholder values only -- see GC4 in the task brief. Distinctive enough +# that an accidental substring match elsewhere in a test's own output would +# be implausible. +MARKER_KEY = "marker-access-key-1a2b3c4d" +MARKER_SECRET = "marker-secret-key-5e6f7a8b" +MARKER_PASSPHRASE = "marker-passphrase-9c0d1e2f" + +FULL_CREDENTIALS = { + AWS_ACCESS_KEY_ID: MARKER_KEY, + AWS_SECRET_ACCESS_KEY: MARKER_SECRET, + PULUMI_CONFIG_PASSPHRASE: MARKER_PASSPHRASE, +} +ALL_MARKERS = (MARKER_KEY, MARKER_SECRET, MARKER_PASSPHRASE) + +# What a developer who already has credentials exported in their own shell +# looks like -- a different, non-secret set of values under the *same* three +# names, seeded into the parent environment of every test in this file by the +# autouse fixture below. Distinct from the MARKERs on purpose: "the child got +# the profile's value" and "the child inherited the shell's value" are +# different outcomes, and a test whose parent environment does not carry these +# names at all cannot tell them apart. +AMBIENT_KEY = "ambient-access-key-2f4e6a8c" +AMBIENT_SECRET = "ambient-secret-key-1d3f5b7d" +AMBIENT_PASSPHRASE = "ambient-config-value-0e2c4a6e" +AMBIENT_CREDENTIALS = { + AWS_ACCESS_KEY_ID: AMBIENT_KEY, + AWS_SECRET_ACCESS_KEY: AMBIENT_SECRET, + PULUMI_CONFIG_PASSPHRASE: AMBIENT_PASSPHRASE, +} + +# The other half of what "a developer who already has AWS credentials +# exported" looks like: a session token from an assumed role, and a selected +# shared-config profile. Seeded into the parent environment for the same +# reason `AMBIENT_CREDENTIALS` is -- a test whose parent never carries these +# names cannot tell "scrubbed" from "was never there", which is precisely +# the assertion `SCRUBBED_NAMES` needs. Values, not just names, so nothing +# here depends on whether the developer running the suite happens to have +# either exported. +AMBIENT_SESSION_TOKEN = "ambient-session-token-3a5c7e9b" +AMBIENT_AWS_PROFILE = "ambient-config-profile-8d0f2b4a" +AMBIENT_SCRUBBED = dict( + zip(session.SCRUBBED_NAMES, (AMBIENT_SESSION_TOKEN, AMBIENT_AWS_PROFILE)) +) + +# A backend URL that carries a password in its userinfo -- the +# `postgres://user:password@host/db` form `pulumi login --help` documents and +# `_redact_url` exists for. Used wherever a test would otherwise only ever see +# a `file://` URL, for which `_redact_url` is the identity function and every +# assertion about redaction is therefore vacuous. +USERINFO_BACKEND_URL = "postgres://dbuser:super-secret-password@db.invalid:5432/state" +USERINFO_PASSWORD = "super-secret-password" + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only -- see `test_store.py`'s + identical fixture. This file performs several real seal/open round trips + per test (`init_store`, `set_credentials`, `resolve_credentials`).""" + from stackward import crypto + + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture(autouse=True) +def no_real_pulumi_state(monkeypatch, tmp_path): + """Fix this file's *parent* environment: the four names `exec`/`shell` + overlay, and nothing about them left to whoever's shell runs the suite. + + `PULUMI_HOME` points at an empty, per-test directory and + `PULUMI_BACKEND_URL` is cleared, so no test can read or depend on this + machine's real Pulumi state -- `_current_backend()` consults both, in + that order, once `PULUMI_BACKEND_URL` is set. + + The three credential names are **set**, not cleared, and that is the + single decision this fixture exists to make for the whole file. It does + two jobs at once, which were previously two separate bugs: + + *Isolation.* A developer with `AWS_ACCESS_KEY_ID` or + `AWS_SECRET_ACCESS_KEY` exported in their own shell -- an entirely + ordinary state -- failed the environment-delta tests below, which + computed `child_keys - parent_keys` and got a smaller set than they + expected. The suite's result must not depend on the shell it is run + from. + + *Discrimination.* Nothing in this suite ever put a credential name in the + parent environment, which made the delta tests blind to the one + substitution that matters: `env[name] = credentials[name]` weakened to + `env.setdefault(name, credentials[name])` passed every test in this file. + That implementation hands a developer's ambient key to a child paired + with a *different* profile's backend -- credentials for one account + pointed at another account's state. `session.py`'s own module docstring + claims the diff-based tests catch "an implementation that happened to + also inherit the right ambient variables"; this is precisely the case + they could not see, and seeding these names is what makes the assertions + below about the child's *values* rather than merely its set of keys. + """ + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "pulumi-home")) + monkeypatch.delenv(PULUMI_BACKEND_URL, raising=False) + for name, value in AMBIENT_CREDENTIALS.items(): + monkeypatch.setenv(name, value) + # See AMBIENT_SCRUBBED: set for the same reason the three above are, so + # that the delta assertions can see a removal rather than an absence. + for name, value in AMBIENT_SCRUBBED.items(): + monkeypatch.setenv(name, value) + + +@pytest.fixture +def store_directory(tmp_path) -> Path: + return tmp_path / "stackward" + + +def write_store_config(directory: Path, text: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / store.CONFIG_FILENAME + path.write_text(text) + path.chmod(store.CONFIG_MODE) + return path + + +def seed_profile( + directory: Path, + name: str, + *, + backend_url: str, + credentials: dict[str, str] | None = None, + password: str = PASSWORD, +) -> None: + """A store holding one profile in `config`, with `credentials` sealed + into it when given. Mirrors what a real `credentials init` + + `credentials set` (Task 8's sibling commands) would leave behind.""" + write_store_config(directory, f'[profile.{name}]\nbackend_url = "{backend_url}"\n') + if credentials is not None: + store.init_store(password, directory=directory) + store.set_credentials(name, credentials, password, directory=directory) + + +# --------------------------------------------------------------------------- +# A real child process, driven entirely by environment variables it reads +# itself -- never by argv, so it works identically as `exec`'s command or as +# `$SHELL` (which `shell` invokes with no arguments at all). +# --------------------------------------------------------------------------- + +_STUB = """\ +#!/usr/bin/env python3 +import json, os, sys + +dump_env_to = os.environ.get("STUB_DUMP_ENV_TO") +if dump_env_to: + with open(dump_env_to, "w") as f: + json.dump(dict(os.environ), f) + +dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") +if dump_argv_to: + with open(dump_argv_to, "w") as f: + json.dump(sys.argv[1:], f) + +sig = os.environ.get("STUB_SIGNAL") +if sig: + os.kill(os.getpid(), int(sig)) + +sys.exit(int(os.environ.get("STUB_EXIT_CODE", "0"))) +""" + + +@pytest.fixture +def stub(tmp_path) -> Path: + path = tmp_path / "stub.py" + path.write_text(_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path) -> None: + """Make `shutil.which("pulumi")` resolve to `executable` -- `cmd_login`'s + only way of finding the real binary, and the seam every `login` test + uses to run the stub in its place instead.""" + monkeypatch.setattr( + session.shutil, "which", lambda name: str(executable) if name == "pulumi" else None + ) + + +# --------------------------------------------------------------------------- +# `_compose_backend_url` -- verbatim pass-through, and composition +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "s3://placeholder-bucket/placeholder-prefix", + "gs://placeholder-bucket", + "azblob://placeholder-container", + "file:///placeholder/path", + "s3://placeholder?endpoint=placeholder.invalid®ion=placeholder-region", + "some-future-scheme://placeholder;weird=chars&more=1", + ], +) +def test_backend_url_passes_through_byte_for_byte(url): + """No normalisation, re-encoding or reordering -- whatever a profile + names is exactly what `login`/`exec`/`shell` use.""" + profile = Profile(name="p", backend_url=url) + assert session._compose_backend_url(profile) == url + + +def test_component_form_with_bucket_only(): + profile = Profile(name="p", bucket="placeholder-bucket") + assert session._compose_backend_url(profile) == "s3://placeholder-bucket" + + +def test_component_form_with_bucket_and_prefix(): + profile = Profile(name="p", bucket="placeholder-bucket", prefix="placeholder-prefix") + assert ( + session._compose_backend_url(profile) + == "s3://placeholder-bucket/placeholder-prefix" + ) + + +def test_component_form_with_region_only_adds_a_region_query_param(): + profile = Profile(name="p", bucket="placeholder-bucket", region="placeholder-region") + url = session._compose_backend_url(profile) + assert url == "s3://placeholder-bucket?region=placeholder-region" + + +def test_component_form_with_endpoint_forces_path_style(): + """A custom endpoint is exactly the case virtual-hosted-style addressing + does not work for, so `s3ForcePathStyle` is added alongside it.""" + profile = Profile(name="p", bucket="placeholder-bucket", endpoint="placeholder.invalid") + url = session._compose_backend_url(profile) + assert "endpoint=placeholder.invalid" in url + assert "s3ForcePathStyle=true" in url + + +def test_component_form_percent_encodes_a_scheme_bearing_endpoint(): + """An `endpoint` is routinely a full URL (`https://host:port`), and it + goes into a *query parameter*, where `://` and `:` are reserved. Correct + by construction today because `urlencode` quotes its values -- but + nothing exercised it, so hand-assembling the query string (the obvious + "simplification") would produce a URL Pulumi parses differently and no + test would notice.""" + profile = Profile( + name="p", bucket="placeholder-bucket", endpoint="https://placeholder.invalid:9000" + ) + url = session._compose_backend_url(profile) + assert "endpoint=https%3A%2F%2Fplaceholder.invalid%3A9000" in url + assert "://placeholder.invalid" not in url.removeprefix("s3://placeholder-bucket") + assert "s3ForcePathStyle=true" in url + + +def test_component_form_without_an_endpoint_never_adds_path_style(): + profile = Profile(name="p", bucket="placeholder-bucket", region="placeholder-region") + assert "s3ForcePathStyle" not in session._compose_backend_url(profile) + + +def test_component_form_combines_every_field(): + profile = Profile( + name="p", + bucket="placeholder-bucket", + prefix="placeholder-prefix", + region="placeholder-region", + endpoint="placeholder.invalid", + ) + url = session._compose_backend_url(profile) + assert url.startswith("s3://placeholder-bucket/placeholder-prefix?") + assert "region=placeholder-region" in url + assert "endpoint=placeholder.invalid" in url + assert "s3ForcePathStyle=true" in url + + +def test_component_form_percent_encodes_special_characters(): + profile = Profile(name="p", bucket="a bucket/weird", prefix="a prefix?with=chars") + url = session._compose_backend_url(profile) + # The bucket's own '/' must be encoded (it is not a path separator here); + # the prefix's '/' would be, but this prefix has none. + assert "a%20bucket%2Fweird" in url + assert "a%20prefix%3Fwith%3Dchars" in url + + +def test_an_empty_prefix_region_or_endpoint_is_treated_as_absent(): + profile = Profile(name="p", bucket="placeholder-bucket", prefix="", region="", endpoint="") + assert session._compose_backend_url(profile) == "s3://placeholder-bucket" + + +def test_a_profile_with_no_backend_at_all_fails_closed_naming_the_profile(): + """Unreachable through `store._build_profile`, which already refuses to + construct such a `Profile` -- covers a hand-built one instead, so this + function never raises a bare `AttributeError`.""" + profile = Profile(name="broken-profile") + with pytest.raises(SessionError, match="broken-profile"): + session._compose_backend_url(profile) + + +# --------------------------------------------------------------------------- +# Reading Pulumi's current backend, and the guard built on it +# --------------------------------------------------------------------------- + + +def test_pulumi_home_defaults_when_the_env_var_is_absent(monkeypatch): + monkeypatch.delenv("PULUMI_HOME", raising=False) + assert session._pulumi_home() == Path.home() / ".pulumi" + + +def test_pulumi_home_honours_the_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("PULUMI_HOME", str(tmp_path / "custom")) + assert session._pulumi_home() == tmp_path / "custom" + + +def test_current_backend_is_none_when_never_logged_in(): + assert session._current_backend() is None + + +def test_current_backend_reads_the_current_field(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text(json.dumps({"current": "file:///seen"})) + assert session._current_backend() == "file:///seen" + + +def test_current_backend_is_none_when_the_field_is_absent_from_valid_json( + monkeypatch, tmp_path +): + """A credentials file with no `current` entry yet is the same legitimate + "never logged in" state as no file at all -- not corruption.""" + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text(json.dumps({"accounts": {}})) + assert session._current_backend() is None + + +def test_current_backend_raises_on_malformed_json(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text("{not json") + with pytest.raises(SessionError): + session._current_backend() + + +def test_current_backend_raises_when_the_document_is_not_an_object(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text("[1, 2, 3]") + with pytest.raises(SessionError): + session._current_backend() + + +def test_current_backend_raises_when_current_is_not_a_string(monkeypatch, tmp_path): + home = tmp_path / "ph" + monkeypatch.setenv("PULUMI_HOME", str(home)) + home.mkdir() + (home / "credentials.json").write_text(json.dumps({"current": 5})) + with pytest.raises(SessionError): + session._current_backend() + + +def test_current_backend_raises_on_an_unreadable_file_rather_than_treating_it_as_absent( + monkeypatch, tmp_path +): + """Fail closed: distinguishing "genuinely never logged in" (a missing + file) from "cannot tell" (any other read failure) is what stops the guard + itself from becoming a fail-open path.""" + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text("{}") + + def boom(self, *a, **k): + raise PermissionError("simulated: cannot read") + + monkeypatch.setattr(Path, "read_bytes", boom) + with pytest.raises(SessionError): + session._current_backend() + + +def test_guard_passes_when_never_logged_in_at_all(monkeypatch): + monkeypatch.setattr(session, "_current_backend", lambda: None) + session._check_backend_guard("file:///anything") # must not raise + + +def test_guard_passes_on_an_exact_match(monkeypatch): + monkeypatch.setattr(session, "_current_backend", lambda: "file:///same") + session._check_backend_guard("file:///same") # must not raise + + +def test_guard_refuses_a_mismatch_naming_both(monkeypatch): + monkeypatch.setattr(session, "_current_backend", lambda: "file:///other") + with pytest.raises(SessionError) as raised: + session._check_backend_guard("file:///mine") + message = str(raised.value) + assert "file:///mine" in message + assert "file:///other" in message + + +@pytest.mark.parametrize( + ("current", "resolved"), + [ + # The resolved URL is a *prefix* of the current one, and vice versa. + # Two different buckets, two different backends -- containment is not + # a match, and a substring comparison would silently allow the pair. + ("s3://placeholder-bucket-two/prefix", "s3://placeholder-bucket"), + ("s3://placeholder-bucket", "s3://placeholder-bucket-two/prefix"), + # A trailing path segment, the same shape by a different route. + ("file:///backend/inner", "file:///backend"), + ("file:///backend", "file:///backend/inner"), + ], +) +def test_guard_refuses_a_backend_that_merely_contains_the_other( + monkeypatch, current, resolved +): + """Containment is not equality, and the guard must not treat it as such. + + Every existing guard test pairs two URLs where neither contains the + other, so `current != resolved_url` weakened to `resolved_url not in + current` -- a substring test -- passes all of them. It would let a + profile pointing at one bucket run against a live login to a + differently-named bucket whose name merely starts the same way: a + different backend, silently accepted, which is the entire failure the + guard exists to prevent. + + The comparison in `session.py` is already an exact `!=`; this pins it + there so it cannot be relaxed into containment by a later "fix". + """ + monkeypatch.setattr(session, "_current_backend", lambda: current) + with pytest.raises(SessionError, match="backend mismatch"): + session._check_backend_guard(resolved) + + +def test_guard_message_redacts_userinfo_in_both_urls(monkeypatch): + """The guard's "naming both" message must never print a password that + happened to be embedded in a backend URL (`postgres://user:pass@host/db` + is a documented Pulumi backend form).""" + monkeypatch.setattr( + session, "_current_backend", lambda: "postgres://u:current-secret@host/db" + ) + with pytest.raises(SessionError) as raised: + session._check_backend_guard("postgres://u:resolved-secret@host/db") + message = str(raised.value) + assert "current-secret" not in message + assert "resolved-secret" not in message + assert "@host/db" in message + + +def test_exec_refuses_against_a_real_disagreeing_credentials_file_on_disk( + cli_store, monkeypatch, tmp_path, stub, capsys +): + """The guard's two halves -- reading a real file, and comparing against + it -- proven together, not each in isolation against a mock of the + other.""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + # `no_real_pulumi_state` (autouse) already points PULUMI_HOME here. + home = Path(os.environ["PULUMI_HOME"]) + home.mkdir(parents=True, exist_ok=True) + (home / "credentials.json").write_text( + json.dumps({"current": "file:///a-real-disagreement"}) + ) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + err = capsys.readouterr().err + assert "file:///staging-backend" in err + assert "file:///a-real-disagreement" in err + assert not marker_file.exists() + + +# --------------------------------------------------------------------------- +# `_redact_url` +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "s3://placeholder-bucket/placeholder-prefix", + "file:///placeholder/path", + "gs://placeholder-bucket", + ], +) +def test_redact_url_leaves_a_userinfo_free_url_unchanged(url): + assert session._redact_url(url) == url + + +def test_redact_url_replaces_userinfo_with_a_placeholder(): + url = "postgres://dbuser:super-secret@db.invalid:5432/state" + redacted = session._redact_url(url) + assert "super-secret" not in redacted + assert "dbuser" not in redacted + assert redacted == "postgres://@db.invalid:5432/state" + + +def test_redact_url_handles_a_username_with_no_password(): + url = "postgres://dbuser@db.invalid:5432/state" + assert session._redact_url(url) == "postgres://@db.invalid:5432/state" + + +def test_redact_url_only_touches_the_first_at_sign(): + """A later `@` -- in a path or query string, say -- is not userinfo and + must survive.""" + url = "postgres://dbuser:secret@db.invalid:5432/state?note=a@b" + redacted = session._redact_url(url) + assert "secret" not in redacted + assert redacted.endswith("?note=a@b") + + +# --------------------------------------------------------------------------- +# `_current_backend` honouring an already-exported `PULUMI_BACKEND_URL` +# --------------------------------------------------------------------------- + + +def test_current_backend_prefers_an_exported_env_var_over_the_persisted_file( + monkeypatch, tmp_path +): + """Pulumi's own CLI honours `PULUMI_BACKEND_URL` over a persisted login, + so a value already exported into the caller's shell is what a stray + `pulumi` command would actually use -- and is what the guard must catch, + even when the persisted file says something else entirely.""" + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text(json.dumps({"current": "file:///from-the-file"})) + monkeypatch.setenv(PULUMI_BACKEND_URL, "file:///from-the-env-var") + assert session._current_backend() == "file:///from-the-env-var" + + +def test_current_backend_falls_back_to_the_file_when_the_env_var_is_absent( + monkeypatch, tmp_path +): + # PULUMI_BACKEND_URL is already absent -- the autouse `no_real_pulumi_state` + # fixture clears it for every test in this file. + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text(json.dumps({"current": "file:///from-the-file"})) + assert session._current_backend() == "file:///from-the-file" + + +def test_current_backend_treats_an_empty_env_var_as_absent(monkeypatch, tmp_path): + home = tmp_path / "ph" + home.mkdir() + monkeypatch.setenv("PULUMI_HOME", str(home)) + (home / "credentials.json").write_text(json.dumps({"current": "file:///from-the-file"})) + monkeypatch.setenv(PULUMI_BACKEND_URL, "") + assert session._current_backend() == "file:///from-the-file" + + +# --------------------------------------------------------------------------- +# The store password: env var, interactive prompt, and the tty refusal -- +# never `sys.stdin`. +# --------------------------------------------------------------------------- + + +class _PoisonedStdin: + """Raises if anything reads from it. `_read_store_password` must never + touch `sys.stdin` on any branch -- it is the child's, for `exec`/`shell`.""" + + def read(self, *a, **k): + raise AssertionError("_read_store_password touched sys.stdin") + + def readline(self, *a, **k): + raise AssertionError("_read_store_password touched sys.stdin") + + +@pytest.fixture(autouse=True) +def poisoned_stdin(monkeypatch): + monkeypatch.setattr(sys, "stdin", _PoisonedStdin()) + + +def test_tty_available_asks_for_dev_tty_specifically_and_closes_it(monkeypatch): + """The function body itself, which no test had ever executed. + + Every existing test either set `STACKWARD_PASSWORD` or monkeypatched + `_tty_available` away, so redirecting the probe from `/dev/tty` to + `/dev/null` -- which always opens -- passed the whole suite. That + substitution makes the function answer "yes, there is a terminal" in + every environment, including the ones it exists to refuse, so the path is + asserted here and not merely the boolean. + + `/dev/tty` is the point: it is the controlling terminal, reachable + independently of `sys.stdin`, which for `exec`/`shell` belongs to the + child. Asking about anything else answers a different question. + """ + opened: list[str] = [] + closed: list[int] = [] + real_open, real_close = os.open, os.close + + def spy_open(path, flags, *args, **kwargs): + opened.append(path) + return real_open(os.devnull, os.O_RDWR) + + def spy_close(fd): + closed.append(fd) + return real_close(fd) + + monkeypatch.setattr(session.os, "open", spy_open) + monkeypatch.setattr(session.os, "close", spy_close) + + assert session._tty_available() is True + + assert opened == ["/dev/tty"] + assert closed, "the probe leaked its file descriptor" + + +def test_tty_available_is_false_when_dev_tty_cannot_be_opened(monkeypatch): + """The refusal branch, exercised through the real function rather than + by patching it out.""" + + def refuse(path, *args, **kwargs): + raise OSError("simulated: no controlling terminal") + + monkeypatch.setattr(session.os, "open", refuse) + assert session._tty_available() is False + + +def test_password_from_env_var_is_used_without_prompting(monkeypatch): + monkeypatch.setenv(ENV_STORE_PASSWORD, "from-the-environment") + + def boom(*a, **k): + raise AssertionError("getpass.getpass must not be called when the env var is set") + + monkeypatch.setattr(session.getpass, "getpass", boom) + assert session._read_store_password() == "from-the-environment" + + +def test_password_prompts_interactively_when_a_tty_is_available(monkeypatch): + monkeypatch.delenv(ENV_STORE_PASSWORD, raising=False) + monkeypatch.setattr(session, "_tty_available", lambda: True) + monkeypatch.setattr(session.getpass, "getpass", lambda prompt="": "typed-password") + assert session._read_store_password() == "typed-password" + + +def test_password_refuses_rather_than_falling_back_to_stdin_when_no_tty(monkeypatch): + monkeypatch.delenv(ENV_STORE_PASSWORD, raising=False) + monkeypatch.setattr(session, "_tty_available", lambda: False) + + def boom(*a, **k): + raise AssertionError("getpass.getpass must not run without a tty") + + monkeypatch.setattr(session.getpass, "getpass", boom) + with pytest.raises(SessionError, match=ENV_STORE_PASSWORD): + session._read_store_password() + + +# --------------------------------------------------------------------------- +# Required credential names, and building the child environment +# --------------------------------------------------------------------------- + + +def test_all_three_names_present_is_accepted(): + session._require_credential_names(dict(FULL_CREDENTIALS), "p") # must not raise + + +def test_a_missing_name_is_named_but_no_value_is(): + partial = dict(FULL_CREDENTIALS) + del partial[AWS_SECRET_ACCESS_KEY] + with pytest.raises(SessionError) as raised: + session._require_credential_names(partial, "p") + message = str(raised.value) + assert AWS_SECRET_ACCESS_KEY in message + for marker in ALL_MARKERS: + assert marker not in message + + +def test_every_missing_name_is_named(): + with pytest.raises(SessionError) as raised: + session._require_credential_names({}, "p") + message = str(raised.value) + for name in (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, PULUMI_CONFIG_PASSPHRASE): + assert name in message + + +def test_extra_names_beyond_the_three_required_are_accepted(): + extra = dict(FULL_CREDENTIALS) + extra["SOME_OTHER_NAME"] = "whatever" + session._require_credential_names(extra, "p") # must not raise + + +def test_child_env_overlays_exactly_four_names_on_a_copy_of_the_parent(monkeypatch): + """Every one of the four names carries the *profile's* value, over an + otherwise untouched copy of the parent environment. + + The parent already carries all three credential names, with different + values (see `no_real_pulumi_state`), so this is an assertion about + overwriting and not merely about setting: `env.setdefault(name, ...)` + leaves the ambient value in place and fails here. Without that seeding + the whole test reduced to a set-difference over key *names*, which + `setdefault` satisfies exactly. + """ + # Injected, not merely assumed absent: this is the exact non-interactive + # path (`STACKWARD_PASSWORD=x stackward exec -- ...`) the scrubbing + # protects, and a difference-based assertion that never puts the name in + # the parent to begin with cannot tell "scrubbed" from "was never there". + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + parent_before = dict(os.environ) + env = session._child_env(FULL_CREDENTIALS, "file:///backend") + + assert dict(os.environ) == parent_before # the real environment is untouched + + parent_keys = set(parent_before) + child_keys = set(env) + # `PULUMI_BACKEND_URL` is the only name the parent does not already have. + assert child_keys - parent_keys == {PULUMI_BACKEND_URL} + # The names that must be *dropped*, not merely left alone. + assert parent_keys - child_keys == {ENV_STORE_PASSWORD, *session.SCRUBBED_NAMES} + assert ENV_STORE_PASSWORD not in env + dropped = {ENV_STORE_PASSWORD, *session.SCRUBBED_NAMES} + for key in parent_keys - dropped - set(AMBIENT_CREDENTIALS): + assert env[key] == parent_before[key] # nothing else changed + assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY != AMBIENT_KEY + assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET != AMBIENT_SECRET + assert env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE != AMBIENT_PASSPHRASE + assert env[PULUMI_BACKEND_URL] == "file:///backend" + assert PASSWORD not in env.values() + + +def test_a_stale_session_token_and_shared_profile_are_dropped_from_the_child(): + """The two `SCRUBBED_NAMES`, each present in the parent with a value. + + Not a restatement of the delta assertion above: this one names the two + variables literally, so it stays honest if `SCRUBBED_NAMES` is ever + emptied -- a delta written as `{ENV_STORE_PASSWORD, *SCRUBBED_NAMES}` + passes against an empty tuple, since both sides shrink together. + """ + assert os.environ["AWS_SESSION_TOKEN"] == AMBIENT_SESSION_TOKEN + assert os.environ["AWS_PROFILE"] == AMBIENT_AWS_PROFILE + + env = session._child_env(FULL_CREDENTIALS, "file:///backend") + + assert "AWS_SESSION_TOKEN" not in env + assert "AWS_PROFILE" not in env + + +def test_a_scrubbed_name_sealed_into_the_envelope_is_still_not_injected(): + """Scrubbed *and* never supplied. + + An envelope may legitimately hold names beyond the three required ones + (`test_extra_names_beyond_the_three_required_are_accepted`), so "the + store has a value for it" is a reachable state. It still must not reach + the child: a token sealed months ago is exactly the stale one the scrub + exists to remove, and injecting it would make the store a way to + reintroduce the problem. + """ + sealed = dict(FULL_CREDENTIALS) + sealed["AWS_SESSION_TOKEN"] = "envelope-session-token-6c8a0e2d" + + env = session._child_env(sealed, "file:///backend") + + assert "AWS_SESSION_TOKEN" not in env + + +# --------------------------------------------------------------------------- +# Exit status translation, including a signal +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("code", [0, 1, 2, 42, 255]) +def test_exit_status_passes_a_normal_code_through_unchanged(code): + assert session._exit_status(code) == code + + +@pytest.mark.parametrize( + "returncode,expected", + [(-signal.SIGTERM, 128 + signal.SIGTERM), (-signal.SIGKILL, 128 + signal.SIGKILL)], +) +def test_exit_status_translates_a_negative_signal_return_code(returncode, expected): + assert session._exit_status(returncode) == expected + + +# --------------------------------------------------------------------------- +# `_run_child` against a real subprocess +# --------------------------------------------------------------------------- + + +def test_run_child_propagates_a_nonzero_exit_code(stub): + code = session._run_child([str(stub)], {**os.environ, "STUB_EXIT_CODE": "7"}) + assert code == 7 + + +def test_run_child_propagates_a_signal_terminated_child(stub): + code = session._run_child( + [str(stub)], {**os.environ, "STUB_SIGNAL": str(int(signal.SIGTERM))} + ) + assert code == 128 + signal.SIGTERM + + +def test_run_child_reports_a_missing_executable_without_a_traceback(tmp_path, capsys): + missing = tmp_path / "does-not-exist" + code = session._run_child([str(missing)]) + assert code == 2 + assert "does-not-exist" in capsys.readouterr().err + + +def test_run_child_passes_env_none_through_to_inherit_unchanged(monkeypatch, stub): + monkeypatch.setenv("STACKWARD_TEST_MARKER_VAR", "seen") + dump = None + + def fake_run(command, env=None): + nonlocal dump + dump = env + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(session.subprocess, "run", fake_run) + session._run_child([str(stub)]) + assert dump is None + + +def test_run_child_catches_a_keyboard_interrupt_and_returns_the_sigint_status(monkeypatch): + """`fail_closed` cannot catch this -- `KeyboardInterrupt` inherits from + `BaseException`, not `Exception` -- so `_run_child` must, or a `Ctrl-C` + during `stackward exec -- pulumi up` prints a raw traceback.""" + + def fake_run(command, env=None): + raise KeyboardInterrupt() + + monkeypatch.setattr(session.subprocess, "run", fake_run) + assert session._run_child(["irrelevant"]) == 128 + signal.SIGINT + + +def test_exec_prints_no_traceback_on_a_keyboard_interrupt( + cli_store, monkeypatch, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + def fake_run(command, env=None): + raise KeyboardInterrupt() + + monkeypatch.setattr(session.subprocess, "run", fake_run) + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 128 + signal.SIGINT + captured = capsys.readouterr() + assert "Traceback" not in captured.err + assert "KeyboardInterrupt" not in captured.err + + +# --------------------------------------------------------------------------- +# `_resolve_profile` -- every precedence branch, including the repo-config +# tier this task is responsible for wiring. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(tmp_path) -> Path: + """A directory tree `find_repo_config` will search from, bounded by a + `.git` marker -- no real git repository is needed for that function.""" + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + return root + + +def test_explicit_profile_resolves_regardless_of_other_sources(store_directory): + """`--profile` at the wiring level: `select_profile` itself already + proves (in `test_store.py`) that an explicit value wins over a competing + `repo_profile` string; `_resolve_profile` goes one step further and never + even calls `_repo_profile()` when `explicit` is given (see + `test_an_explicit_profile_bypasses_a_broken_repo_config`, which proves + that specifically) -- so there is no competing repository profile for + this test to write, and doing so would only look like it was proving + something it no longer does.""" + write_store_config(store_directory, '[profile.chosen]\nbackend_url = "file:///chosen"\n') + profile = session._resolve_profile("chosen", directory=store_directory) + assert profile.name == "chosen" + + +def test_env_var_wins_over_repo_and_default(store_directory, repo, monkeypatch): + write_store_config( + store_directory, + 'default_profile = "fallback"\n' + '[profile.fallback]\nbackend_url = "file:///fallback"\n' + '[profile.from_repo]\nbackend_url = "file:///from-repo"\n' + '[profile.from_env]\nbackend_url = "file:///from-env"\n', + ) + (repo / ".stackward.toml").write_text('profile = "from_repo"\n') + monkeypatch.chdir(repo) + monkeypatch.setenv(store.ENV_PROFILE, "from_env") + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "from_env" + + +def test_the_repo_profile_tier_is_wired_and_beats_the_default( + store_directory, repo, monkeypatch +): + """The tier Task 6's own report flagged as dead code: `select_profile` + has always accepted `repo_profile`, but nothing supplied it until this + module read `.stackward.toml` itself. This is the normal case -- a + repository's stacks live in one backend -- so it must win over + `default_profile`, not merely be reachable.""" + write_store_config( + store_directory, + 'default_profile = "fallback"\n' + '[profile.fallback]\nbackend_url = "file:///fallback"\n' + '[profile.from_repo]\nbackend_url = "file:///from-repo"\n', + ) + (repo / ".stackward.toml").write_text('profile = "from_repo"\n') + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "from_repo" + + +def test_default_profile_is_the_last_resort(store_directory, repo, monkeypatch): + write_store_config( + store_directory, + 'default_profile = "fallback"\n[profile.fallback]\nbackend_url = "file:///fallback"\n', + ) + monkeypatch.chdir(repo) # no .stackward.toml here at all + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "fallback" + + +def test_no_profile_anywhere_is_a_profile_error(store_directory, repo, monkeypatch): + write_store_config(store_directory, '[profile.unrelated]\nbackend_url = "file:///x"\n') + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + with pytest.raises(ProfileError): + session._resolve_profile(None, directory=store_directory) + + +def test_absent_repo_config_falls_through_cleanly_to_the_default( + store_directory, repo, monkeypatch +): + """No `.stackward.toml` at all is a normal state, not an error -- the + repo tier simply does not speak, and selection moves on.""" + write_store_config( + store_directory, + 'default_profile = "fallback"\n[profile.fallback]\nbackend_url = "file:///fallback"\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + assert session._repo_profile() is None + profile = session._resolve_profile(None, directory=store_directory) + assert profile.name == "fallback" + + +def test_an_explicit_profile_bypasses_a_broken_repo_config( + store_directory, repo, monkeypatch +): + """`--profile` is self-sufficient: it must not require an unrelated, + broken `.stackward.toml` elsewhere in the repository to be fixed first.""" + write_store_config(store_directory, '[profile.chosen]\nbackend_url = "file:///chosen"\n') + (repo / ".stackward.toml").write_text("[profile\nbroken = ") + monkeypatch.chdir(repo) + profile = session._resolve_profile("chosen", directory=store_directory) + assert profile.name == "chosen" + + +def test_a_malformed_repo_config_is_a_config_error_not_a_silent_miss(repo, monkeypatch): + """A `.stackward.toml` that fails to parse must not be treated the same + as no file at all -- that would turn a broken repo policy into a silently + missing one.""" + (repo / ".stackward.toml").write_text("[profile\nbroken = ") + monkeypatch.chdir(repo) + from stackward.config import ConfigError + + with pytest.raises(ConfigError): + session._repo_profile() + + +def test_a_malformed_repo_config_is_reported_by_login_as_a_could_not_run_error( + cli_store, repo, monkeypatch, capsys +): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + (repo / ".stackward.toml").write_text("[profile\nbroken = ") + monkeypatch.chdir(repo) + + code = main(["login"]) + + assert code == 2 + assert "invalid TOML" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# `_prepare_env` -- the full pipeline `exec`/`shell` share, at the unit level +# --------------------------------------------------------------------------- + + +def test_prepare_env_happy_path_builds_the_expected_environment(store_directory, monkeypatch): + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + env = session._prepare_env("staging", directory=store_directory) + assert env[AWS_ACCESS_KEY_ID] == MARKER_KEY + assert env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET + assert env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE + assert env[PULUMI_BACKEND_URL] == "file:///staging-backend" + + +def test_prepare_env_checks_the_backend_guard_before_reading_a_password( + store_directory, monkeypatch +): + """Ordering matters: a mismatch must be reported without ever prompting + for -- or requiring -- a password.""" + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///somewhere-else") + + def boom(): + raise AssertionError("password must not be read when the guard refuses") + + monkeypatch.setattr(session, "_read_store_password", boom) + with pytest.raises(SessionError, match="backend mismatch"): + session._prepare_env("staging", directory=store_directory) + + +def test_prepare_env_allows_a_matching_backend(store_directory, monkeypatch): + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///staging-backend") + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + env = session._prepare_env("staging", directory=store_directory) + assert env[PULUMI_BACKEND_URL] == "file:///staging-backend" + + +def test_prepare_env_wrong_password_raises_and_leaks_nothing(store_directory, monkeypatch): + seed_profile( + store_directory, + "staging", + backend_url="file:///staging-backend", + credentials=FULL_CREDENTIALS, + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, WRONG_PASSWORD) + with pytest.raises(StoreError) as raised: + session._prepare_env("staging", directory=store_directory) + message = str(raised.value) + assert WRONG_PASSWORD not in message + for marker in ALL_MARKERS: + assert marker not in message + + +def test_prepare_env_missing_credential_name_raises_naming_it(store_directory, monkeypatch): + incomplete = {AWS_ACCESS_KEY_ID: MARKER_KEY, AWS_SECRET_ACCESS_KEY: MARKER_SECRET} + seed_profile( + store_directory, "staging", backend_url="file:///staging-backend", credentials=incomplete + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + with pytest.raises(SessionError, match=PULUMI_CONFIG_PASSPHRASE): + session._prepare_env("staging", directory=store_directory) + + +def test_prepare_env_profile_with_no_credentials_set_raises(store_directory, monkeypatch): + """A profile in `config` with no envelope selects but cannot resolve -- + R3 from Task 6's store, exercised through this module's own pipeline.""" + seed_profile(store_directory, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + with pytest.raises(StoreError): + session._prepare_env("staging", directory=store_directory) + + +# --------------------------------------------------------------------------- +# Full CLI wiring: `main([...])`, real subprocesses, `capfd`-level secrecy. +# --------------------------------------------------------------------------- + + +def assert_expected_session_env( + child_env: dict[str, str], parent_before: dict[str, str], *, backend_url: str +) -> None: + """The full contract `exec` and `shell` share, in one place. + + Both commands overlay the same four names onto a copy of the caller's + environment and scrub the same one, so both get the same assertion block. + `shell` previously had no equivalent at all -- the one test on it checked + two keys, and popping `PULUMI_CONFIG_PASSPHRASE` out of `shell`'s child + environment passed the whole suite while failing two `exec` tests. + + The four names are asserted by *value* against the profile's, and against + the different values the same names carry in the parent (see + `no_real_pulumi_state`), so an implementation that inherited rather than + overlaid them fails here rather than satisfying a set-difference over + names. + """ + parent_keys = set(parent_before) + child_keys = set(child_env) + # `PULUMI_BACKEND_URL` is the only name the parent does not already carry. + assert child_keys - parent_keys == {PULUMI_BACKEND_URL} + # The names that must be *dropped*, not merely left alone: the store + # master password unlocks every profile, not just this one, and must + # never reach caller-supplied code; the two `SCRUBBED_NAMES` would sit + # beside the freshly injected key pair and contradict it. All three are + # in the parent with real values (see `no_real_pulumi_state`), so this + # asserts a removal and not an absence. + assert parent_keys - child_keys == {ENV_STORE_PASSWORD, *session.SCRUBBED_NAMES} + assert ENV_STORE_PASSWORD not in child_env + for name in session.SCRUBBED_NAMES: + assert name not in child_env + untouched = ( + parent_keys + - {"STUB_DUMP_ENV_TO", ENV_STORE_PASSWORD} + - set(session.SCRUBBED_NAMES) + ) + for key in untouched - set(AMBIENT_CREDENTIALS): + assert child_env[key] == parent_before[key] + assert child_env[AWS_ACCESS_KEY_ID] == MARKER_KEY != AMBIENT_KEY + assert child_env[AWS_SECRET_ACCESS_KEY] == MARKER_SECRET != AMBIENT_SECRET + assert child_env[PULUMI_CONFIG_PASSPHRASE] == MARKER_PASSPHRASE != AMBIENT_PASSPHRASE + assert child_env[PULUMI_BACKEND_URL] == backend_url + assert PASSWORD not in child_env.values() + + +@pytest.fixture +def cli_store(tmp_path, monkeypatch) -> Path: + """Points the real config-home lookup (`XDG_CONFIG_HOME`) at an isolated + directory, the way `cli.config_home` is actually consulted by every + `cmd_*` entry point -- as opposed to the `directory=` seam the unit tests + above use directly.""" + home = tmp_path / "xdg" + monkeypatch.setenv("XDG_CONFIG_HOME", str(home)) + return home / "stackward" + + +def test_exec_with_no_command_is_a_usage_error(monkeypatch, capsys): + assert main(["exec"]) == 2 + assert "requires a command" in capsys.readouterr().err + + +def test_exec_with_only_a_bare_separator_is_a_usage_error(capsys): + assert main(["exec", "--"]) == 2 + assert "requires a command" in capsys.readouterr().err + + +def test_exec_usage_error_is_reported_before_any_profile_resolution(monkeypatch, capsys): + """If the usage check ran after resolution instead of before it, this + would still exit 2 -- but via `fail_closed`'s generic catch-all around + the poisoned `_resolve_profile` below, with a *different* message. The + message assertion, not just the exit code, is what makes this + discriminate between the two orderings.""" + monkeypatch.setattr( + session, + "_resolve_profile", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("profile resolution must not run before the usage check") + ), + ) + assert main(["exec"]) == 2 + err = capsys.readouterr().err + assert "requires a command" in err + assert "AssertionError" not in err + + +def test_shell_with_no_shell_env_var_errors(monkeypatch, capsys): + monkeypatch.delenv("SHELL", raising=False) + assert main(["shell"]) == 2 + assert "SHELL" in capsys.readouterr().err + + +def test_exec_runs_the_child_with_exactly_the_expected_environment_delta( + cli_store, monkeypatch, tmp_path, stub, capfd +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + parent_before = dict(os.environ) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert_expected_session_env( + child_env, parent_before, backend_url="file:///staging-backend" + ) + + captured = capfd.readouterr() + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") + + +def test_exec_passes_extra_flags_through_to_the_child_argv( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + + code = main(["exec", "--profile", "staging", "--", str(stub), "up", "--yes"]) + + assert code == 0 + assert json.loads(dump.read_text()) == ["up", "--yes"] + + +def test_exec_dash_dash_help_runs_the_child_rather_than_printing_stackwards_help( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + + code = main(["exec", "--profile", "staging", "--", str(stub), "--help"]) + + assert code == 0 + assert json.loads(dump.read_text()) == ["--help"] + assert "usage:" not in capsys.readouterr().out + + +def test_a_second_separator_survives_and_reaches_the_child( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "argv.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(dump)) + + code = main(["exec", "--profile", "staging", "--", str(stub), "--", "pulumi"]) + + assert code == 0 + assert json.loads(dump.read_text()) == ["--", "pulumi"] + + +def test_exec_propagates_a_nonzero_exit_code_from_a_real_child( + cli_store, monkeypatch, stub, capfd +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_EXIT_CODE", "3") + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 3 + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub, capfd): + """The one path where decrypted credential values are genuinely live in + this process's memory at the moment the child dies -- the case a sweep + over only early-failing paths would miss entirely.""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_SIGNAL", str(int(signal.SIGTERM))) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 128 + signal.SIGTERM + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_reports_a_missing_command_without_disclosing_the_environment( + cli_store, monkeypatch, tmp_path, stub, capfd +): + """`stackward exec -- puluim up` -- an ordinary typo -- must not print + the credentials it had just put in that child's environment. + + This is `exec`'s single most likely failure, and it was the one path + where a decrypted environment is fully built and live at the moment + something goes wrong. Nothing covered it: the only test on + `_run_child`'s `OSError` branch called it directly with `env=None`, and + no `exec` or `shell` test ever named a command that does not exist, so + adding `env={env}` to that message passed all 781 tests. + + `capfd`, not `capsys`: the message is this process's own, but the whole + point of the path is a real spawn attempt, and this file's convention is + file-descriptor capture wherever a child is involved. + """ + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + missing = tmp_path / "puluim" + + code = main(["exec", "--profile", "staging", "--", str(missing)]) + + assert code == 2 + captured = capfd.readouterr() + # It still says which command it could not run -- the message has to stay + # useful, and the name is the caller's own argv, never a credential. + assert "puluim" in captured.err + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") + + +def test_shell_reports_a_missing_shell_without_disclosing_the_environment( + cli_store, monkeypatch, tmp_path, capfd +): + """The same path through `shell`, whose `$SHELL` can equally name + something that is not there (a shell uninstalled since login).""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("SHELL", str(tmp_path / "no-such-shell")) + + code = main(["shell", "--profile", "staging"]) + + assert code == 2 + captured = capfd.readouterr() + assert "no-such-shell" in captured.err + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") + + +def test_exec_refuses_a_backend_mismatch_and_never_runs_the_child( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///a-different-backend") + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + err = capsys.readouterr().err + assert "file:///staging-backend" in err + assert "file:///a-different-backend" in err + assert not marker_file.exists() + + +def test_exec_with_wrong_password_refuses_and_never_runs_the_child( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, WRONG_PASSWORD) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + captured = capsys.readouterr() + assert not marker_file.exists() + for marker in (WRONG_PASSWORD, *ALL_MARKERS): + assert marker not in captured.out + assert marker not in captured.err + + +def test_exec_with_no_password_available_refuses_and_never_runs_the_child( + cli_store, monkeypatch, tmp_path, stub, capsys +): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.delenv(ENV_STORE_PASSWORD, raising=False) + monkeypatch.setattr(session, "_tty_available", lambda: False) + marker_file = tmp_path / "ran" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(marker_file)) + + code = main(["exec", "--profile", "staging", "--", str(stub)]) + + assert code == 2 + assert not marker_file.exists() + captured = capsys.readouterr() + # Must be *this* module's own refusal, naming the escape hatch -- not + # `fail_closed`'s generic catch-all, which an unguarded `getpass` call + # could also route through by raising some other exception once it hits + # the poisoned stdin in this sandbox (no controlling terminal is ever + # available while running under pytest). + assert ENV_STORE_PASSWORD in captured.err + assert "RuntimeError" not in captured.err + assert "AssertionError" not in captured.err + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def _run_detached(argv: list[str], env: dict[str, str], *, stdin: str, cwd: Path): + """Run `stackward ` in a real process with **no controlling + terminal**, feeding `stdin` to it. + + `start_new_session=True` is `setsid()`: the child gets a fresh session and + therefore no controlling terminal at all, so `open("/dev/tty")` fails with + `ENXIO` exactly as it does in a CI job, a git hook run from a GUI client, + or a `nohup`. That state cannot be produced inside the test process, which + has a terminal whenever the suite is run from one -- and monkeypatching + `_tty_available` to `False`, which is what every existing test does, is + assuming the answer rather than provoking it. + + The environment is passed through wholesale (minus what the caller + changes) so that `PYTHONPATH` and the rest of the interpreter's own setup + reach the child. + """ + program = "import sys; from stackward.cli import main; sys.exit(main(sys.argv[1:]))" + return subprocess.run( + [sys.executable, "-c", program, *argv], + input=stdin, + capture_output=True, + text=True, + env=env, + cwd=str(cwd), + start_new_session=True, + timeout=120, + ) + + +def test_no_tty_and_no_password_refuses_instead_of_reading_the_childs_stdin( + cli_store, tmp_path, stub +): + """The fail-open `_tty_available` exists to prevent, provoked for real. + + Without the check, `getpass.getpass` cannot open `/dev/tty` either -- and + falls back to reading `sys.stdin`, with echo. For `exec` and `shell` that + descriptor is the *child's*: the password is consumed before the child + ever sees it, and printed on screen on the way past. The refusal has to + happen first. + + Two halves, and both are needed. The exit code alone does not + discriminate: with the probe pointed at `/dev/null` instead of + `/dev/tty`, `getpass` reads the password off the pipe below, the store + opens, and the child *runs* -- so the marker file is what says whether + the fallback happened. The positive control at the end proves the marker + file would have appeared, in this same detached setup, had a password + been available at all. + """ + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + marker_file = tmp_path / "the-child-ran" + + env = dict(os.environ) + env.pop(ENV_STORE_PASSWORD, None) + env.pop(PULUMI_BACKEND_URL, None) + # An empty, per-test Pulumi home: otherwise the backend guard could refuse + # first, for an unrelated reason, and this test would pass having never + # reached the password at all. + env["PULUMI_HOME"] = str(tmp_path / "empty-pulumi-home") + env["STUB_DUMP_ARGV_TO"] = str(marker_file) + + argv = ["exec", "--profile", "staging", "--", str(stub)] + result = _run_detached(argv, env, stdin=PASSWORD + "\n", cwd=tmp_path) + + assert result.returncode == 2 + # This module's own refusal, naming the non-interactive escape hatch -- + # not a traceback, and not a generic fail-closed catch-all. + assert ENV_STORE_PASSWORD in result.stderr + assert "Traceback" not in result.stderr + assert not marker_file.exists(), "the child ran: stdin was consumed as a password" + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(result.stdout, marker, what="a credential") + assert_no_leak(result.stderr, marker, what="a credential") + + # Positive control: the same detached process, with the password supplied + # the way it is supposed to be, does run the child. Without this, the + # `not marker_file.exists()` assertion above would also be satisfied by a + # setup that could never have produced the file at all. + control = _run_detached( + argv, {**env, ENV_STORE_PASSWORD: PASSWORD}, stdin="", cwd=tmp_path + ) + assert control.returncode == 0 + assert marker_file.exists() + + +def test_exec_reports_an_unexpected_exception_via_fail_closed( + cli_store, monkeypatch, stub, capsys +): + """Proves `@fail_closed` is actually wired on `cmd_exec`: an + unanticipated exception must exit 2 with a type-only message, never a + traceback and never Python's own default exit code of 1.""" + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + + def boom(*a, **k): + raise RuntimeError("unanticipated") + + monkeypatch.setattr(session, "_compose_backend_url", boom) + code = main(["exec", "--profile", "staging", "--", str(stub)]) + assert code == 2 + err = capsys.readouterr().err + assert "RuntimeError" in err + assert "unanticipated" not in err + + +def test_exec_uses_the_wired_repo_profile_tier_end_to_end( + cli_store, monkeypatch, tmp_path, stub +): + seed_profile( + cli_store, "from_repo", backend_url="file:///from-repo-backend", credentials=FULL_CREDENTIALS + ) + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + (repo / ".stackward.toml").write_text('profile = "from_repo"\n') + monkeypatch.chdir(repo) + monkeypatch.delenv(store.ENV_PROFILE, raising=False) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + + code = main(["exec", "--", str(stub)]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == "file:///from-repo-backend" + + +def test_shell_runs_stub_as_the_shell_with_the_expected_environment_delta( + cli_store, monkeypatch, tmp_path, stub, capfd +): + """`shell` gets the same assertion block as `exec`, not a weaker one. + + This test previously checked two keys despite its name, which left + `shell`'s child environment almost entirely unconstrained: popping + `PULUMI_CONFIG_PASSPHRASE` out of it before `_run_child` passed the whole + suite, while the identical pop in `cmd_exec` failed two tests. The two + commands make the same promise and now carry the same proof of it. + """ + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + parent_before = dict(os.environ) + + code = main(["shell", "--profile", "staging"]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert_expected_session_env( + child_env, parent_before, backend_url="file:///staging-backend" + ) + + captured = capfd.readouterr() + for marker in (*ALL_MARKERS, PASSWORD): + assert_no_leak(captured.out, marker, what="a credential") + assert_no_leak(captured.err, marker, what="a credential") + + +@pytest.mark.parametrize("command", ["exec", "shell"]) +def test_a_userinfo_bearing_backend_url_reaches_the_child_verbatim( + cli_store, monkeypatch, tmp_path, stub, command +): + """`PULUMI_BACKEND_URL` is delivered as written, never redacted. + + `_redact_url` is for *printing* a backend URL. Applying it on the + injection path instead would hand `pulumi` a URL with `@` where + the credentials belong, and the backend would simply not work. Nothing + caught that: every environment-dumping test used a `file://` URL, for + which `_redact_url` is the identity function, so + `env[PULUMI_BACKEND_URL] = _redact_url(backend_url)` passed the entire + suite -- in both `cmd_exec`'s helper and `cmd_login`. + + A `postgres://user:password@host/db` backend, which `pulumi login --help` + documents, is the case that tells the two apart. + """ + seed_profile( + cli_store, "staging", backend_url=USERINFO_BACKEND_URL, credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + argv = ["exec", "--profile", "staging", "--", str(stub)] + if command == "shell": + argv = ["shell", "--profile", "staging"] + + assert main(argv) == 0 + + child_env = json.loads(dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == USERINFO_BACKEND_URL + assert "" not in child_env[PULUMI_BACKEND_URL] + + +def test_shell_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub, capfd): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setenv("STUB_SIGNAL", str(int(signal.SIGTERM))) + + code = main(["shell", "--profile", "staging"]) + + assert code == 128 + signal.SIGTERM + captured = capfd.readouterr() + for marker in ALL_MARKERS: + assert marker not in captured.out + assert marker not in captured.err + + +def test_shell_backend_mismatch_refuses(cli_store, monkeypatch, tmp_path, stub, capsys): + seed_profile( + cli_store, "staging", backend_url="file:///staging-backend", credentials=FULL_CREDENTIALS + ) + monkeypatch.setenv("SHELL", str(stub)) + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + monkeypatch.setattr(session, "_current_backend", lambda: "file:///elsewhere") + + code = main(["shell", "--profile", "staging"]) + + assert code == 2 + err = capsys.readouterr().err + assert "file:///staging-backend" in err + assert "file:///elsewhere" in err + + +# --------------------------------------------------------------------------- +# `login` +# --------------------------------------------------------------------------- + + +def test_login_runs_pulumi_login_with_the_url_via_env_not_argv( + cli_store, monkeypatch, tmp_path, stub +): + """The URL reaches `pulumi login` as `PULUMI_BACKEND_URL`, never as a + positional argument -- some backend forms (`postgres://user:pass@host/db`) + can carry a plaintext password in the URL itself, and argv is visible in + `ps` for the life of the call.""" + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + argv_dump = tmp_path / "argv.json" + env_dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(env_dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + assert json.loads(argv_dump.read_text()) == ["login"] + child_env = json.loads(env_dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == "file:///staging-backend" + + +def test_login_never_opens_the_credentials_store(cli_store, monkeypatch, tmp_path, stub): + """A profile can be logged into before its credentials have ever been + set -- `store.py`'s own R3, exercised here: no `credentials` file exists + at all, and `login` must still succeed.""" + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + assert not (cli_store / store.CREDENTIALS_FILENAME).exists() + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + assert not (cli_store / store.CREDENTIALS_FILENAME).exists() + + +def test_login_composes_the_component_form(cli_store, monkeypatch, tmp_path, stub): + write_store_config( + cli_store, + '[profile.staging]\nbucket = "placeholder-bucket"\nregion = "placeholder-region"\n', + ) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == "s3://placeholder-bucket?region=placeholder-region" + + +def test_login_scrubs_the_store_password_from_pulumis_environment( + cli_store, monkeypatch, tmp_path, stub +): + """`login` never needs the store password -- it never opens the + credentials store -- but it must not hand one along to `pulumi` anyway, + for the same reason `exec`/`shell` scrub it.""" + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv(ENV_STORE_PASSWORD, PASSWORD) + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + child_env = json.loads(dump.read_text()) + assert ENV_STORE_PASSWORD not in child_env + assert PASSWORD not in child_env.values() + + +def test_login_keeps_the_ambient_aws_names_it_does_not_contradict( + cli_store, monkeypatch, tmp_path, stub +): + """The deliberate asymmetry, asserted rather than merely documented. + + `login` injects no credentials, so neither scrubbed name can conflict + with one there -- and a backend the caller reaches through their own + assumed role or shared-config profile is a working setup. Scrubbing in + `_environ_without_store_password` (shared with `login`) instead of in + `_child_env` would break it silently, and every other test in this file + would still pass. + """ + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(dump)) + fake_pulumi(monkeypatch, stub) + + assert main(["login", "--profile", "staging"]) == 0 + + child_env = json.loads(dump.read_text()) + assert child_env["AWS_SESSION_TOKEN"] == AMBIENT_SESSION_TOKEN + assert child_env["AWS_PROFILE"] == AMBIENT_AWS_PROFILE + + +def test_login_never_puts_a_userinfo_bearing_url_in_argv_or_output( + cli_store, monkeypatch, tmp_path, stub, capfd +): + """A `postgres://user:password@host/db` profile -- a form + `pulumi login --help` documents -- must never put its password where + `ps` or a printed message could show it. + + `capfd`, not `capsys`. This is the one leak test in this file guarding a + real secret-bearing URL, and it drives a real child process: `capsys` + replaces this interpreter's `sys.stdout`/`sys.stderr` objects and sees + nothing the child writes to file descriptors 1 and 2. Against `capsys` a + `print(url, file=sys.stderr)` is caught and an `os.write(2, url)` -- or + anything the child itself prints -- is not, which is the wrong half to be + blind to for a test whose whole subject is a real subprocess. This file's + own header states `capfd` as the convention for exactly this reason. + + The environment is dumped as well as argv, for two reasons: it proves the + URL was genuinely live in this run rather than the test passing because + nothing ever resolved (`assert_no_leak` against a run that never had the + secret proves nothing), and it pins the delivery mechanism -- via + `PULUMI_BACKEND_URL`, verbatim, never through `argv`. + """ + seed_profile(cli_store, "staging", backend_url=USERINFO_BACKEND_URL) + argv_dump = tmp_path / "argv.json" + env_dump = tmp_path / "env.json" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_ENV_TO", str(env_dump)) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 + argv = json.loads(argv_dump.read_text()) + assert argv == ["login"] + assert_no_leak(json.dumps(argv), USERINFO_PASSWORD, what="the backend password") + + # The secret really was in play: it reached the child, verbatim, by the + # one route that is not visible in `ps`. + child_env = json.loads(env_dump.read_text()) + assert child_env[PULUMI_BACKEND_URL] == USERINFO_BACKEND_URL + + captured = capfd.readouterr() + assert_no_leak(captured.out, USERINFO_PASSWORD, what="the backend password") + assert_no_leak(captured.err, USERINFO_PASSWORD, what="the backend password") + + +def test_login_reports_a_missing_pulumi_executable(cli_store, monkeypatch, capsys): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setattr(session.shutil, "which", lambda name: None) + + code = main(["login", "--profile", "staging"]) + + assert code == 2 + assert "pulumi" in capsys.readouterr().err + + +def test_login_reports_profile_resolution_failure(cli_store, capsys): + code = main(["login", "--profile", "nonexistent"]) + assert code == 2 + assert "nonexistent" in capsys.readouterr().err + + +def test_login_propagates_a_nonzero_exit_code(cli_store, monkeypatch, stub): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv("STUB_EXIT_CODE", "9") + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 9 + + +def test_login_propagates_a_signal_terminated_child(cli_store, monkeypatch, stub): + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setenv("STUB_SIGNAL", str(int(signal.SIGTERM))) + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 128 + signal.SIGTERM + + +def test_login_is_not_subject_to_the_backend_guard(cli_store, monkeypatch, tmp_path, stub): + """`login` is what *sets* the backend Pulumi is pointed at -- gating it on + matching that same state would make switching profiles impossible.""" + seed_profile(cli_store, "staging", backend_url="file:///staging-backend") + monkeypatch.setattr(session, "_current_backend", lambda: "file:///a-totally-different-place") + fake_pulumi(monkeypatch, stub) + + code = main(["login", "--profile", "staging"]) + + assert code == 0 diff --git a/tests/test_set_secrets.py b/tests/test_set_secrets.py new file mode 100644 index 0000000..027c987 --- /dev/null +++ b/tests/test_set_secrets.py @@ -0,0 +1,1510 @@ +"""Tests for `set-secrets`. + +Two things every test in this file is careful about, for the reasons +`test_session.py` states them: + +*No assertion depends on a credential value appearing in captured output.* +Every place a real value could plausibly leak — a successful publish, a +failed one, a drift mismatch, a `pulumi` failure that echoes something odd on +its own stdout/stderr — has a test that asserts the value's absence from +`capfd`-level output (real child process writes included, not only this +process's own `print` calls), never a test that merely checks the call +succeeded. + +*Several tests exist to fail against a plausible wrong implementation, not +merely to exercise a right one.* The stdin/argv test uses a real child +process that dumps its own `sys.argv` and `sys.stdin` to separate files, +rather than inspecting a mocked `subprocess.run` call, because asserting on +the `input=` keyword only proves the call was *constructed* with the value on +stdin, not that a real child process could actually read it there. The +timeout test puts the failing entry in the *middle* of three, because +checking only the exit code and the summary text would pass an +implementation that aborts on the first failure, provided that failure +happened to be the last entry. +""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path + +import pytest + +from stackward.cli import main +from stackward.commands import set_secrets +from stackward.commands.set_secrets import ( + DriftPair, + LocalSecretSource, + ProjectManifest, + SetSecretsError, + _config_path_for_name, + _parse_drift_pairs, + _parse_required, + _publish_entries, + parse_env_file, + parse_project_manifest, + resolve_source_files, + select_project, + substitute_stack, +) +from stackward.config import Config, find_repo_config, load_config + +from leakcheck import assert_no_leak + +# Placeholder values only -- see GC4 in the task brief. +# +# They read as noise on purpose. The leak assertions below go through +# `leakcheck.assert_no_leak`, which fails on *any* eight-character run of the +# value as well as on the whole string, so a marker sharing an eight-character +# run with something the command legitimately prints -- a config path, a +# logical name, a `tmp_path` component -- would fail on output that disclosed +# nothing. The previous spelling, `marker-db-password-...`, shared the run +# `password` with the config path `db.password` that every one of these tests +# prints, which is exactly that false positive; and a leak check that cries +# wolf is a leak check that gets deleted. +# +# No two of these share an eight-character run with each other either, so a +# test asserting one marker's absence cannot fire on another marker's +# legitimate presence. +MARKER_DB_PASSWORD = "qz7m4x-1f8b3d9k-6t2v5r0w" +MARKER_PUBLISHED = "hj9c5n-2p6y8s4g-7l1e3a0u" +MARKER_BOOTSTRAP = "wd3r7v-5k9z1q6h-8n4j2c0m" +# The value a `plaintext`-table entry carries. Spelled opaquely for the same +# reason as the markers above: the old literal shared the run `plaintext` with +# the `--plaintext` flag the same argv legitimately carries. +MARKER_PLAINTEXT = "np4b8k-2z6r1x9d-5c7h3j0t" + +# One per parameter case of the stdin/argv test below, so a leak under one +# invocation shape cannot be excused by a match against another shape's value. +# Opaque rather than descriptive for the reason above: a marker spelling out +# its own case (`case-plaintext-no-stack-...`) shares the run `plaintext` with +# the `--plaintext` flag that same argv legitimately carries. +ARGV_CASE_MARKERS = { + ("secret", "no-stack"): "b4k9w2-7t5m1x8r", + ("secret", "with-stack"): "c6p3z8-2h9v4n1s", + ("plaintext", "no-stack"): "d1r7y5-8j2c6q0f", + ("plaintext", "with-stack"): "e9s2u4-3g7l5b1k", +} + + +# --------------------------------------------------------------------------- +# A real `pulumi` stand-in, driven by environment variables -- never a mock +# of `subprocess.run`, so the stdin/argv test proves a real child process can +# read what this module sent it. +# --------------------------------------------------------------------------- + +_PULUMI_STUB = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, sys, time + + argv = sys.argv[1:] + + + def arg_after(flag): + if flag in argv: + i = argv.index(flag) + if i + 1 < len(argv): + return argv[i + 1] + return None + + + dump_argv_to = os.environ.get("STUB_DUMP_ARGV_TO") + if dump_argv_to: + with open(dump_argv_to, "a") as f: + f.write(json.dumps(argv) + "\\n") + + path = arg_after("--path") + + if "set" in argv: + data = sys.stdin.buffer.read() + dump_stdin_to = os.environ.get("STUB_DUMP_STDIN_TO") + if dump_stdin_to: + with open(dump_stdin_to, "ab") as f: + f.write(data + b"\\n") + slow_path = os.environ.get("STUB_SLOW_PATH") + if slow_path and path == slow_path: + time.sleep(float(os.environ.get("STUB_SLEEP_SECONDS", "5"))) + echo = os.environ.get("STUB_SET_ECHO") + if echo: + sys.stderr.write(echo) + sys.exit(int(os.environ.get("STUB_SET_EXIT_CODE", "0"))) + + if "get" in argv: + table = json.loads(os.environ.get("STUB_GET_VALUES", "{}")) + exit_code = int(os.environ.get("STUB_GET_EXIT_CODE", "0")) + if path in table: + # A real `pulumi config get` terminates its output with a + # trailing newline -- reproduced here on purpose, so a + # comparison that forgot to strip it would fire on every equal + # pair instead of staying silent. + sys.stdout.write(table[path] + "\\n") + sys.exit(exit_code) + + sys.exit(1) + """ +) + + +@pytest.fixture +def stub_pulumi(tmp_path) -> Path: + path = tmp_path / "pulumi-stub.py" + path.write_text(_PULUMI_STUB) + path.chmod(0o755) + return path + + +def fake_pulumi(monkeypatch, executable: Path | None) -> None: + """Make `shutil.which("pulumi")` resolve to `executable`, or to nothing + at all when `executable` is `None` -- the seam every end-to-end test in + this file uses in place of the real binary.""" + monkeypatch.setattr( + set_secrets.shutil, + "which", + lambda name: str(executable) if (executable and name == "pulumi") else None, + ) + + +@pytest.fixture(autouse=True) +def fast_timeout(monkeypatch): + """A short default timeout for every test in this file -- the real + default (`DEFAULT_TIMEOUT_SECONDS = 30.0`) would make the timeout test + below take thirty real seconds for no benefit.""" + monkeypatch.setattr(set_secrets, "DEFAULT_TIMEOUT_SECONDS", 0.3) + + +# --------------------------------------------------------------------------- +# A repo tree `find_repo_config` will search from -- mirrors +# `test_session.py`'s own `repo` fixture. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def repo(tmp_path) -> Path: + root = tmp_path / "repo" + (root / ".git").mkdir(parents=True) + return root + + +def write_repo_config(repo: Path, text: str) -> Path: + path = repo / ".stackward.toml" + path.write_text(text) + return path + + +def load_repo_config(repo: Path) -> Config: + path = find_repo_config(repo) + assert path is not None + return load_config(path) + + +def recorded_pulumi_calls(argv_dump: Path, *values: str) -> list[list[str]]: + """Every `pulumi` invocation the stub recorded -- having first asserted + that none of `values` appears in any argument of any of them. + + Global Constraint 6 ("values reach external commands on stdin, never + argv") used to be proved for exactly one invocation shape: one test, one + `secret` entry, no `--stack`. `--stack` is the production shape for any + real rotation, and `plaintext` goes through the same + `_pulumi_config_set` code path, so appending the value to argv under + either of those conditions passed the whole suite. Every test in this + file that dumps argv now reads it back through this function, so the + absence assertion cannot be forgotten by the next test that dumps argv + for some unrelated reason. + + `assert_no_leak`, not `value not in arg`: a truncated or re-encoded value + in `ps` output discloses the credential just as completely as the whole + string does. Checked against the joined argv as well as each argument + separately -- the join is what would catch a value split across two + arguments, which `ps` renders back as one line anyway. + """ + calls = [json.loads(line) for line in argv_dump.read_text().splitlines()] + assert calls, "no pulumi invocation was recorded" + for call in calls: + for value in values: + assert_no_leak(" ".join(call), value, what="the resolved value") + for arg in call: + assert_no_leak(arg, value, what="the resolved value") + return calls + + +# --------------------------------------------------------------------------- +# `parse_env_file` +# --------------------------------------------------------------------------- + + +def test_parse_env_file_ignores_blanks_and_comments(tmp_path): + path = tmp_path / ".env" + path.write_text("\n# a comment\n\nDB_PASSWORD=" + MARKER_DB_PASSWORD + "\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": MARKER_DB_PASSWORD} + + +def test_parse_env_file_strips_leading_export(tmp_path): + path = tmp_path / ".env" + path.write_text(f"export DB_PASSWORD={MARKER_DB_PASSWORD}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": MARKER_DB_PASSWORD} + + +@pytest.mark.parametrize("quote", ['"', "'"]) +def test_parse_env_file_strips_one_matched_pair_of_quotes(tmp_path, quote): + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={quote}{MARKER_DB_PASSWORD}{quote}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": MARKER_DB_PASSWORD} + + +def test_parse_env_file_only_strips_one_matched_pair(tmp_path): + """A doubly-quoted value keeps its inner pair -- only *one* layer is + stripped, per the brief's own wording.""" + path = tmp_path / ".env" + path.write_text('DB_PASSWORD=""nested""\n') + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": '"nested"'} + + +def test_parse_env_file_keeps_a_hash_inside_a_value(tmp_path): + """`#` opens a comment only at the *start* of a line. + + No test anywhere wrote a value containing one, so truncating at an inline + `#` -- the behaviour several `.env` parsers actually have -- passed the + whole suite. A `#` is ordinary in a generated credential, and truncating + at it publishes a different, shorter value that looks entirely correct in + every log and every `pulumi config` listing: the failure only ever + surfaces as an authentication error somewhere else, later. + """ + path = tmp_path / ".env" + value = "p4x#z9q#mk2" + path.write_text(f"DB_PASSWORD={value}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": value} + + +def test_parse_env_file_splits_at_the_first_equals_only(tmp_path): + """A value may contain `=`, and everything after the first one is the + value. + + Also untested until now, and equally ordinary: base64 padding ends in + `=`, and a connection string or a query-shaped token carries them + throughout. Splitting at the last `=` instead would move part of the + value into the key, so the name would silently resolve to nothing and the + entry would be reported as a clean skip. + """ + path = tmp_path / ".env" + value = "p4x=z9q==" + path.write_text(f"DB_PASSWORD={value}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": value} + + +@pytest.mark.parametrize( + "value", + [ + '"p4xz9q', # an opening quote with nothing closing it + 'p4xz9q"', # a closing quote with nothing opening it + "\'p4xz9q\"", # two quote characters, but not a matched pair + "xp4xz9qx", # a matched pair -- of something that is not a quote + ], +) +def test_parse_env_file_strips_quotes_only_as_an_anchored_matched_pair(tmp_path, value): + """Both halves of the anchoring, each on its own: the two characters must + be *equal*, and they must be *quotes*. + + Dropping either half silently eats the first and last character of a + value that was never quoted -- which, for a credential, means publishing + something that is wrong by two characters and looks right everywhere. + Each case here fails under exactly one of the two weakenings and passes + under the correct rule. + """ + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={value}\n") + path.chmod(0o600) + assert parse_env_file(path) == {"DB_PASSWORD": value} + + +def test_parse_env_file_warns_on_a_permissive_mode(tmp_path, capfd): + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + path.chmod(0o644) + parse_env_file(path) + err = capfd.readouterr().err + assert str(path) in err + assert "0644" in err or "644" in err + assert_no_leak(err, MARKER_DB_PASSWORD, what="the file's own value") + + +def test_parse_env_file_no_warning_at_exactly_0600(tmp_path, capfd): + path = tmp_path / ".env" + path.write_text(f"DB_PASSWORD={MARKER_DB_PASSWORD}\n") + path.chmod(0o600) + parse_env_file(path) + assert capfd.readouterr().err == "" + + +def test_parse_env_file_malformed_line_raises(tmp_path): + path = tmp_path / ".env" + path.write_text("this is not key=value... wait\nDB_PASSWORD\n") + path.chmod(0o600) + with pytest.raises(SetSecretsError, match=r"\.env:2"): + parse_env_file(path) + + +def test_parse_env_file_unreadable_path_raises(tmp_path): + with pytest.raises(SetSecretsError): + parse_env_file(tmp_path / "does-not-exist.env") + + +# --------------------------------------------------------------------------- +# `substitute_stack` +# --------------------------------------------------------------------------- + + +def test_substitute_stack_passes_through_a_filename_with_no_placeholder(): + assert substitute_stack(".env", None) == ".env" + assert substitute_stack(".env", "prod") == ".env" + + +def test_substitute_stack_replaces_the_placeholder(): + assert substitute_stack(".env.{stack}.local", "prod") == ".env.prod.local" + + +def test_substitute_stack_raises_when_no_stack_was_given(): + with pytest.raises(SetSecretsError, match=r"\.env\.\{stack\}"): + substitute_stack(".env.{stack}", None) + + +# --------------------------------------------------------------------------- +# `resolve_source_files` +# --------------------------------------------------------------------------- + + +def test_resolve_source_files_absent_source_table_is_an_empty_list(tmp_path): + assert resolve_source_files(Config(secrets={}), tmp_path, None) == [] + + +def test_resolve_source_files_absent_files_key_is_an_empty_list(tmp_path): + config = Config(secrets={"source": {}}) + assert resolve_source_files(config, tmp_path, None) == [] + + +def test_resolve_source_files_rejects_a_non_list_files_value(tmp_path): + config = Config(secrets={"source": {"files": "not-a-list"}}) + with pytest.raises(SetSecretsError): + resolve_source_files(config, tmp_path, None) + + +def test_resolve_source_files_rejects_a_files_list_of_non_strings(tmp_path): + config = Config(secrets={"source": {"files": [1, 2]}}) + with pytest.raises(SetSecretsError): + resolve_source_files(config, tmp_path, None) + + +def test_resolve_source_files_resolves_a_relative_entry_against_repo_root(tmp_path): + config = Config(secrets={"source": {"files": [".env"]}}) + assert resolve_source_files(config, tmp_path, None) == [tmp_path / ".env"] + + +def test_resolve_source_files_leaves_an_absolute_entry_unchanged(tmp_path): + absolute = tmp_path / "elsewhere" / ".env" + config = Config(secrets={"source": {"files": [str(absolute)]}}) + # A repo root that is NOT an ancestor of `absolute` -- proves the + # absolute entry is not joined onto it at all, not merely that the join + # happens to produce the same path by coincidence. + unrelated_root = tmp_path / "a-different-repo-root" + assert resolve_source_files(config, unrelated_root, None) == [absolute] + + +def test_resolve_source_files_substitutes_stack_per_entry(tmp_path): + config = Config(secrets={"source": {"files": [".env.{stack}.local"]}}) + resolved = resolve_source_files(config, tmp_path, "placeholder-stack") + assert resolved == [tmp_path / ".env.placeholder-stack.local"] + + +# --------------------------------------------------------------------------- +# `_parse_required` -- the one canonical `[required]` shape +# --------------------------------------------------------------------------- + + +def test_parse_required_accepts_the_canonical_paths_table(): + assert _parse_required({"paths": ["a.b", "c.d"]}, "x") == frozenset({"a.b", "c.d"}) + + +def test_parse_required_absent_is_empty(): + assert _parse_required(None, "x") == frozenset() + + +def test_parse_required_rejects_a_bare_list(): + """The shape this module used to also accept (`required = [...]`) is now + a hard error, not a silently-tolerated alternate spelling.""" + with pytest.raises(SetSecretsError, match="x"): + _parse_required(["a.b", "c.d"], "x") + + +def test_parse_required_rejects_a_table_whose_own_keys_are_the_paths(): + """The other shape this module used to also accept + (`[secrets."".required]` with the paths as the table's own keys) is + now a hard error too -- 'paths' is the only recognised key.""" + with pytest.raises(SetSecretsError, match="a.b"): + _parse_required({"a.b": True, "c.d": "anything"}, "x") + + +def test_parse_required_rejects_an_unknown_key_alongside_paths(): + with pytest.raises(SetSecretsError, match="extra"): + _parse_required({"paths": ["a.b"], "extra": "oops"}, "x") + + +def test_parse_required_rejects_a_paths_list_of_non_strings(): + with pytest.raises(SetSecretsError): + _parse_required({"paths": [1, 2]}, "x") + + +def test_parse_required_rejects_a_non_table(): + with pytest.raises(SetSecretsError): + _parse_required("a.b", "x") + + +# --------------------------------------------------------------------------- +# `_parse_drift_pairs` +# --------------------------------------------------------------------------- + + +def test_parse_drift_pairs_builds_pairs(): + raw = [{"bootstrap": "BOOT", "managed": "MANAGED"}] + assert _parse_drift_pairs(raw, "x") == (DriftPair(bootstrap="BOOT", managed="MANAGED"),) + + +def test_parse_drift_pairs_absent_is_empty(): + assert _parse_drift_pairs(None, "x") == () + + +def test_parse_drift_pairs_rejects_a_missing_field(): + with pytest.raises(SetSecretsError): + _parse_drift_pairs([{"bootstrap": "BOOT"}], "x") + + +def test_parse_drift_pairs_rejects_a_non_list(): + with pytest.raises(SetSecretsError): + _parse_drift_pairs({"bootstrap": "BOOT", "managed": "MANAGED"}, "x") + + +# --------------------------------------------------------------------------- +# `parse_project_manifest` +# --------------------------------------------------------------------------- + + +def test_parse_project_manifest_builds_the_secret_table(): + raw = {"secret": {"db.password": "DB_PASSWORD"}} + manifest = parse_project_manifest(raw, ".") + assert manifest.secret == {"db.password": "DB_PASSWORD"} + assert manifest.plaintext == {} + assert manifest.required == frozenset() + assert manifest.drift_pairs == () + + +def test_parse_project_manifest_builds_the_plaintext_table(): + raw = {"plaintext": {"registry.user": "REGISTRY_USER"}} + manifest = parse_project_manifest(raw, ".") + assert manifest.plaintext == {"registry.user": "REGISTRY_USER"} + + +def test_parse_project_manifest_rejects_a_malformed_config_path_in_secret(): + """`paths.parse` is the one grammar every config path in the manifest is + validated with -- a key using invalid path syntax must fail here, at load + time, rather than surfacing later as a `pulumi` invocation error.""" + raw = {"secret": {"a[": "SOME_NAME"}} + with pytest.raises(SetSecretsError): + parse_project_manifest(raw, ".") + + +def test_parse_project_manifest_rejects_a_malformed_config_path_in_plaintext(): + """`plaintext` is published too now, so its keys are validated exactly + like `secret`'s.""" + raw = {"plaintext": {"a[": "SOME_NAME"}} + with pytest.raises(SetSecretsError): + parse_project_manifest(raw, ".") + + +def test_parse_project_manifest_does_not_validate_unmanaged_keys(): + """`unmanaged` may legitimately hold a wildcard shape that is not a + `--path` at all -- see the module docstring -- so it must never be run + through `paths.parse`, unlike `secret` and `plaintext`.""" + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "unmanaged": {"legacy.*": "reason, not a logical name"}, + } + manifest = parse_project_manifest(raw, ".") + assert manifest.secret == {"db.password": "DB_PASSWORD"} + + +def test_parse_project_manifest_required_path_not_in_secret_or_plaintext_raises(): + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "required": {"paths": ["nonexistent.path"]}, + } + with pytest.raises(SetSecretsError, match="nonexistent.path"): + parse_project_manifest(raw, ".") + + +def test_parse_project_manifest_required_path_in_secret_is_accepted(): + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "required": {"paths": ["db.password"]}, + } + manifest = parse_project_manifest(raw, ".") + assert manifest.required == frozenset({"db.password"}) + + +def test_parse_project_manifest_required_path_in_plaintext_is_accepted(): + """`required` now ranges over both publishable tables, since both are + actually published.""" + raw = { + "plaintext": {"registry.user": "REGISTRY_USER"}, + "required": {"paths": ["registry.user"]}, + } + manifest = parse_project_manifest(raw, ".") + assert manifest.required == frozenset({"registry.user"}) + + +def test_parse_project_manifest_rejects_a_path_declared_in_both_secret_and_plaintext(): + """The split between `secret` and `plaintext` *is* the declaration of + whether a value is a credential -- a path in both tables is a + contradiction in that declaration, and must be refused rather than + silently resolved toward the more dangerous (plaintext) reading.""" + raw = { + "secret": {"db.password": "DB_PASSWORD"}, + "plaintext": {"db.password": "DB_PASSWORD_AGAIN"}, + } + with pytest.raises(SetSecretsError, match="db.password"): + parse_project_manifest(raw, ".") + + +def test_parse_project_manifest_secret_value_must_be_a_string(): + with pytest.raises(SetSecretsError): + parse_project_manifest({"secret": {"db.password": 123}}, ".") + + +# --------------------------------------------------------------------------- +# `select_project` +# --------------------------------------------------------------------------- + + +def test_select_project_uses_the_single_declared_table_regardless_of_cwd(repo): + write_repo_config( + repo, + '[secrets.source]\nfiles = []\n\n[secrets."."]\nsecret = { "a.b" = "NAME" }\n', + ) + config = load_repo_config(repo) + unrelated = repo / "not-a-declared-project" + unrelated.mkdir() + assert select_project(config, repo, unrelated) == "." + + +def test_select_project_multi_table_selects_by_cwd(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "a.b" = "NAME" }\n\n' + '[secrets."infra/foo"]\nsecret = { "c.d" = "OTHER" }\n', + ) + config = load_repo_config(repo) + subdir = repo / "infra" / "foo" + subdir.mkdir(parents=True) + assert select_project(config, repo, subdir) == "infra/foo" + assert select_project(config, repo, repo) == "." + + +def test_select_project_multi_table_no_match_raises_naming_candidates(repo): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "a.b" = "NAME" }\n\n' + '[secrets."infra/foo"]\nsecret = { "c.d" = "OTHER" }\n', + ) + config = load_repo_config(repo) + elsewhere = repo / "elsewhere" + elsewhere.mkdir() + with pytest.raises(SetSecretsError, match="infra/foo"): + select_project(config, repo, elsewhere) + + +def test_select_project_with_no_declared_projects_raises(): + config = Config(secrets={"source": {"files": []}}) + with pytest.raises(SetSecretsError): + select_project(config, Path("/tmp"), Path("/tmp")) + + +# --------------------------------------------------------------------------- +# `LocalSecretSource` -- precedence +# --------------------------------------------------------------------------- + + +def test_resolves_a_name_present_in_only_one_file(): + source = LocalSecretSource({}, [{"NAME": "from-file-1"}]) + assert source.resolve("NAME") == "from-file-1" + + +def test_later_file_overrides_an_earlier_one(): + source = LocalSecretSource( + {}, [{"NAME": "from-file-1"}, {"NAME": "from-file-2"}] + ) + assert source.resolve("NAME") == "from-file-2" + + +def test_environment_wins_over_every_file(): + source = LocalSecretSource( + {"NAME": "from-env"}, [{"NAME": "from-file-1"}, {"NAME": "from-file-2"}] + ) + assert source.resolve("NAME") == "from-env" + + +def test_full_precedence_chain_across_env_and_two_files(): + """One test, three sources, three distinct names -- proving each tier + actually wins where it should, not merely that the highest tier beats a + single lower one.""" + source = LocalSecretSource( + {"ONLY_ENV": "env-value", "IN_ALL_THREE": "env-wins"}, + [ + {"ONLY_FILE_1": "file-1-value", "IN_ALL_THREE": "file-1-loses", "IN_BOTH_FILES": "file-1-loses"}, + {"IN_ALL_THREE": "file-2-loses", "IN_BOTH_FILES": "file-2-wins"}, + ], + ) + assert source.resolve("ONLY_ENV") == "env-value" + assert source.resolve("ONLY_FILE_1") == "file-1-value" + assert source.resolve("IN_BOTH_FILES") == "file-2-wins" + assert source.resolve("IN_ALL_THREE") == "env-wins" + + +def test_unresolvable_name_returns_none(): + source = LocalSecretSource({}, [{"OTHER": "value"}]) + assert source.resolve("NAME") is None + + +# --------------------------------------------------------------------------- +# `_config_path_for_name` +# --------------------------------------------------------------------------- + + +def test_config_path_for_name_finds_the_one_match(): + assert _config_path_for_name({"a.b": "NAME"}, "NAME") == "a.b" + + +def test_config_path_for_name_is_none_when_absent(): + assert _config_path_for_name({"a.b": "OTHER"}, "NAME") is None + + +def test_config_path_for_name_is_none_when_ambiguous(): + assert _config_path_for_name({"a.b": "NAME", "c.d": "NAME"}, "NAME") is None + + +# --------------------------------------------------------------------------- +# `_publish_entries` -- skip-if-unset, without touching `pulumi` at all +# --------------------------------------------------------------------------- + + +def test_unset_entry_is_skipped_and_pulumi_is_never_invoked(monkeypatch): + manifest = ProjectManifest(secret={"a.b": "UNSET_NAME"}) + source = LocalSecretSource({}, []) + + def poison(*args, **kwargs): + raise AssertionError("pulumi must not be invoked for an unresolved entry") + + monkeypatch.setattr(set_secrets, "_pulumi_config_set", poison) + outcomes = _publish_entries( + manifest, source, pulumi="/usr/bin/pulumi", stack=None, dry_run=False, timeout=1.0 + ) + assert len(outcomes) == 1 + assert outcomes[0].status == "skipped" + + +def test_empty_string_value_is_treated_as_unset(monkeypatch): + """An environment variable exported as `""` must not be published -- that + would clear whatever secret is already there, the destructive surprise + the brief calls out by name.""" + manifest = ProjectManifest(secret={"a.b": "EMPTY_NAME"}) + source = LocalSecretSource({"EMPTY_NAME": ""}, []) + + def poison(*args, **kwargs): + raise AssertionError("pulumi must not be invoked for an empty value") + + monkeypatch.setattr(set_secrets, "_pulumi_config_set", poison) + outcomes = _publish_entries( + manifest, source, pulumi="/usr/bin/pulumi", stack=None, dry_run=False, timeout=1.0 + ) + assert outcomes[0].status == "skipped" + + +def test_required_and_unset_is_a_failure_not_a_skip(): + manifest = ProjectManifest(secret={"a.b": "NAME"}, required=frozenset({"a.b"})) + source = LocalSecretSource({}, []) + outcomes = _publish_entries( + manifest, source, pulumi=None, stack=None, dry_run=False, timeout=1.0 + ) + assert outcomes[0].status == "failed" + assert "required" in outcomes[0].reason + + +# --------------------------------------------------------------------------- +# End-to-end via `main()`, with a real `pulumi` stand-in. +# --------------------------------------------------------------------------- + + +def test_no_repo_config_is_a_clean_failure(repo, monkeypatch, capfd): + monkeypatch.chdir(repo) + assert main(["set-secrets"]) == 2 + err = capfd.readouterr().err + assert "error:" in err + assert "Traceback" not in err + + +def test_source_files_are_read_from_disk_through_the_full_manifest_pipeline( + repo, monkeypatch, stub_pulumi, tmp_path +): + """`resolve_source_files` is real logic -- default-to-empty, type + validation, `{stack}` substitution per entry, repo-root-relative + resolution -- and this is the one test that exercises it through the + actual manifest-to-disk path, rather than through hand-built + `LocalSecretSource` dicts (as `test_full_precedence_chain_across_env_and_ + two_files` does for the resolver itself). One test closes four gaps at + once: two *real* files on disk where the second overrides a name from + the first; `--stack` supplied so `{stack}` substitution is exercised as + it is actually wired, not just as a standalone function call; a third + declared file that is genuinely absent from disk, proving the + silent-skip ruling end-to-end; and the result is read off a real child + process's stdin, not this module's own belief about what it resolved. + """ + write_repo_config( + repo, + '[secrets.source]\n' + 'files = [".env.base", ".env.{stack}.local", ".env.absent"]\n\n' + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n', + ) + (repo / ".env.base").write_text("DB_PASSWORD=from-base-file\n") + (repo / ".env.base").chmod(0o600) + (repo / ".env.placeholder-stack.local").write_text("DB_PASSWORD=from-stack-local-file\n") + (repo / ".env.placeholder-stack.local").chmod(0o600) + # ".env.absent" is deliberately never created. + + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets", "--stack", "placeholder-stack"]) == 0 + + stdin_content = stdin_dump.read_bytes() + assert b"from-stack-local-file" in stdin_content + assert b"from-base-file" not in stdin_content + + +def test_an_absolute_source_file_path_is_read_regardless_of_repo_root( + repo, monkeypatch, stub_pulumi, tmp_path +): + outside = tmp_path / "outside-the-repo" / ".env" + outside.parent.mkdir(parents=True) + outside.write_text("DB_PASSWORD=from-an-absolute-path\n") + outside.chmod(0o600) + + write_repo_config( + repo, + f'[secrets.source]\nfiles = ["{outside}"]\n\n' + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets"]) == 0 + assert b"from-an-absolute-path" in stdin_dump.read_bytes() + + +def test_dry_run_never_invokes_a_subprocess(repo, monkeypatch, capfd): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + + def poison(*args, **kwargs): + raise AssertionError("--dry-run must never invoke a subprocess") + + monkeypatch.setattr(set_secrets.subprocess, "run", poison) + assert main(["set-secrets", "--dry-run"]) == 0 + out = capfd.readouterr().out + assert "would set" in out + assert "db.password" in out + assert_no_leak(out, MARKER_DB_PASSWORD, what="the resolved value") + + +def test_dry_run_reports_a_required_unresolved_path_as_a_failure(repo, monkeypatch, capfd): + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.".".required]\n' + 'paths = ["db.password"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + assert main(["set-secrets", "--dry-run"]) == 2 + err = capfd.readouterr().err + assert "db.password" in err + + +@pytest.mark.parametrize("table", ["secret", "plaintext"]) +@pytest.mark.parametrize( + ("stack_args", "stack_label"), + [([], "no-stack"), (["--stack", "placeholder-stack"], "with-stack")], + ids=["no-stack", "with-stack"], +) +def test_a_value_reaches_pulumi_on_stdin_and_never_in_argv( + repo, monkeypatch, capfd, stub_pulumi, tmp_path, table, stack_args, stack_label +): + """Global Constraint 6, over every shape this command actually produces. + + This was one test: one `secret` entry, no `--stack`. Both of the + conditions it did not cover are the ordinary ones -- `--stack` is how any + real rotation is invoked, and `plaintext` entries go through the same + `_pulumi_config_set` call with a different flag -- and appending the + value to argv under either of them passed the entire suite. `--stack` in + particular is the more dangerous shape: it is the branch that *already* + appends to `args`, so a value appended one line later reads as part of + the same edit. + + Each case carries its own marker, so a leak under one parameter cannot be + excused by a match against another parameter's value; and the marker is + resolved from the environment, so what lands on stdin is the same string + the assertion is made about. + """ + value = ARGV_CASE_MARKERS[table, stack_label] + write_repo_config( + repo, + f'[secrets."."]\n{table} = {{ "managed.value" = "VALUE_NAME" }}\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("VALUE_NAME", value) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets", *stack_args]) == 0 + + calls = recorded_pulumi_calls(argv_dump, value) + assert len(calls) == 1 + call_argv = calls[0] + assert f"--{table}" in call_argv + assert "--path" in call_argv and "managed.value" in call_argv + assert ("--stack" in call_argv) is bool(stack_args) + + # The other half of the same claim: it really did reach the child, on + # stdin, where `ps` cannot see it. Absent from argv alone would also be + # satisfied by never sending it at all. + assert value.encode() in stdin_dump.read_bytes() + + captured = capfd.readouterr() + assert_no_leak(captured.out, value, what="the resolved value") + assert_no_leak(captured.err, value, what="the resolved value") + + +def test_a_plaintext_entry_is_published_with_plaintext_and_not_secret( + repo, monkeypatch, capfd, stub_pulumi, tmp_path +): + """`plaintext` entries are published too (Ruling 1) -- with + `--plaintext`, never `--secret`. Still delivered on stdin: one code path + for both tables, per the ruling.""" + write_repo_config( + repo, + '[secrets."."]\nplaintext = { "registry.user" = "REGISTRY_USER" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("REGISTRY_USER", MARKER_PLAINTEXT) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + + assert main(["set-secrets"]) == 0 + + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_PLAINTEXT) + assert "--plaintext" in call_argv + assert "--secret" not in call_argv + assert "--path" in call_argv and "registry.user" in call_argv + + assert MARKER_PLAINTEXT.encode() in stdin_dump.read_bytes() + + +def test_a_secret_entry_is_never_published_with_plaintext( + repo, monkeypatch, stub_pulumi, tmp_path +): + """The converse of the test above: a `secret`-table entry always carries + `--secret` and never `--plaintext`, even now that both tables are + published through the same `_pulumi_config_set` code path.""" + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets"]) == 0 + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD) + assert "--secret" in call_argv + assert "--plaintext" not in call_argv + + +def test_secret_and_plaintext_entries_are_both_published_in_one_run( + repo, monkeypatch, stub_pulumi, tmp_path +): + """Both tables are live at once, each with its own flag -- not merely + that either works in isolation.""" + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'plaintext = { "registry.user" = "REGISTRY_USER" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + monkeypatch.setenv("REGISTRY_USER", MARKER_PLAINTEXT) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets"]) == 0 + calls = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD, MARKER_PLAINTEXT) + assert len(calls) == 2 + + secret_call = next(c for c in calls if "db.password" in c) + plaintext_call = next(c for c in calls if "registry.user" in c) + assert "--secret" in secret_call and "--plaintext" not in secret_call + assert "--plaintext" in plaintext_call and "--secret" not in plaintext_call + + +def test_stack_flag_is_forwarded_to_pulumi(repo, monkeypatch, capfd, stub_pulumi, tmp_path): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets", "--stack", "placeholder-stack"]) == 0 + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD) + assert "--stack" in call_argv + assert "placeholder-stack" in call_argv + + captured = capfd.readouterr() + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") + + +def test_stack_flag_omitted_when_not_given(repo, monkeypatch, capfd, stub_pulumi, tmp_path): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets"]) == 0 + (call_argv,) = recorded_pulumi_calls(argv_dump, MARKER_DB_PASSWORD) + assert "--stack" not in call_argv + + captured = capfd.readouterr() + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") + + +def test_required_unresolved_path_fails_the_run(repo, monkeypatch, stub_pulumi, capfd): + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n\n' + '[secrets.".".required]\n' + 'paths = ["db.password"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 2 + err = capfd.readouterr().err + assert "db.password" in err + + +def test_a_non_canonical_required_shape_is_rejected_not_silently_ignored( + repo, monkeypatch, capfd +): + """Ruling 2: `required = [...]` (the bare-array shape this module used to + also accept) is now a hard load-time error -- specifically *not* silently + read as "nothing required". + + The exit code alone cannot distinguish "rejected at load time" from "the + old shape was quietly tolerated": in this exact manifest the tolerated + reading would still end up treating 'db.password' as required (the list + happens to contain a valid path), still fail to resolve it, and still + exit 2 through the ordinary per-entry failure path -- printing a + "summary:" line on the way. A genuine load-time rejection never reaches + that loop at all, so *that* absence, not the exit code, is the property + this test actually has to prove -- the same trap the brief warns several + of this task's tests are prone to. + """ + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'required = ["db.password"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.delenv("DB_PASSWORD", raising=False) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary:" not in captured.out + assert "error:" in captured.err + assert "paths" in captured.err + + +def test_a_scalar_where_the_secret_table_belongs_is_rejected_not_read_as_empty( + repo, monkeypatch, capfd +): + """`secret = "DB_PASSWORD"` -- the shape a manifest author writes when + they forget the `path = name` mapping -- is a hard load-time refusal. + + Read instead as "this project declares no entries", it produces `0 set, + 0 skipped, 0 failed` and exit 0: a rotation that published nothing, + reported in the language of success. Exit 0 with no work done is the + worst of the three possible outcomes here, because nothing downstream has + any reason to look again. + + The `summary:` line's absence, not the exit code, is what proves the + refusal happened at load time rather than in the publishing loop. + """ + write_repo_config(repo, '[secrets."."]\nsecret = "DB_PASSWORD"\n') + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary:" not in captured.out + assert "must be a table" in captured.err + assert "secret" in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") + + +def test_the_summary_line_counts_every_outcome(repo, monkeypatch, capfd, stub_pulumi): + """One run with one of each outcome, and the exact counts asserted. + + The `summary:` line is the whole report for a run nobody reads closely, + and no test anywhere asserted its numbers -- hard-coding + `0 set, 0 skipped, 0 failed` passed all 781. Three entries in a fixed, + known declaration order: one that resolves, one that does not and is not + required, and one that does not and is. + """ + write_repo_config( + repo, + '[secrets."."]\n' + "secret = { " + '"a.set" = "NAME_SET", ' + '"b.skip" = "NAME_SKIP", ' + '"c.fail" = "NAME_FAIL" ' + "}\n\n" + '[secrets.".".required]\n' + 'paths = ["c.fail"]\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("NAME_SET", "a-value-that-resolves") + monkeypatch.delenv("NAME_SKIP", raising=False) + monkeypatch.delenv("NAME_FAIL", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary: 1 set, 1 skipped, 1 failed" in captured.out + + +def test_a_path_declared_in_both_secret_and_plaintext_is_rejected_not_downgraded( + repo, monkeypatch, capfd, stub_pulumi +): + """End-to-end version of the overlap check: without it, `_publish_entries` + would publish 'db.password' twice -- `--secret` first, `--plaintext` + last, with the last write winning -- leaving a value its author declared + a credential sitting unencrypted in Pulumi's config. This must fail + before either write happens, not merely end up correct by luck of write + order.""" + write_repo_config( + repo, + '[secrets."."]\n' + 'secret = { "db.password" = "DB_PASSWORD" }\n' + 'plaintext = { "db.password" = "DB_PASSWORD_AGAIN" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + monkeypatch.setenv("DB_PASSWORD_AGAIN", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "summary:" not in captured.out + assert "db.password" in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") + + +def test_a_missing_pulumi_binary_fails_that_entry_and_is_reported(repo, monkeypatch, capfd): + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, None) + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + assert "not found on PATH" in captured.err + assert "db.password" in captured.err + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") + + +def test_a_timeout_in_the_middle_does_not_abort_the_remaining_entries( + repo, monkeypatch, capfd, stub_pulumi, tmp_path +): + """Three entries, the timing-out one in the middle, in a fixed, known + declaration order -- proving the first and third entries both still get + set, not merely that the run finishes and reports *a* failure.""" + write_repo_config( + repo, + '[secrets."."]\n' + "secret = { " + '"a.first" = "NAME_FIRST", ' + '"b.slow" = "NAME_SLOW", ' + '"c.last" = "NAME_LAST" ' + "}\n", + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("NAME_FIRST", "value-first") + monkeypatch.setenv("NAME_SLOW", "value-slow") + monkeypatch.setenv("NAME_LAST", "value-last") + fake_pulumi(monkeypatch, stub_pulumi) + + stdin_dump = tmp_path / "stdin.log" + monkeypatch.setenv("STUB_DUMP_STDIN_TO", str(stdin_dump)) + monkeypatch.setenv("STUB_SLOW_PATH", "b.slow") + # fast_timeout sets DEFAULT_TIMEOUT_SECONDS to 0.3s; sleep well past it. + monkeypatch.setenv("STUB_SLEEP_SECONDS", "2") + + assert main(["set-secrets"]) == 2 + out, err = (lambda c: (c.out, c.err))(capfd.readouterr()) + assert "set 'a.first'" in out + assert "set 'c.last'" in out + assert "b.slow" in err + assert "timed out" in err + assert "not set:" in err and "b.slow" in err.split("not set:")[1] + + # The two entries either side of the slow one really did reach a real + # child process on stdin -- not merely that this module *believes* they + # were set. (The slow entry's own value also reaches the stub's stdin + # before it sleeps and is killed on timeout -- a real subprocess cannot + # un-receive what it was already sent, so that is not asserted here; what + # matters is that this module never treats that entry as having landed.) + stdin_lines = stdin_dump.read_bytes().splitlines() + assert b"value-first" in stdin_lines + assert b"value-last" in stdin_lines + + +def test_a_pulumi_failure_never_echoes_the_value_it_was_given( + repo, monkeypatch, capfd, stub_pulumi +): + """Even when `pulumi` itself misbehaves and writes something odd to its + own stderr, this module must not relay it -- it is not this module's to + vouch for as free of the value it just sent on stdin.""" + write_repo_config( + repo, + '[secrets."."]\nsecret = { "db.password" = "DB_PASSWORD" }\n', + ) + monkeypatch.chdir(repo) + monkeypatch.setenv("DB_PASSWORD", MARKER_DB_PASSWORD) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv("STUB_SET_EXIT_CODE", "1") + monkeypatch.setenv("STUB_SET_ECHO", f"pulumi choked on {MARKER_DB_PASSWORD}") + + assert main(["set-secrets"]) == 2 + captured = capfd.readouterr() + # `assert_no_leak`, not `not in`: the reason this module builds for a + # non-zero exit (`pulumi exited 1`) is printed by `_print_outcome`, and a + # reason quoting even the first eight characters of the value would pass a + # whole-string check while disclosing the credential. + assert_no_leak(captured.out, MARKER_DB_PASSWORD, what="the resolved value") + assert_no_leak(captured.err, MARKER_DB_PASSWORD, what="the resolved value") + + +# --------------------------------------------------------------------------- +# Drift detection +# --------------------------------------------------------------------------- + + +_DRIFT_MANIFEST = ( + '[secrets."."]\n' + 'secret = { "managed.path" = "MANAGED_NAME" }\n\n' + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME"\n' + 'managed = "MANAGED_NAME"\n' +) + +_AMBIGUOUS_DRIFT_MANIFEST = ( + '[secrets."."]\n' + "secret = { " + '"a.path" = "MANAGED_NAME", ' + '"b.path" = "MANAGED_NAME" ' + "}\n\n" + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME"\n' + 'managed = "MANAGED_NAME"\n' +) + +# `managed` lives in `plaintext`, not `secret` -- proves the inversion in +# `_check_drift` covers both publishable tables (Also-fix 3), not just +# `secret`. +_PLAINTEXT_MANAGED_DRIFT_MANIFEST = ( + '[secrets."."]\n' + 'plaintext = { "managed.path" = "MANAGED_NAME" }\n\n' + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME"\n' + 'managed = "MANAGED_NAME"\n' +) + +# Two drift pairs, each unambiguously resolvable -- regression coverage for +# the `published` variable-shadowing bug: a pre-loop `published` (the merged +# path->name dict) and the loop's own `published` (`_pulumi_config_get`'s +# `str | None` result) used to share one name, so the second and every later +# pair called `_config_path_for_name` with a leftover `str` instead of the +# dict and crashed with `AttributeError`. +_TWO_DRIFT_PAIRS_MANIFEST = ( + '[secrets."."]\n' + "secret = { " + '"managed.path.one" = "MANAGED_NAME_ONE", ' + '"managed.path.two" = "MANAGED_NAME_TWO" ' + "}\n\n" + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME_ONE"\n' + 'managed = "MANAGED_NAME_ONE"\n\n' + '[[secrets.".".drift_pairs]]\n' + 'bootstrap = "BOOTSTRAP_NAME_TWO"\n' + 'managed = "MANAGED_NAME_TWO"\n' +) + + +def test_drift_warns_when_the_values_differ(repo, monkeypatch, capfd, stub_pulumi): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "drift" in captured.err + assert "BOOTSTRAP_NAME" in captured.err + assert "MANAGED_NAME" in captured.err + assert "managed.path" in captured.err + # The drift warning names the two logical names and the config path, and + # must never quote either value -- not whole, and not the leading run of + # one, which a `not in` check would have let through. + for stream in (captured.err, captured.out): + assert_no_leak(stream, MARKER_PUBLISHED, what="the published value") + assert_no_leak(stream, MARKER_BOOTSTRAP, what="the bootstrap value") + + +def test_drift_is_silent_when_the_values_agree(repo, monkeypatch, capfd, stub_pulumi): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_PUBLISHED) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv("STUB_GET_VALUES", json.dumps({"managed.path": MARKER_PUBLISHED})) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "drift" not in captured.err + assert "warning" not in captured.err + + +def test_drift_comparison_removes_only_the_clis_own_trailing_newline( + repo, monkeypatch, capfd, stub_pulumi +): + """Also-fix 4: a published value that itself legitimately ends in a + newline must still compare equal to a local value that also does -- + `.removesuffix("\\n")` removes exactly the one trailing newline `pulumi + config get` itself appends, where `.rstrip("\\n")` would also eat the + value's own, firing a spurious warning over a difference that was never + real. The stub appends its own trailing newline on top of the value + (simulating the CLI), so this only passes if exactly one newline is + stripped, not every one.""" + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + value_ending_in_newline = "value-with-its-own-trailing-newline\n" + monkeypatch.setenv("MANAGED_NAME", value_ending_in_newline) + monkeypatch.setenv("BOOTSTRAP_NAME", value_ending_in_newline) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": value_ending_in_newline}) + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "drift" not in captured.err + assert "warning" not in captured.err + + +def test_drift_is_silent_when_the_published_value_is_unreadable( + repo, monkeypatch, capfd, stub_pulumi +): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv("STUB_GET_EXIT_CODE", "1") + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert captured.err == "" + + +def test_drift_warns_when_the_managed_name_is_ambiguous(repo, monkeypatch, capfd, stub_pulumi): + write_repo_config(repo, _AMBIGUOUS_DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "MANAGED_NAME" in captured.err + assert_no_leak(captured.err, MARKER_BOOTSTRAP, what="the bootstrap value") + + +def test_drift_finds_a_managed_name_declared_in_plaintext(repo, monkeypatch, capfd, stub_pulumi): + """Also-fix 3: `_check_drift` inverts over `secret ∪ plaintext`, since + both are published now (Ruling 1). Before the fix, this exact manifest + -- `managed` correctly and unambiguously declared, just under + `plaintext` -- would wrongly hit the "does not name exactly one declared + secret" branch instead of comparing the values at all.""" + write_repo_config(repo, _PLAINTEXT_MANAGED_DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PLAINTEXT) + monkeypatch.setenv("BOOTSTRAP_NAME", MARKER_BOOTSTRAP) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "does not name exactly one" not in captured.err + assert "drift" in captured.err + assert "managed.path" in captured.err + assert_no_leak(captured.err, MARKER_BOOTSTRAP, what="the bootstrap value") + + +def test_a_second_drift_pair_is_evaluated_and_the_run_reaches_its_summary( + repo, monkeypatch, capfd, stub_pulumi +): + """Regression test for the `published` variable-shadowing bug: a + pre-loop `published` (the merged `secret ∪ plaintext` dict) and the + loop's own `published` (`_pulumi_config_get`'s result) shared one name, + so every drift pair after the first called `_config_path_for_name` with + a leftover `str` and crashed with `AttributeError` -- which `fail_closed` + then turned into a bare exit 2 *after* every `pulumi config set` call had + already succeeded and been printed, with the closing `summary:` line + never reached at all. Two pairs, both unambiguously resolvable and both + genuinely differing (so both warnings must fire, not just "no crash"), + proves the second pair is actually evaluated rather than merely not + crashing on it by accident. + """ + write_repo_config(repo, _TWO_DRIFT_PAIRS_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME_ONE", "local-value-one") + monkeypatch.setenv("MANAGED_NAME_TWO", "local-value-two") + monkeypatch.setenv("BOOTSTRAP_NAME_ONE", "local-value-one") + monkeypatch.setenv("BOOTSTRAP_NAME_TWO", "local-value-two") + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", + json.dumps( + { + "managed.path.one": "published-value-one-differs", + "managed.path.two": "published-value-two-differs", + } + ), + ) + + assert main(["set-secrets"]) == 0 + captured = capfd.readouterr() + assert "AttributeError" not in captured.err + assert "MANAGED_NAME_ONE" in captured.err + assert "MANAGED_NAME_TWO" in captured.err + assert "summary:" in captured.out + + +def test_drift_is_never_checked_under_dry_run(repo, monkeypatch, capfd, stub_pulumi, tmp_path): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.setenv("BOOTSTRAP_NAME", "a-different-value") + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + argv_dump = tmp_path / "argv.log" + monkeypatch.setenv("STUB_DUMP_ARGV_TO", str(argv_dump)) + + assert main(["set-secrets", "--dry-run"]) == 0 + assert not argv_dump.exists() + captured = capfd.readouterr() + assert "drift" not in captured.err + + +def test_drift_is_silent_when_the_bootstrap_value_does_not_resolve( + repo, monkeypatch, capfd, stub_pulumi +): + write_repo_config(repo, _DRIFT_MANIFEST) + monkeypatch.chdir(repo) + monkeypatch.setenv("MANAGED_NAME", MARKER_PUBLISHED) + monkeypatch.delenv("BOOTSTRAP_NAME", raising=False) + fake_pulumi(monkeypatch, stub_pulumi) + monkeypatch.setenv( + "STUB_GET_VALUES", json.dumps({"managed.path": "a-different-published-value"}) + ) + + assert main(["set-secrets"]) == 0 + assert capfd.readouterr().err == "" diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..cdeb286 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,1085 @@ +"""Tests for the credential store: profiles, file handling, and the store API. + +Two things these tests are careful about. + +*They never assert on a credential value being present in output.* A test that +proved a value was printed would itself print it on failure. Where a value must +not appear, the assertion is on absence; where a value must round-trip, it is +compared to a constant, not displayed. + +*Several are written to fail against the plausible wrong implementation rather +than merely to exercise the right one.* `--profile` naming a missing profile +must not fall through to `default_profile`; rotation must re-seal an envelope +whose config table has been deleted; an atomic write that fails must leave both +the original file and the directory as they were. Each of those is a bug a +passing "happy path" suite would not notice. + +The Argon2id cost is lowered here by an autouse fixture — these tests are about +store semantics, not about how expensive a guess is, and the shipped parameters +are pinned separately in `tests/test_crypto.py`. +""" + +from __future__ import annotations + +import base64 +import json +import os +import pathlib +import stat +import tomllib + +import pytest + +from leakcheck import assert_no_leak +from stackward import crypto, store +from stackward.store import ( + CONFIG_MODE, + CREDENTIALS_MODE, + DIR_MODE, + ENV_PROFILE, + PasswordError, + Profile, + ProfileError, + StoreConfig, + StoreError, + atomic_write, + init_store, + load_store_config, + resolve_credentials, + rotate_password, + select_profile, + set_credentials, + store_profiles, +) + +PASSWORD = "the store password" +NEW_PASSWORD = "the rotated store password" +WRONG_PASSWORD = "not the store password" + +# Placeholder credential names and values. The names are arbitrary here on +# purpose: the store does not know which variables a backend needs, and naming +# real ones would put a vendor's vocabulary into a module that has none. +MARKER = "marker-credential-value-8f2c41" +CREDENTIALS = {"NAME_ONE": MARKER, "NAME_TWO": "second-placeholder-value"} + + +@pytest.fixture(autouse=True) +def cheap_kdf(monkeypatch): + """Lower the Argon2id cost for this module only. + + At the shipped parameters every seal costs about 60 ms, and this file + performs several hundred. The properties under test here — precedence, + atomicity, all-or-nothing rotation — do not depend on the cost, and + `test_crypto.py::test_the_shipped_cost_parameters_are_the_documented_ones` + is what stops this fixture hiding a production weakening. + """ + monkeypatch.setattr(crypto, "ARGON2ID_MEMORY_KIB", 8192) + monkeypatch.setattr(crypto, "ARGON2ID_ITERATIONS", 1) + monkeypatch.setattr(crypto, "ARGON2ID_LANES", 1) + + +@pytest.fixture +def store_directory(tmp_path): + """An isolated store directory. Every store function takes `directory`, so + no test depends on the real `XDG_CONFIG_HOME` or writes outside `tmp_path`.""" + return tmp_path / "stackward" + + +def write_config(store_directory, text: str): + store_directory.mkdir(parents=True, exist_ok=True) + path = store_directory / "config" + path.write_text(text) + # At the required mode, so that these tests do not trip the permission + # warning as a side effect of whatever umask the suite runs under. A + # literal rather than `CONFIG_MODE`: this helper is used by the test that + # pins the call site's constant, which cannot pin anything if the helper + # moves with it. + os.chmod(path, 0o600) + return path + + +def config_with(*names: str, default: str | None = None) -> str: + lines = [] if default is None else [f'default_profile = "{default}"', ""] + for name in names: + lines += [f"[profile.{name}]", 'backend_url = "file:///placeholder"', ""] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# `config`: backend identity +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "s3://placeholder-bucket/placeholder-prefix", + "gs://placeholder-bucket", + "azblob://placeholder-container", + "file:///placeholder/path", + "s3://placeholder/prefix?endpoint=placeholder.invalid®ion=placeholder", + "some-future-scheme://placeholder", + ], +) +def test_a_backend_url_is_carried_through_byte_for_byte(store_directory, url): + """Pass-through, not parsing. Any normalising or reassembling this module + did would show up here as a changed string, and would be the thing that + stopped an unanticipated scheme from working.""" + write_config(store_directory, f"[profile.one]\nbackend_url = {json.dumps(url)}\n") + assert load_store_config(store_directory).profiles["one"].backend_url == url + + +def test_the_component_form_is_carried_as_structured_data(store_directory): + write_config( + store_directory, + "[profile.one]\n" + 'bucket = "placeholder-bucket"\n' + 'prefix = "placeholder/prefix"\n' + 'endpoint = "https://placeholder.invalid"\n' + 'region = "placeholder-region"\n', + ) + profile = load_store_config(store_directory).profiles["one"] + assert profile.backend_url is None + assert profile.bucket == "placeholder-bucket" + assert profile.prefix == "placeholder/prefix" + assert profile.endpoint == "https://placeholder.invalid" + assert profile.region == "placeholder-region" + + +def test_a_profile_carrying_both_forms_is_refused(store_directory): + """Two answers to "which backend?" with no rule for choosing between them.""" + write_config( + store_directory, + '[profile.one]\nbackend_url = "file:///placeholder"\nbucket = "placeholder"\n', + ) + with pytest.raises(StoreError): + load_store_config(store_directory) + + +def test_a_profile_carrying_neither_form_is_refused(store_directory): + """There is no default backend to fall back on, so an empty profile is an + error and not an inherited one.""" + write_config(store_directory, "[profile.one]\n") + with pytest.raises(StoreError): + load_store_config(store_directory) + + +def test_the_component_form_without_a_bucket_is_refused(store_directory): + write_config(store_directory, '[profile.one]\nregion = "placeholder-region"\n') + with pytest.raises(StoreError): + load_store_config(store_directory) + + +def test_an_unknown_profile_key_is_refused_rather_than_ignored(store_directory): + write_config( + store_directory, + '[profile.one]\nbackend_url = "file:///placeholder"\nbucket_name = "typo"\n', + ) + with pytest.raises(StoreError): + load_store_config(store_directory) + + +def test_a_default_profile_with_no_table_is_refused(store_directory): + write_config(store_directory, 'default_profile = "missing"\n' + config_with("one")) + with pytest.raises(StoreError): + load_store_config(store_directory) + + +def test_an_absent_config_is_empty_rather_than_an_error(store_directory): + """Not having set the tool up yet is a normal state. It is `select_profile` + that reports it, because only it can say what to do about it.""" + assert load_store_config(store_directory) == StoreConfig() + + +def test_an_invalid_config_raises_rather_than_reading_as_absent(store_directory): + """A typo must not degrade into "no profiles", which would turn a broken + backend into a silently missing one.""" + write_config(store_directory, "[profile.one\nbackend_url = ") + with pytest.raises(StoreError): + load_store_config(store_directory) + + +def test_an_invalid_config_is_reported_by_position_and_never_by_quoting_it( + store_directory, +): + """`config` can hold a credential, so its parse error must not quote it. + + This call site used to interpolate `tomllib`'s own message in full, on + the stated grounds that "`config` holds backend identity and never a + credential". Both halves of that were wrong. A `backend_url` of the + documented `postgres://user:password@host/db` form is a credential in + `config`; and `tomllib` does read document text back -- most of its + messages are a fixed description plus a coordinate, but a duplicated + table declaration names the key, which is the shape written below + (`Cannot declare ('...',) twice`). + + The coordinate is asserted too: dropping the message entirely would also + satisfy a bare no-leak check, and the position is what makes the error + actionable. + """ + secret = "supersecret-marker-9f1e2d" + write_config( + store_directory, + f'[{secret}]\nbackend_url = "file:///placeholder"\n[{secret}]\n', + ) + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + + message = str(raised.value) + assert_no_leak(message, secret, what="a name written into `config`") + assert "invalid TOML" in message + assert "line 3" in message + + +def test_a_config_value_is_never_quoted_back_by_a_syntax_error(store_directory): + """The same rule for a credential in the *value* position -- the shape a + real `postgres://user:password@host/db` backend URL takes.""" + secret = "supersecret-marker-4b7c0a" + write_config( + store_directory, + f'[profile.one]\nbackend_url = "postgres://u:{secret}@h.invalid/db"\n[broken\n', + ) + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + assert_no_leak(str(raised.value), secret, what="a backend URL password") + + +def test_a_credential_at_the_end_of_the_document_is_not_quoted_back(store_directory): + """The other coordinate `tomllib` produces, `(at end of document)`. + + `toml_position` matches two message shapes and every other test here + exercises only the first, which would leave the second silently + unrecognised -- and an unrecognised shape yields no coordinate at all, + so the branch is worth a test of its own. An unterminated string is the + natural way to reach it, and is also the shape a half-pasted backend URL + actually takes. + """ + secret = "supersecret-marker-2e8d6c" + write_config(store_directory, f'backend_url = "postgres://u:{secret}@h.invalid/db') + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + + message = str(raised.value) + assert_no_leak(message, secret, what="a backend URL password") + assert "invalid TOML" in message + assert "end of document" in message + + +def test_a_toml_message_with_no_coordinate_loses_the_coordinate_not_the_secrecy( + store_directory, monkeypatch +): + """The fallback: an unrecognised message shape must degrade to no + position, never to quoting the message. + + `TOMLDecodeError`'s text is not an API and this project supports three + Python versions, so `toml_position` has to cope with a message it + cannot parse. The safe degradation is losing the diagnostic; the unsafe + one is falling back to the whole message, which is exactly what this + call site used to do unconditionally. + """ + secret = "supersecret-marker-5a3f1b" + write_config(store_directory, '[profile.one]\nbucket = "b"\n') + + def raise_unparseable(*_args, **_kwargs): + raise tomllib.TOMLDecodeError(f"a shape from some future release: {secret}") + + monkeypatch.setattr(tomllib, "load", raise_unparseable) + with pytest.raises(StoreError) as raised: + load_store_config(store_directory) + + message = str(raised.value) + assert_no_leak(message, secret, what="the parser's own message") + assert message.endswith("invalid TOML") + + +def test_an_unknown_top_level_key_is_refused_naming_it(store_directory): + """The profile-level analogue of this is tested; the top-level one was + not, so `_build_store_config`'s own refusal never ran at all. An + unrecognised top-level key must not be ignored: a misspelled + `default_profile` silently dropped would make the store fall through to + "no profile selected" with nothing pointing at the typo.""" + write_config( + store_directory, 'defualt_profile = "one"\n[profile.one]\nbucket = "b"\n' + ) + with pytest.raises(StoreError, match="defualt_profile"): + load_store_config(store_directory) + + +# --------------------------------------------------------------------------- +# Profile selection precedence +# --------------------------------------------------------------------------- + + +@pytest.fixture +def four_profiles(store_directory): + write_config( + store_directory, config_with("chosen", "from_env", "from_repo", "fallback") + ) + return load_store_config(store_directory) + + +def test_the_explicit_profile_wins_over_every_other_source(four_profiles): + selected = select_profile( + "chosen", + config=four_profiles, + repo_profile="from_repo", + environ={ENV_PROFILE: "from_env"}, + ) + assert selected.name == "chosen" + + +def test_the_environment_wins_when_no_explicit_profile_was_given(four_profiles): + selected = select_profile( + config=four_profiles, + repo_profile="from_repo", + environ={ENV_PROFILE: "from_env"}, + ) + assert selected.name == "from_env" + + +def test_the_repository_config_wins_over_the_default_profile(store_directory): + write_config(store_directory, config_with("from_repo", "fallback", default="fallback")) + selected = select_profile( + config=load_store_config(store_directory), repo_profile="from_repo", environ={} + ) + assert selected.name == "from_repo" + + +def test_the_default_profile_is_the_last_resort(store_directory): + write_config(store_directory, config_with("fallback", default="fallback")) + selected = select_profile(config=load_store_config(store_directory), environ={}) + assert selected.name == "fallback" + + +def test_no_profile_anywhere_is_an_error_and_never_a_guess(four_profiles): + with pytest.raises(ProfileError): + select_profile(config=four_profiles, environ={}) + + +def test_a_named_profile_that_does_not_exist_errors_instead_of_using_the_default( + store_directory, +): + """The case that separates "first source that *speaks* wins" from "first + source that *resolves* wins". Falling through here would run the command + against a backend nobody asked for — the worst thing this tool could do + quietly — and a loop that skipped invalid sources would pass every other + precedence test in this file. + """ + write_config(store_directory, config_with("fallback", default="fallback")) + config = load_store_config(store_directory) + for explicit, environ, repo_profile in ( + ("absent", {}, None), + (None, {ENV_PROFILE: "absent"}, None), + (None, {}, "absent"), + ): + with pytest.raises(ProfileError) as raised: + select_profile( + explicit, config=config, environ=environ, repo_profile=repo_profile + ) + assert "absent" in str(raised.value) + assert "fallback" not in str(raised.value) + + +def test_an_empty_environment_variable_is_an_error_rather_than_a_miss(store_directory): + """A variable set to the empty string is a broken variable, not an absent + one. Skipping it would silently substitute the default.""" + write_config(store_directory, config_with("fallback", default="fallback")) + with pytest.raises(ProfileError): + select_profile( + config=load_store_config(store_directory), environ={ENV_PROFILE: ""} + ) + + +def test_a_profile_name_that_would_collide_with_the_verifier_is_rejected(store_directory): + """The verifier's AAD is `stackward:verifier`. Excluding ':' from the name + charset is the mechanism that keeps the two AAD namespaces disjoint, so a + profile envelope can never be interchangeable with the verifier's.""" + with pytest.raises(ProfileError): + store.validate_profile_name("stackward:verifier", "test") + + +@pytest.mark.parametrize( + "name", + [ + "with space", + "-leading-dash", + "with/slash", + "with:colon", + # TOML reads a dot in a table header as nesting, so `[profile.with.dot]` + # declares a table inside a table rather than this profile. + "with.dot", + "", + "wîth-å", + ], +) +def test_an_unspellable_profile_name_is_rejected(name): + with pytest.raises(ProfileError): + store.validate_profile_name(name, "test") + + +# --------------------------------------------------------------------------- +# File handling +# --------------------------------------------------------------------------- + + +def test_the_store_directory_is_created_private_whatever_the_umask(store_directory): + """0o700 as a literal, deliberately -- never `DIR_MODE`. + + Asserting `== DIR_MODE` compares the module against itself: the + expectation moves with the constant, so `DIR_MODE = 0o777` produces a + world-writable store directory and this test still passes. The number is + the requirement, so the number is what is written here. + """ + previous = os.umask(0) + try: + store.ensure_store_dir(store_directory) + finally: + os.umask(previous) + assert stat.S_IMODE(store_directory.stat().st_mode) == 0o700 + + +def test_the_credentials_file_is_written_private_whatever_the_umask(store_directory): + """0o600 as a literal -- see the sibling test above on why not + `CREDENTIALS_MODE`. `CREDENTIALS_MODE = 0o606` leaves the credentials + file world-*writable* and passes an `== CREDENTIALS_MODE` assertion.""" + previous = os.umask(0) + try: + init_store(PASSWORD, directory=store_directory) + finally: + os.umask(previous) + mode = stat.S_IMODE((store_directory / "credentials").stat().st_mode) + assert mode == 0o600 + + +def test_the_config_file_mode_the_store_expects_is_owner_only(store_directory, capsys): + """The mode `config` is *held to*, asserted where the module actually + applies it rather than against the constant it applies. + + Nothing in this module writes `config` -- it is hand-edited -- so the + only place `CONFIG_MODE` has an effect is the permissiveness warning + `load_store_config` raises, and that is therefore where it has to be + pinned. A unit test of `warn_if_permissive(path, 0o600)` proves the + function; it says nothing about which constant the call site passes, + and `CONFIG_MODE = 0o644` leaves that unit test green. + + 0644 is the specific mode that matters: `config` can hold a credential + (a `backend_url` of the documented `postgres://user:password@host/db` + form), so a world-readable one has to be reported. + """ + write_config(store_directory, '[profile.one]\nbackend_url = "file:///placeholder"\n') + os.chmod(store_directory / "config", 0o644) + + load_store_config(store_directory) + + captured = capsys.readouterr() + assert "0644" in captured.err + assert str(store_directory / "config") in captured.err + assert captured.out == "" + + +@pytest.mark.parametrize("mode", [0o600, 0o644]) +def test_the_mode_is_set_before_the_rename_not_after(store_directory, monkeypatch, mode): + """Setting the mode after the rename leaves a window in which the finished + file is visible at its real path with whatever mode it was created with. + This records the mode of the temporary file at the moment of the rename. + + Two modes are checked because only one of them discriminates: `mkstemp` + already creates at 0600, so at that mode a `chmod` after the rename would + look identical and this test would prove nothing. 0644 is what makes the + ordering observable -- which is why it is written as a literal and not as + `CONFIG_MODE`, whose value is now also 0600. Parametrising over the + module's two constants would have quietly turned this into two copies of + the case that proves nothing the moment `CONFIG_MODE` changed. + """ + store_directory.mkdir(parents=True) + real_replace = os.replace + observed: list[int] = [] + + def spy(src, dst): + observed.append(stat.S_IMODE(os.stat(src).st_mode)) + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", spy) + atomic_write(store_directory / "credentials", b"content", mode) + assert observed == [mode] + + +def test_a_failed_write_leaves_the_original_file_intact(store_directory, monkeypatch): + target = store_directory / "credentials" + store_directory.mkdir(parents=True) + target.write_bytes(b"the original content") + + def boom(*_args, **_kwargs): + raise OSError("induced failure") + + monkeypatch.setattr(os, "fsync", boom) + with pytest.raises(OSError): + atomic_write(target, b"the replacement content", CREDENTIALS_MODE) + assert target.read_bytes() == b"the original content" + + +def test_a_failed_rename_leaves_the_original_file_intact(store_directory, monkeypatch): + target = store_directory / "credentials" + store_directory.mkdir(parents=True) + target.write_bytes(b"the original content") + + def boom(*_args, **_kwargs): + raise OSError("induced failure") + + monkeypatch.setattr(os, "replace", boom) + with pytest.raises(OSError): + atomic_write(target, b"the replacement content", CREDENTIALS_MODE) + assert target.read_bytes() == b"the original content" + + +@pytest.mark.parametrize("failing", ["fsync", "replace"]) +def test_a_failed_write_leaves_no_temporary_file_behind( + store_directory, monkeypatch, failing +): + """Debris in the store directory would be a sealed credentials file nobody + is tracking, kept at whatever mode `mkstemp` produced.""" + target = store_directory / "credentials" + store_directory.mkdir(parents=True) + target.write_bytes(b"the original content") + + def boom(*_args, **_kwargs): + raise OSError("induced failure") + + monkeypatch.setattr(os, failing, boom) + with pytest.raises(OSError): + atomic_write(target, b"the replacement content", CREDENTIALS_MODE) + assert [path.name for path in store_directory.iterdir()] == ["credentials"] + + +@pytest.mark.parametrize( + ("filename", "allowed", "mode", "expected"), + [ + ("credentials", 0o600, 0o600, False), + ("credentials", 0o600, 0o640, True), + ("credentials", 0o600, 0o644, True), + ("config", 0o600, 0o600, False), + ("config", 0o600, 0o644, True), + ("config", 0o600, 0o666, True), + # More restrictive than allowed is not "more permissive". + ("credentials", 0o644, 0o600, False), + ], +) +def test_a_more_permissive_mode_than_required_is_warned_about( + store_directory, capsys, filename, allowed, mode, expected +): + """`allowed` is a literal on both halves, deliberately. + + Passing `allowed=CREDENTIALS_MODE` made every row compare the module + against itself: widen the constant and the expectation widens with it, + so the parametrisation kept passing while describing a different, more + permissive policy than the one written down here. + """ + store_directory.mkdir(parents=True) + path = store_directory / filename + path.write_text("") + os.chmod(path, mode) + store.warn_if_permissive(path, allowed) + captured = capsys.readouterr() + assert (str(path) in captured.err) is expected + assert captured.out == "" + + +# --------------------------------------------------------------------------- +# Sealing and resolving +# --------------------------------------------------------------------------- + + +@pytest.fixture +def initialised(store_directory): + init_store(PASSWORD, directory=store_directory) + set_credentials("one", CREDENTIALS, PASSWORD, directory=store_directory) + return store_directory + + +def test_credentials_round_trip_through_the_store(initialised): + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS + + +def test_the_credentials_file_holds_none_of_the_credential_bytes(initialised): + """A store that stringified or base64-encoded its values instead of sealing + them would pass the round-trip test above and fail here.""" + raw = (initialised / "credentials").read_bytes() + assert MARKER.encode("ascii") not in raw + assert MARKER.encode("ascii").hex().encode("ascii") not in raw + assert base64.b64encode(MARKER.encode("ascii")) not in raw + assert PASSWORD.encode("ascii") not in raw + + +def test_a_wrong_password_is_reported_as_a_wrong_password(initialised): + """What the store-level verifier buys: the failure names the actual problem + instead of surfacing as an unopenable profile.""" + with pytest.raises(PasswordError): + resolve_credentials("one", WRONG_PASSWORD, directory=initialised) + + +def test_a_wrong_password_is_refused_before_any_profile_envelope_is_touched( + initialised, monkeypatch +): + """The verifier must be the first thing opened, or a mistyped password + would be indistinguishable from a damaged store.""" + set_credentials("two", CREDENTIALS, PASSWORD, directory=initialised) + with pytest.raises(PasswordError): + set_credentials("three", CREDENTIALS, WRONG_PASSWORD, directory=initialised) + assert store_profiles(initialised) == ["one", "two"] + + +def test_an_envelope_moved_between_profiles_does_not_open(initialised): + """The store-level consequence of the AAD binding: an attacker (or a + well-meaning hand edit) who copies one profile's envelope over another's + gets a refusal, not that profile's credentials under a different name.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["two"] = document["profiles"]["one"] + path.write_text(json.dumps(document)) + + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS + with pytest.raises(StoreError) as raised: + resolve_credentials("two", PASSWORD, directory=initialised) + assert not isinstance(raised.value, PasswordError) + assert MARKER not in str(raised.value) + + +def test_a_profile_with_no_envelope_is_an_error_not_an_empty_mapping(initialised): + """Fail closed. Returning `{}` would let a caller run a command with no + credentials in the environment and blame the backend for the result.""" + with pytest.raises(ProfileError): + resolve_credentials("absent", PASSWORD, directory=initialised) + + +def test_a_profile_in_config_without_credentials_selects_but_does_not_resolve( + store_directory, +): + """How the two files relate when a profile is in one and not the other. + `config` alone is enough to know which backend a profile names — which is + all `login` needs — so selection succeeds; resolving credentials that were + never sealed is an error naming the profile.""" + write_config(store_directory, config_with("one")) + init_store(PASSWORD, directory=store_directory) + + config = load_store_config(store_directory) + assert select_profile("one", config=config, environ={}).name == "one" + with pytest.raises(ProfileError) as raised: + resolve_credentials("one", PASSWORD, directory=store_directory) + assert "one" in str(raised.value) + + +def test_resolving_before_the_store_exists_says_so(store_directory): + with pytest.raises(StoreError): + resolve_credentials("one", PASSWORD, directory=store_directory) + + +def test_initialising_over_an_existing_store_is_refused(initialised): + """`init` twice is a plausible mistake; discarding every sealed envelope in + response to it is not a recoverable one.""" + with pytest.raises(StoreError): + init_store(PASSWORD, directory=initialised) + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS + + +@pytest.mark.parametrize( + "payload", + [ + {"NAME": 42}, # a value that is not a string + {"NAME": [MARKER]}, + [MARKER], # not a mapping at all + MARKER, + # A mapping built the wrong way round, so the credential is the *key*. + # This is what makes naming the offending entry unsafe: the assumption + # that keys are harmless variable names is not one this branch can make. + {MARKER: 42}, + ], +) +def test_a_malformed_decrypted_payload_is_refused_without_echoing_it( + initialised, payload +): + """The only branch in the module where decrypted values are in scope when an + exception is raised — every other failure happens before anything is opened, + so an absence assertion there is trivially true. This one is not: the + payload is sealed under the right password and the right AAD, so it decrypts + successfully and *then* fails validation. Includes a mapping whose key is + the credential, which is why the message names neither half of an entry. + """ + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["malformed"] = crypto.seal_json(payload, PASSWORD, "malformed") + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError) as raised: + resolve_credentials("malformed", PASSWORD, directory=initialised) + assert MARKER not in str(raised.value) + assert "malformed" in str(raised.value) + + +def test_a_damaged_store_file_is_refused_rather_than_read_as_empty(initialised): + (initialised / "credentials").write_text("{ not json") + with pytest.raises(StoreError): + resolve_credentials("one", PASSWORD, directory=initialised) + + +def test_an_unsupported_store_version_is_refused(initialised): + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["v"] = 99 + path.write_text(json.dumps(document)) + with pytest.raises(StoreError): + resolve_credentials("one", PASSWORD, directory=initialised) + + +def test_a_credential_pasted_into_the_store_version_is_not_read_back(initialised): + """`v` is the one hand-editable header field in the file that is + *expected* to hold secrets, and its contents are printed back. The test + above sets an integer, which `describe_value` shows on purpose, so it + could never see a version field that echoed a string. + + Opaque marker -- see `tests/leakcheck.py`: this message also prints the + store path, and a marker built from real words shares runs with it. + """ + pasted = "Yw3Kd8Qr5Vn2Tx7Bz4Ms9Gj6" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["v"] = pasted + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError) as raised: + resolve_credentials("one", PASSWORD, directory=initialised) + + message = str(raised.value) + assert_no_leak(message, pasted, what="a pasted store version") + assert "" in message + + +def test_a_store_with_no_verifier_is_refused_rather_than_opened(initialised): + """The verifier is what makes a mistyped password fail immediately + instead of weeks later, so a store that has none cannot be treated as + "no check to run" -- that would silently restore the failure mode the + verifier exists to remove. None of `_check_password`'s three refusals + had ever executed.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + del document[store.VERIFIER_KEY] + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError, match="verifier"): + resolve_credentials("one", PASSWORD, directory=initialised) + + +def test_an_unusable_verifier_envelope_is_a_store_error_not_a_wrong_password( + initialised, +): + """A structurally broken verifier envelope is damage, and must be + reported as damage: telling a user "wrong password" for a store their + password is fine for sends them to retype it forever.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document[store.VERIFIER_KEY] = {"v": 1, "kdf": "not an object"} + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError) as raised: + resolve_credentials("one", PASSWORD, directory=initialised) + assert not isinstance(raised.value, PasswordError) + assert "unusable" in str(raised.value) + + +def test_a_verifier_that_opens_to_the_wrong_plaintext_is_refused(initialised): + """The known-plaintext half of the check, which the GCM tag alone does + not provide: an envelope sealed under the right password and the right + AAD but carrying different content still is not this tool's verifier.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document[store.VERIFIER_KEY] = crypto.seal_json( + "not the verifier plaintext", PASSWORD, store.VERIFIER_AAD + ) + path.write_text(json.dumps(document)) + + with pytest.raises(StoreError, match="did not match"): + resolve_credentials("one", PASSWORD, directory=initialised) + + +def test_the_temporary_file_is_created_in_the_target_directory( + store_directory, monkeypatch +): + """`atomic_write`'s atomicity rests entirely on the rename being within + one filesystem, which rests entirely on `mkstemp(dir=...)`. Nothing + pinned it: dropping `dir=directory` puts the temporary file under + `$TMPDIR`, and `os.replace` across filesystems raises `OSError` -- or, + worse, on a setup where `$TMPDIR` happens to share the filesystem, + succeeds while writing sealed credentials into a world-traversable + directory first. + + Recorded at the moment of the rename, which is the only point the + temporary file is guaranteed to still exist. + """ + store_directory.mkdir(parents=True) + real_replace = os.replace + observed: list[pathlib.Path] = [] + + def spy(src, dst): + observed.append(pathlib.Path(src).parent) + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", spy) + atomic_write(store_directory / "credentials", b"content", 0o600) + assert observed == [store_directory] + + +# --------------------------------------------------------------------------- +# Password rotation +# --------------------------------------------------------------------------- + + +def test_rotation_reseals_every_envelope(initialised): + set_credentials( + "two", {"NAME": "another-placeholder"}, PASSWORD, directory=initialised + ) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) + + assert resolve_credentials("one", NEW_PASSWORD, directory=initialised) == CREDENTIALS + assert resolve_credentials("two", NEW_PASSWORD, directory=initialised) == { + "NAME": "another-placeholder" + } + with pytest.raises(PasswordError): + resolve_credentials("one", PASSWORD, directory=initialised) + + +def test_rotation_draws_fresh_salts_rather_than_reusing_the_old_ones(initialised): + before = json.loads((initialised / "credentials").read_bytes()) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) + after = json.loads((initialised / "credentials").read_bytes()) + for section in (["verifier"], ["profiles", "one"]): + old, new = before, after + for key in section: + old, new = old[key], new[key] + assert old["salt"] != new["salt"] + assert old["nonce"] != new["nonce"] + + +def test_rotation_reseals_an_envelope_whose_config_table_is_gone(store_directory): + """Rotation iterates the credentials file, not the profile list in `config`. + An implementation that walked `config` instead would silently and + irrecoverably destroy this envelope, and every other test here would pass.""" + write_config(store_directory, config_with("one")) + init_store(PASSWORD, directory=store_directory) + set_credentials("one", CREDENTIALS, PASSWORD, directory=store_directory) + set_credentials( + "orphan", {"NAME": "orphan-placeholder"}, PASSWORD, directory=store_directory + ) + + rotate_password(PASSWORD, NEW_PASSWORD, directory=store_directory) + + assert store_profiles(store_directory) == ["one", "orphan"] + assert resolve_credentials("orphan", NEW_PASSWORD, directory=store_directory) == { + "NAME": "orphan-placeholder" + } + + +def test_rotation_with_the_wrong_current_password_changes_nothing(initialised): + before = (initialised / "credentials").read_bytes() + with pytest.raises(PasswordError): + rotate_password(WRONG_PASSWORD, NEW_PASSWORD, directory=initialised) + assert (initialised / "credentials").read_bytes() == before + + +def test_rotation_is_all_or_nothing_when_one_reseal_fails(initialised, monkeypatch): + """Induce a failure partway through and prove the store is untouched: the + same bytes, both envelopes still opening under the *old* password, neither + under the new one, and no debris beside the file. A partial rotation would + leave a store whose profiles need different passwords with nothing recording + which is which. + """ + set_credentials( + "two", {"NAME": "another-placeholder"}, PASSWORD, directory=initialised + ) + before = (initialised / "credentials").read_bytes() + + real_seal_json = store.seal_json + + def failing_seal_json(payload, password, aad): + # Keyed on the profile, not on a call count. An implementation that + # wrote after each re-seal would already have persisted "one" by the + # time this fires, so the byte-comparison below catches it — a + # count-based trigger happened to fire before the first such write and + # let that implementation pass. + if aad == "two": + raise RuntimeError("induced failure partway through the rotation") + return real_seal_json(payload, password, aad) + + monkeypatch.setattr(store, "seal_json", failing_seal_json) + with pytest.raises(RuntimeError): + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) + monkeypatch.undo() + + assert (initialised / "credentials").read_bytes() == before + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS + assert resolve_credentials("two", PASSWORD, directory=initialised) == { + "NAME": "another-placeholder" + } + with pytest.raises(PasswordError): + resolve_credentials("one", NEW_PASSWORD, directory=initialised) + assert sorted(path.name for path in initialised.iterdir()) == ["credentials"] + + +def test_rotation_touches_the_file_exactly_once(initialised, monkeypatch): + """The mechanism behind all-or-nothing, asserted directly: build every + re-sealed envelope in memory, then write once. An implementation that saved + after each profile would be all-or-nothing only by luck about where a + failure landed, and the induced-failure test above can only observe the + failures it induces.""" + set_credentials( + "two", {"NAME": "another-placeholder"}, PASSWORD, directory=initialised + ) + real_atomic_write = store.atomic_write + writes: list[str] = [] + + def counting_atomic_write(path, data, mode): + writes.append(path.name) + return real_atomic_write(path, data, mode) + + monkeypatch.setattr(store, "atomic_write", counting_atomic_write) + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) + assert writes == ["credentials"] + + +def test_rotation_stops_rather_than_dropping_an_envelope_it_cannot_open(initialised): + """An envelope that will not open must abort the rotation. Skipping it would + turn a damaged envelope into a deleted one.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["damaged"] = {**document["profiles"]["one"], "ct": "AAAA"} + path.write_text(json.dumps(document)) + before = path.read_bytes() + + with pytest.raises(StoreError): + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) + assert path.read_bytes() == before + + +# --------------------------------------------------------------------------- +# Location, and what never leaks +# --------------------------------------------------------------------------- + + +def test_the_store_lives_under_the_xdg_config_home(tmp_path, monkeypatch): + """`store_dir` defers to `cli.config_home` rather than re-deriving the XDG + base, so there is one answer to where this tool's files live.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert store.store_dir() == tmp_path / "stackward" + assert store.credentials_path() == tmp_path / "stackward" / "credentials" + assert store.config_path() == tmp_path / "stackward" / "config" + + +def test_no_error_message_from_any_failing_path_contains_a_credential( + initialised, capsys +): + """One assertion over every failure this module can produce. Any new branch + that interpolated a value would have to be added here to be seen — but a + branch that leaked one would already be caught by the sweep below.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["profiles"]["moved"] = document["profiles"]["one"] + path.write_text(json.dumps(document)) + + messages: list[str] = [] + for call in ( + lambda: resolve_credentials("one", WRONG_PASSWORD, directory=initialised), + lambda: resolve_credentials("absent", PASSWORD, directory=initialised), + lambda: resolve_credentials("moved", PASSWORD, directory=initialised), + lambda: rotate_password(WRONG_PASSWORD, NEW_PASSWORD, directory=initialised), + lambda: init_store(PASSWORD, directory=initialised), + lambda: set_credentials( + "four", CREDENTIALS, WRONG_PASSWORD, directory=initialised + ), + ): + with pytest.raises(StoreError) as raised: + call() + messages.append(str(raised.value)) + + captured = capsys.readouterr() + for text in [*messages, captured.out, captured.err]: + assert MARKER not in text + assert PASSWORD not in text + assert WRONG_PASSWORD not in text + + +def test_a_profile_dataclass_reports_which_form_it_carries(): + assert not Profile(name="one", backend_url="file:///placeholder").has_components + assert Profile(name="one", bucket="placeholder-bucket").has_components + + +# --------------------------------------------------------------------------- +# An absent password, per entry point +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_init_store_refuses_an_absent_password_instead_of_sealing_with_nothing( + store_directory, password +): + """Argon2id derives a usable key from `b""`, so an unguarded `init_store("")` + writes a well-formed store that opens with no password at all — no error, no + warning, and no way to tell it from a protected one.""" + with pytest.raises(PasswordError): + init_store(password, directory=store_directory) + assert not (store_directory / "credentials").exists() + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_set_credentials_refuses_an_absent_password(initialised, password): + with pytest.raises(PasswordError): + set_credentials("two", CREDENTIALS, password, directory=initialised) + assert store_profiles(initialised) == ["one"] + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_resolve_credentials_refuses_an_absent_password(initialised, password): + with pytest.raises(PasswordError): + resolve_credentials("one", password, directory=initialised) + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_rotation_refuses_an_absent_new_password_and_changes_nothing( + initialised, password +): + """The irreversible one: rotating *to* an empty password would leave a store + that opens with nothing, with the old password gone.""" + before = (initialised / "credentials").read_bytes() + with pytest.raises(PasswordError): + rotate_password(PASSWORD, password, directory=initialised) + assert (initialised / "credentials").read_bytes() == before + assert resolve_credentials("one", PASSWORD, directory=initialised) == CREDENTIALS + + +@pytest.mark.parametrize("password", ["", " ", "\t\n"]) +def test_rotation_refuses_an_absent_current_password(initialised, password): + with pytest.raises(PasswordError): + rotate_password(password, NEW_PASSWORD, directory=initialised) + + +def test_an_absent_password_is_not_reported_as_a_damaged_store(initialised): + """`EmptyPasswordError` is a `CryptoError`, so the broad clause in + `_check_password` would otherwise recode "no password" as "the verifier is + unusable" — a message that sends the reader after the wrong problem.""" + with pytest.raises(PasswordError) as raised: + resolve_credentials("one", "", directory=initialised) + assert "required" in str(raised.value) + + +def test_rotation_keeps_top_level_fields_it_does_not_own(initialised): + """Rotation replaces the verifier and the profiles and leaves the rest of + the document alone. Rebuilding it from a literal would silently drop any + other field, and would leave the two write paths disagreeing about what a + document is — `set_credentials` preserves the whole thing.""" + path = initialised / "credentials" + document = json.loads(path.read_bytes()) + document["a_field_a_later_version_added"] = {"kept": True} + path.write_text(json.dumps(document)) + + rotate_password(PASSWORD, NEW_PASSWORD, directory=initialised) + + after = json.loads(path.read_bytes()) + assert after["a_field_a_later_version_added"] == {"kept": True} + assert resolve_credentials("one", NEW_PASSWORD, directory=initialised) == CREDENTIALS diff --git a/uv.lock b/uv.lock index 774146a..e8c29a2 100644 --- a/uv.lock +++ b/uv.lock @@ -513,7 +513,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=42.0" }, + { name = "cryptography", specifier = ">=44.0" }, { name = "pyyaml", specifier = ">=6.0" }, ]