diff --git a/.editorconfig b/.editorconfig index a63bbd9..9445a7c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,6 +29,11 @@ trim_trailing_whitespace = true [*.sh] indent_style = tab +# The carried tree is byte-identical to the hub's copy, which indents with four spaces. +[lib/fleet/**.sh] +indent_style = space +indent_size = 4 + # Markdown files # Two trailing spaces are a hard line break in Markdown, so trimming them silently rewrites content. [*.md] diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index e042858..0fc2c6c 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -40,7 +40,7 @@ jobs: validate: name: Validate sources job needs: [ assert-ref ] - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@d7041d8ce15356dfd9223e54d74c64d464f28335 # 2.0.1192 permissions: contents: read with: @@ -58,7 +58,7 @@ jobs: deploy: name: Deploy site job needs: [ validate ] - uses: ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 + uses: ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml@d7041d8ce15356dfd9223e54d74c64d464f28335 # 2.0.1192 with: environment: ${{ inputs.environment }} permissions: diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index f7926be..ee60f4e 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -18,7 +18,7 @@ jobs: merge-bot: name: Merge bot pull request job - uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 + uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@d7041d8ce15356dfd9223e54d74c64d464f28335 # 2.0.1192 secrets: CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 2f8dfdc..6ed06e1 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -16,7 +16,7 @@ jobs: # Also replaces this repo's own dispatch-ref assertion: the hub task errors identically on a ref other than main/develop. plan: name: Plan release job - uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@d7041d8ce15356dfd9223e54d74c64d464f28335 # 2.0.1192 with: event_name: ${{ github.event_name }} actor: ${{ github.actor }} @@ -30,7 +30,7 @@ jobs: name: Validate sources job needs: [plan] if: ${{ needs.plan.outputs.publish == 'true' }} - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@d7041d8ce15356dfd9223e54d74c64d464f28335 # 2.0.1192 permissions: contents: read with: @@ -54,7 +54,7 @@ jobs: # An if: on a job replaces the implicit success() over its needs. # Without the validate arm a failed gate would publish anyway. if: ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }} - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@d7041d8ce15356dfd9223e54d74c64d464f28335 # 2.0.1192 permissions: contents: write actions: write diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index f39ba55..b1ee501 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -19,7 +19,7 @@ jobs: validate: name: Validate sources job - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@63cf1c9436876d55889260ce43d3900da2fd9b47 # 2.0.1166 + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@d7041d8ce15356dfd9223e54d74c64d464f28335 # 2.0.1192 permissions: contents: read with: diff --git a/.secrets/README.md b/.secrets/README.md index 4a64633..24d55b0 100644 --- a/.secrets/README.md +++ b/.secrets/README.md @@ -24,4 +24,4 @@ than the documented shape. | `~/.secrets/blog_vps_staging.env` | The VPS staging environment. | Every value each file holds, and what reads it, is described once in -[`ENVIRONMENT.md`](../ENVIRONMENT.md). Start a new one from [`example.env`](./example.env). +[`ENVIRONMENT.md`](../ENVIRONMENT.md). Start a new one from [`example.env`](./example.env), then `chmod 600` it, since `make-release.sh` refuses a file that group or other can read. diff --git a/.secrets/example.env b/.secrets/example.env index 69c1fd2..085e7fd 100644 --- a/.secrets/example.env +++ b/.secrets/example.env @@ -24,7 +24,7 @@ # blog_vps_production.env ENV_FILE=blog_vps_production.env deploy/make-release.sh # blog_vps_staging.env ENV_FILE=blog_vps_staging.env deploy/make-release.sh # -# Sourced with `set -a`, which overwrites a variable the caller exported first. A named +# make-release.sh reads DEPLOY_ROOT, DEPLOY_SSH_HOST, and SITE_BASE_URL from it over the caller's, and refuses a file that group or other can read. A named # file that does not exist is a hard failure rather than a fall-through. Real files live # in ~/.secrets/, never in this checkout, so no value naming a machine reaches this history. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ea4103d..46667af 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -27,7 +27,8 @@ "command": "shellcheck", "args": [ "deploy/make-release.sh", - "checks/check-live-urls.sh" + "checks/check-live-urls.sh", + "lib/fleet/sh/secrets.sh" ], "problemMatcher": [] }, @@ -38,7 +39,8 @@ "args": [ "-d", "deploy/make-release.sh", - "checks/check-live-urls.sh" + "checks/check-live-urls.sh", + "lib/fleet/sh/secrets.sh" ], "problemMatcher": [] }, diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index bd9b1c3..2451dec 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -8,9 +8,9 @@ Every configuration value this repository reads or writes, described once. [`OPE ## The mechanism -A local run reads one file. `~/.secrets/blog__.env` is sourced with `set -a`, selected by `ENV_FILE`, and defaults to `~/.secrets/blog_local_production.env`. The real files live on the host, never in this checkout, and [`.secrets/example.env`](./.secrets/example.env) is the tracked template that documents the shape. +A local run reads one file. `~/.secrets/blog__.env` is selected by `ENV_FILE`, and defaults to `~/.secrets/blog_local_production.env`. `make-release.sh` checks it with the carried `lib/fleet/sh/secrets.sh` readers. It reads only the values it needs, each in a child shell, rather than sourcing the whole file. A file must give group and other no access and use LF line endings, or the script refuses it and says why. The real files live on the host, never in this checkout, and [`.secrets/example.env`](./.secrets/example.env) is the tracked template that documents the shape. -Two consequences of `set -a` are worth stating because both have surprised someone. Sourcing overwrites a variable the caller exported first, so exporting `DEPLOY_ROOT` by hand does not switch environments and only `ENV_FILE` does. And a named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. +Two consequences are worth stating because both have surprised someone. Once a file is read, its `DEPLOY_ROOT`, `DEPLOY_SSH_HOST`, and `SITE_BASE_URL` replace any value the caller exported, even where the file leaves one unset. So exporting `DEPLOY_ROOT` by hand does not switch environments, and only `ENV_FILE` does. And a named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. CI reads no file. The deploy workflow resolves the same values from the GitHub Environment, which is why the shapes have to match even though the sources do not. @@ -74,7 +74,7 @@ Set on the command line for one run rather than stored anywhere. | Value | Effect | | --- | --- | -| `ENV_FILE` | which environment file to source. Defaults to `~/.secrets/blog_local_production.env` | +| `ENV_FILE` | which environment file to read. Defaults to `~/.secrets/blog_local_production.env` | | `REQUIRE_BROTLI=1` | fail rather than shipping gzip-only. CI sets it | | `NO_LINK_DEST=1` | full copy instead of hard-linking from the previous release | | `KEEP_RELEASES` | how many releases `make-release.sh` leaves behind | diff --git a/OPERATIONS.md b/OPERATIONS.md index 36c3de6..53364cc 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -39,7 +39,7 @@ ENV_FILE=~/.secrets/blog_local_production.env deploy/make-release.sh "" "$RELEAS EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL" ``` -Name the file in both places, even when it is the default, since `make-release.sh` sources `ENV_FILE` independently of the shell above and a value already exported earlier in the same session would otherwise win silently over the sourced one. The empty first argument leaves the deploy root at the sourced `DEPLOY_ROOT`, and `RELEASE` is reused so `EXPECT_RELEASE` verifies the release the command just built rather than skipping the release-stamp guard. `set -e` matters here too: a failed `git rev-parse` would otherwise leave `RELEASE` empty, which silently skips the check's own release-stamp verification instead of failing loud: +Name the file in both places, even when it is the default, since `make-release.sh` reads `ENV_FILE` independently of the shell above and a value already exported earlier in the same session would otherwise win silently over the sourced one. The empty first argument leaves the deploy root at the `DEPLOY_ROOT` the script reads from that file. `RELEASE` is reused so `EXPECT_RELEASE` verifies the release the command just built rather than skipping the release-stamp guard. `set -e` matters here too: a failed `git rev-parse` would otherwise leave `RELEASE` empty, which silently skips the check's own release-stamp verification instead of failing loud: ```sh set -e @@ -65,9 +65,9 @@ So the bundle stamps its own version as `X-Blog-Release`, and `check-live-urls.s EXPECT_RELEASE= checks/check-live-urls.sh "$SITE_BASE_URL" ``` -Sourcing the environment file first puts the deploy root and the base URL in the environment, so no literal value is typed. `make-release.sh` then needs no arguments, because its deploy root falls back to `$DEPLOY_ROOT` and its version falls back to a timestamp. It still accepts both arguments, and the deploy workflow passes a bundle path and a version, as [Deploying](#deploying) below shows. Either form works locally, and the argument wins over the environment. +Sourcing the environment file first puts the base URL, and the other values `check-live-urls.sh` reads, in the shell, so no literal value is typed. `make-release.sh` needs no arguments. Its deploy root falls back to the `DEPLOY_ROOT` it reads from its own file, and its version falls back to a timestamp. It still accepts both arguments, and the deploy workflow passes a bundle path and a version, as [Deploying](#deploying) below shows. Either form works locally, and the argument wins over the file's `DEPLOY_ROOT`. -`ENV_FILE` is set as well as sourced, and the redundancy is deliberate. The script sources its own file regardless, so leaving `ENV_FILE` off would build and install against `~/.secrets/blog_local_production.env` while the shell's `$SITE_BASE_URL` still named staging, and the run would check the staging site after publishing to the production root. The script prints the file it read, on every build, for that reason. +The file is both sourced in the shell and named in `ENV_FILE`, and the redundancy is deliberate. The script reads its own file regardless, so leaving `ENV_FILE` off would build and install against `~/.secrets/blog_local_production.env` while the shell's `$SITE_BASE_URL` still named staging, and the run would check the staging site after publishing to the production root. The script prints the file it read, on every build, for that reason. It refuses to install a release that fails the build gate. `check-live-urls.sh` does take a base URL, which is where the sourced `$SITE_BASE_URL` goes. It follows every URL in the contract against the running mirror, checking each redirect's destination rather than trusting its status code. @@ -119,9 +119,9 @@ SITE_BASE_URL= deploy/make-release.sh EXPECT_RELEASE= checks/check-live-urls.sh ``` -The deploy root and the base URL are the only host-specific values. A local run reads them from a file under `~/.secrets/`, one per environment, copied from [`.secrets/example.env`](./.secrets/example.env). CI passes the base URL and a scratch bundle path, then uploads the bundle to the host's deploy root. The real files live on the host, never in this checkout. +The deploy root and the base URL are the only host-specific values. A local run reads them from a file under `~/.secrets/`, one per environment, copied from [`.secrets/example.env`](./.secrets/example.env). Make each one mode 600, since `make-release.sh` refuses a file that group or other can read. CI passes the base URL and a scratch bundle path, then uploads the bundle to the host's deploy root. The real files live on the host, never in this checkout. -**The command-prefix form above is CI-only.** A local run whose default environment file exists sources it after the command-prefix assignment and overwrites it, since `set -a` overwrites a value the caller exported first. Locally, select the environment through `ENV_FILE` instead, as the two examples earlier in this section do. +**The command-prefix form above is CI-only.** A local run whose default environment file exists reads it after the command-prefix assignment and overwrites it. A value read from the file replaces one the caller exported. Locally, select the environment through `ENV_FILE` instead, as the two examples earlier in this section do. **A VPS release is named by its deploy run, never by a commit.** The hub's deploy task names each release `--` and verifies the site against that id itself. To check a VPS site by hand afterwards, pass that id as `EXPECT_RELEASE`. Read it from the **Deploy site job**'s log, where `make-release.sh` prints `==> installing release ` with the id ending in the run's own id and attempt. The Validate sources job prints the same line earlier in the run, for a scratch bundle stamped with a bare timestamp, so the first match of a log search is the wrong id. Never copy it from the site's `X-Blog-Release` header, since a stale config still serves the previous id and the check would then pass against it. A short SHA never matches, so the check waits out its timeout and fails. @@ -139,7 +139,7 @@ The deploy root and the base URL are the only host-specific values. A local run | Variable | Effect | | --- | --- | -| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog_local_production.env`. | +| `ENV_FILE` | Which environment file to read. Defaults to `~/.secrets/blog_local_production.env`. | | `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. | | `SITE_BASE_URL` | Overrides the site base URL. | | `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. | @@ -497,7 +497,7 @@ The local mirrors are not staging. They run the same bundle against the same web **The two words are `production` and `staging`, spelled out, in every position.** No `prod`, no `stage`. The same two name the container, the deploy root, the environment file, the `X-Blog-Env` value, and the GitHub Environment. This is not tidiness: the environment name is a value that gets **compared**, by `EXPECT_SITE_ENV` and by the deploy, so a spelling that differs in one position fails a deploy for a reason that reads like an outage. The local mirrors prefix the same words, `mirror-production` and `mirror-staging`, so a header names exactly one of the four environments in the fleet. -Each environment is one file under `~/.secrets/`, named `blog__.env`, selected with `ENV_FILE`, and holding the deploy root, the base URL, and the container name. The name carries both halves because the two pairs differ in server as well as environment, so a file says which machine it describes rather than leaving that to the value inside it, and the four in the table above are the four files. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. Selecting the file is how an environment is chosen: the file is sourced with `set -a`, so it overwrites a `DEPLOY_ROOT` the caller exported and setting that variable by hand does not switch anything. A named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. +Each environment is one file under `~/.secrets/`, named `blog__.env`, selected with `ENV_FILE`, and holding the deploy root, the base URL, and the container name. The name carries both halves because the two pairs differ in server as well as environment, so a file says which machine it describes rather than leaving that to the value inside it, and the four in the table above are the four files. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. Selecting the file is how an environment is chosen. The script reads `DEPLOY_ROOT` from the file and overwrites one the caller exported. Setting that variable by hand therefore switches nothing. A named file that does not exist is a hard failure rather than a fall-through, because on a host serving two sites the ambient value is the other site's root. **The staging FQDN sits under the VPS wildcard deliberately.** `blog.vps.insanegenius.net` needs no new certificate and no new DNS record, and it keeps the staging name off the production domain. diff --git a/README.md b/README.md index be1386f..85d7a20 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ ENV_FILE=~/.secrets/blog_local_production.env deploy/make-release.sh "" "$RELEAS EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL" ``` -The deploy root and the base URL come from a file per environment in `~/.secrets/`, named `blog__.env`, copied from [example.env][env-example] and selected with `ENV_FILE`. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. The real files live on the host, never in this checkout. +The deploy root and the base URL come from a file per environment in `~/.secrets/`, named `blog__.env`, copied from [example.env][env-example] and selected with `ENV_FILE`. Make each one mode 600, since `make-release.sh` refuses a file that group or other can read. `~/.secrets/blog_local_production.env` is the one read when `ENV_FILE` is unset. The real files live on the host, never in this checkout. ## 3rd Party Tools diff --git a/deploy/README.md b/deploy/README.md index d5439a5..17908b1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -66,7 +66,7 @@ EXPECT_RELEASE="$RELEASE" checks/check-live-urls.sh "$SITE_BASE_URL" The deploy root and the base URL are the only host-specific values, and they pair per environment. Copy [`example.env`][example-env] to `~/.secrets/blog_local_production.env`, which is the file read when `ENV_FILE` is unset, and add `~/.secrets/blog__.env` for each further -environment. A single environment therefore needs `~/.secrets/blog_local_production.env` and nothing +environment. `chmod 600` each one, since the script refuses a file that group or other can read. A single environment therefore needs `~/.secrets/blog_local_production.env` and nothing else, since a differently named file is read only when `ENV_FILE` names it. The real files live on the host, in `~/.secrets/`, never in this checkout. CI passes the base URL and a scratch bundle path explicitly instead, then uploads the bundle to the host's deploy root: @@ -75,7 +75,7 @@ bundle path explicitly instead, then uploads the bundle to the host's deploy roo SITE_BASE_URL= deploy/make-release.sh ``` -**That command-prefix form is CI-only.** A local run whose default environment file exists sources it after the command-prefix assignment and overwrites it, since `set -a` overwrites a value the caller exported first. Locally, select the environment through `ENV_FILE` instead, per the table below. +**That command-prefix form is CI-only.** A local run whose default environment file exists reads it after the command-prefix assignment and overwrites it. A value read from the file replaces one the caller exported. Locally, select the environment through `ENV_FILE` instead, per the table below. **One file per environment, named `~/.secrets/blog__.env`, selected by `ENV_FILE`.** Both halves are spelled out, so a name says which machine it describes as well as which @@ -95,8 +95,8 @@ to create that path here and asks for a local one to assemble a bundle into: ENV_FILE=~/.secrets/blog_vps_staging.env deploy/make-release.sh /path/to/bundle ``` -Selecting the file is the only way to switch environments. The file is sourced with `set -a`, -which exports every assignment in it and **overwrites** a variable the caller exported first, so +Selecting the file is the only way to switch environments. The script reads `DEPLOY_ROOT`, +`DEPLOY_SSH_HOST`, and `SITE_BASE_URL` from it and **overwrites** each one the caller exported, so `DEPLOY_ROOT=... deploy/make-release.sh` does not do what it looks like. The first argument still wins, because it is read after the file. A named file that does not exist is a hard failure rather than a fall-through to the ambient environment, since on a host running two sites @@ -113,7 +113,7 @@ that reason. | Variable | Effect | | --- | --- | -| `ENV_FILE` | Which environment file to source. Defaults to `~/.secrets/blog_local_production.env`. | +| `ENV_FILE` | Which environment file to read. Defaults to `~/.secrets/blog_local_production.env`. | | `DEPLOY_ROOT` | Fallback deploy root. The first argument wins. | | `SITE_BASE_URL` | Overrides the site base URL. Bridged internally to `HUGO_BASEURL`, the name Hugo maps `HUGO_` onto config natively. | | `REQUIRE_BROTLI=1` | Fails rather than shipping gzip-only. CI sets this. | diff --git a/deploy/make-release.sh b/deploy/make-release.sh index 59e7442..5c28961 100755 --- a/deploy/make-release.sh +++ b/deploy/make-release.sh @@ -37,13 +37,15 @@ if [ "$#" -gt 2 ]; then fi REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=lib/fleet/sh/secrets.sh +. "$REPO/lib/fleet/sh/secrets.sh" # The release upstream replaced `git whatchanged` with `git log`. # Anything older restores nothing and exits 0, so this is the floor rather than a preference. MTIME_MIN=2025.08 # The deploy root and the base URL are the only host-specific values, and they pair per environment. -# ENV_FILE selects the environment, because `set -a` overwrites a value the caller exported. +# ENV_FILE selects the environment, because the file's values replace the caller's, set or not. # The first argument overrides the root, being read after this. # Files are named ~/.secrets/blog__.env, both words spelled out, so the default names the environment it actually selects rather than being the one file whose name says nothing. # The leading blog field disambiguates this repo's files in ~/.secrets, since other repos on the same host also write there. @@ -59,17 +61,14 @@ case "$ENV_FILE" in ;; *) ENV_FILE="$HOME/.secrets/$ENV_FILE" ;; esac -if [ -f "$ENV_FILE" ]; then +# The default file is optional, because CI passes every value explicitly and reads no file. +# A named file is not, since a typo would fall through to the other environment's root. +if [ "$ENV_FILE" != "$DEFAULT_ENV_FILE" ] || [ "$(secret_verdict "$ENV_FILE")" != absent ]; then + require_secret "$ENV_FILE" echo "==> environment: $ENV_FILE" - set -a - # shellcheck disable=SC1090,SC1091 - . "$ENV_FILE" - set +a -elif [ "$ENV_FILE" != "$DEFAULT_ENV_FILE" ]; then - # The default file is optional, because CI passes every value explicitly and reads no file. - # A named file is not, since a typo would fall through to the other environment's root. - echo "environment file not found: $ENV_FILE" >&2 - exit 1 + DEPLOY_ROOT="$(read_env_value "$ENV_FILE" DEPLOY_ROOT)" + DEPLOY_SSH_HOST="$(read_env_value "$ENV_FILE" DEPLOY_SSH_HOST)" + SITE_BASE_URL="$(read_env_value "$ENV_FILE" SITE_BASE_URL)" fi # Hugo maps HUGO_ onto config natively, and only that name, so this is the one place SITE_BASE_URL becomes the name Hugo actually reads. diff --git a/lib/fleet/sh/secrets.sh b/lib/fleet/sh/secrets.sh new file mode 100644 index 0000000..e48a5a0 --- /dev/null +++ b/lib/fleet/sh/secrets.sh @@ -0,0 +1,243 @@ +#!/bin/bash + +# Meant to be sourced, never run: `. "$REPO/lib/fleet/sh/secrets.sh"` from a script that has already found its own tree through `BASH_SOURCE`. +# It sets no shell option, since sourcing it would change the caller's own, and every function answers through its output or its status under any of them. +# +# The hub hosts this file and a repository carries it byte-for-byte, per GOVERNANCE.md "Hub-Hosted Tooling", so a repository extends it in a file of its own outside the carried directory that sources it, never by editing it. +# A caller that needs these functions in an elevated shell ships them there by name with `declare -f`, since sourcing this file in the calling shell does not reach one started by `sudo`. +# A function here that calls another is shipped with it, so `secret_verdict` needs `secret_shape_verdict`, `secret_state`, `unresolved_link_reason`, `secret_content_verdict`, `secret_content_state`, and `secret_content_refusal`. + +# `unresolved_link_reason` +# Echoes the clause that says why a symlink answering `-L` true and `-e` false does not resolve, worded for the account running it, so every site that reports such a link composes one sentence instead of spelling its own. +# Root with its DAC override enters every directory, so a link it cannot resolve has a target that is missing or loops back on itself, and the clause says only that. +# A uid 0 without the override, such as one in a user namespace facing an unmapped owner, is read as root all the same. +# Any other account cannot tell those two from a target sitting behind a directory it cannot enter, and the clause names all three. +# A caller that tells the two wordings apart does so by the ending ` sits behind a directory this account cannot enter`, fixed text here. +unresolved_link_reason() { + if [[ $EUID -eq 0 ]]; then + echo "its target is missing or loops back on itself" + else + echo "its target is missing, loops back on itself, or sits behind a directory this account cannot enter" + fi +} + +# `secret_state` +# One word for what a secret file is, because a gate that skips on a bare `-f` reads four unrelated states as one, since `-f` answers false for each of them. +# `absent` only where nothing is at the path and every directory leading to it is one this account can actually enter, which is the single state a gate may skip on. +# `damaged ` where the path or a directory leading to it is a symlink that does not resolve, or is present in a shape a secret cannot be read from. +# `unknown ` where a directory leading to it cannot be entered, or the path is not absolute, so the path's absence was never established rather than observed. +# `present` where a regular file is there. +# The walk starts at the root rather than at the immediate parent, since a single unenterable ancestor makes every component below it answer false to `-e` and that would otherwise read as a proven absence. +# See `secret_verdict` below for the full check built on it. +secret_state() { + local path="$1" walked="" part index last + if [[ $path != /* ]]; then + echo "unknown $path is not an absolute path" + return 0 + fi + # A doubled or trailing slash names the same component again, so both are removed before the split rather than becoming empty components the walk would have to skip. + while [[ $path == *//* ]]; do + path="${path//\/\//\/}" + done + path="${path%/}" + # The trim above empties a path that was only slashes, which would otherwise walk no components at all and fall through to `present` without ever reaching the leaf test. + # The root is a directory, so it gets the same answer any other non-regular-file leaf gets rather than a fifth state. + if [[ -z $path ]]; then + echo "damaged / is not a regular file" + return 0 + fi + local -a parts + # Split on `/` alone, since `read` stops at the first newline and a path component may hold one, and a split that fails answers rather than walking no components. + readarray -d / -t parts < <(printf '%s' "${path#/}") || { + echo "unknown $path could not be split into its components" + return 0 + } + last=$((${#parts[@]} - 1)) + for index in "${!parts[@]}"; do + part="${parts[index]}" + walked="$walked/$part" + # `-L` before `-e` throughout: a symlink that does not resolve answers false to `-e`, which is exactly the state a bare existence test reads as an absence. + # Reported as unresolved rather than as a missing target, since `-e` cannot separate a target that is gone from one that loops back on itself or, for a non-root account, one sitting behind a directory this account cannot enter, and asserting any one of them would claim more than the test established. + if [[ -L $walked && ! -e $walked ]]; then + echo "damaged $walked is a symlink that does not resolve, so $(unresolved_link_reason)" + return 0 + fi + if [[ ! -e $walked ]]; then + echo absent + return 0 + fi + if [[ $index -eq $last ]]; then + if [[ ! -f $walked ]]; then + echo "damaged $walked is not a regular file" + return 0 + fi + echo present + return 0 + fi + if [[ ! -d $walked ]]; then + echo "damaged $walked is not a directory, so it can hold no secret" + return 0 + fi + if [[ ! -x $walked ]]; then + echo "unknown $walked cannot be entered by this account" + return 0 + fi + done + echo present +} + +# `secret_shape_verdict` [private|shared] +# What `secret_verdict` below answers, from the file's metadata alone, without opening it. +# `readable` where it is a regular file, not a symlink, gives group and other no access, and this account can read it. +# `shared` drops the group and other check alone, for a secret a container reads as a Docker secret, which stays group and other readable by design. +# `absent` where nothing is there, which `secret_state` above establishes rather than infers, so a caller whose secret is optional may skip on it. +# `refused ` for everything else, the sentence naming the path and why, so a caller prints it as the reason rather than composing its own. +# A symlink is refused by name even where it resolves to a private file, since `stat -c %a` reads the link's own 777 rather than its target's mode, and a link is also a second place the file's content can be repointed from. +# A caller running as root can call this one alone and run `secret_content_state` in the shell it reads the file with, so root never opens the file. +secret_shape_verdict() { + local path="$1" access="${2:-private}" state mode + # Refused rather than normalized, since the caller reads the spelling it was given, and the kernel answers a trailing slash on a file with "Not a directory". + if [[ $path == */ ]]; then + printf 'refused %s ends in a slash, so it names a directory rather than a file\n' "$path" + return 0 + fi + state=$(secret_state "$path") + case $state in + present) ;; + absent) + echo absent + return 0 + ;; + *) + printf 'refused %s\n' "${state#* }" + return 0 + ;; + esac + if [[ -L $path ]]; then + printf 'refused %s is a symlink, and must be the file itself\n' "$path" + return 0 + fi + # Validated as octal digits before `(( ))` reads it, since a non-octal value there fails the test the same way a private mode passes it. + if ! mode=$(stat -c '%a' -- "$path") || [[ ! $mode =~ ^[0-7]{1,4}$ ]]; then + printf 'refused the mode of %s could not be read, so whether it gives group or other access cannot be established\n' "$path" + return 0 + fi + if [[ $access != shared ]] && ((8#$mode & 8#077)); then + printf 'refused %s is mode %s, and must give group and other no access\n' "$path" "$mode" + return 0 + fi + if [[ ! -r $path ]]; then + printf 'refused %s is mode %s and owned by %s, so this account cannot read it\n' "$path" "$mode" "$(stat -c '%U' -- "$path")" + return 0 + fi + echo readable +} + +# `secret_content_state` +# `clean` where the file `secret_shape_verdict` passed holds no carriage return, `crlf` where it holds one, and `unchecked` where the check could not run. +# A FIFO or device in place of the file, or a symlink to one, answers `unchecked`, since `-D skip` passes such a file unread. +# Refused rather than left to each reader, since each reader fails on a carriage return differently. +# A sourcing shell under errexit stops on a blank CRLF line, and one without it keeps the carriage return in every value. +secret_content_state() { + # `grep` streams the file rather than holding it, and `-D skip` keeps `grep` itself from blocking on a FIFO swapped in after the shape checks. + local found=0 + LC_ALL=C grep -qF -D skip -- $'\r' "$1" || found=$? + case $found in + 0) echo crlf ;; + 1) + if [[ -f $1 ]]; then + echo clean + else + echo unchecked + fi + ;; + *) echo unchecked ;; + esac +} + +# `secret_content_refusal` +# The `refused ` for a `secret_content_state` other than `clean`, built without opening the file, and any state but `crlf` reads as `unchecked`. +secret_content_refusal() { + if [[ $2 == crlf ]]; then + printf 'refused %s holds a carriage return, most often from CRLF line endings, and must use LF line endings only\n' "$1" + else + printf 'refused the check of %s for a carriage return could not run, so whether it holds one cannot be established\n' "$1" + fi +} + +# `secret_content_verdict` +# `readable` where `secret_content_state` answers `clean`, and its `secret_content_refusal` otherwise. +secret_content_verdict() { + local state + state=$(secret_content_state "$1") + if [[ $state == clean ]]; then + echo readable + else + secret_content_refusal "$1" "$state" + fi +} + +# `secret_verdict` [private|shared] +# One word for whether may be read as a secret: `secret_shape_verdict`'s answer, then `secret_content_verdict`'s where the shape is `readable`. +# A reader in another language runs this function rather than restating its checks, so the rule exists once, here. +secret_verdict() { + local verdict + verdict=$(secret_shape_verdict "$1" "${2:-private}") + if [[ $verdict != readable ]]; then + printf '%s\n' "$verdict" + return 0 + fi + secret_content_verdict "$1" +} + +# `secret_set_verdict` ... +# One answer for a set of secrets read all or none, from `secret_verdict` on each path. +# `readable` where every path is, which an empty list is too. +# `missing ` where any is absent, naming the final component of each absent path in the order given, separated by single spaces. +# `refused ` otherwise, the first refusal in the order given, so the sentence names the first file a reader of the set would stop on. +# A missing file outranks a refusal, since the set cannot be complete whatever the refused file turns out to be. +# Naming the refusal first would send the operator to fix a mode, only for the next run to name the files still missing. +secret_set_verdict() { + local path verdict missing="" refused="" + for path in "$@"; do + verdict=$(secret_verdict "$path") + case $verdict in + readable) ;; + absent) missing="$missing ${path##*/}" ;; + *) [[ -n $refused ]] || refused="$verdict" ;; + esac + done + if [[ -n $missing ]]; then + printf 'missing%s\n' "$missing" + elif [[ -n $refused ]]; then + printf '%s\n' "$refused" + else + echo readable + fi +} + +# `require_secret` +# Succeeds only where `secret_verdict` answers `readable`, and otherwise prints why to stderr and fails, so a secret that must be there is one bare call before the line that sources it. +# A bare call under `set -e` stops the script on that failure, where a call in an `if` or before `||` would not. +require_secret() { + local verdict + verdict=$(secret_verdict "$1") + case $verdict in + readable) return 0 ;; + absent) printf 'ERROR: %s is not there\n' "$1" >&2 ;; + *) printf 'ERROR: %s\n' "${verdict#refused }" >&2 ;; + esac + return 1 +} + +# `read_env_value` +# Prints as the env file named by sets it, and fails where any line of that file fails. +# A bash of its own rather than a subshell, because a caller reading this inside an `if` condition has errexit off in every subshell as well, so only the last line of the file would count. +# The child also keeps `set -a` from exporting that file's credentials into anything this script spawns, and `unset` keeps an operator's already-sourced shell from standing in for the file. +# The name is held in a variable of the child's own rather than read from `$2` at the end, since the file runs with the positional parameters in view and can change them. +# What the file itself prints goes to /dev/null rather than into the value, and `BASH_ENV`, `POSIXLY_CORRECT` and `SHELLOPTS` are dropped so nothing but the file runs ahead of the read. +# It checks nothing about the file, so a caller runs `require_secret` on it first. +read_env_value() { + # shellcheck disable=SC2016 # Every expansion belongs to the child bash. + env -u BASH_ENV -u POSIXLY_CORRECT -u SHELLOPTS bash -Eeuo pipefail -c 'env_read_name=$2; unset -v "$env_read_name"; set -a; source "$1" >/dev/null; set +a; printf "%s" "${!env_read_name:-}"' _ "$1" "$2" +}