From 8aa638eda48af11f969c1a4ddc77958a912cf5af Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:30:27 +0100 Subject: [PATCH 1/6] feat(launcher): add a pinned, checksum-verified install command Release tags now publish the launcher with a SHA256SUMS file. The README install command downloads both for a named tag, checks the launcher against the checksum, and runs it only on a match. A wrong, empty or missing checksum file stops it before anything runs. A test extracts the documented command from the README and runs it against local fixtures. Closes dmfdeploy/dmfdeploy#736 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- .github/workflows/ci.yml | 10 ++++ .github/workflows/release-launcher.yml | 34 +++++++++++++ README.md | 27 ++++++++--- test/launcher/test-install-command.sh | 67 ++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release-launcher.yml create mode 100755 test/launcher/test-install-command.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85ffc96..043b60d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,16 @@ jobs: - run: | pytest + launcher: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Install command fails closed on a bad checksum + run: test/launcher/test-install-command.sh + frontend: runs-on: ubuntu-24.04 timeout-minutes: 10 diff --git a/.github/workflows/release-launcher.yml b/.github/workflows/release-launcher.yml new file mode 100644 index 0000000..9905bf9 --- /dev/null +++ b/.github/workflows/release-launcher.yml @@ -0,0 +1,34 @@ +name: release-launcher +# Publish the launcher and its SHA-256 checksum as Release assets for every v* +# tag, so the documented install command can fetch a pinned version and verify +# it before running it. The assets are built from the tagged commit, never main. +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + launcher: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Write checksum + run: | + set -euo pipefail + mkdir release + cp bin/dmf-init release/dmf-init + # Run inside the directory so the file name in SHA256SUMS is plain + # "dmf-init" and `sha256sum -c` works wherever the user downloads it. + (cd release && sha256sum dmf-init > SHA256SUMS) + cat release/SHA256SUMS + - name: Attach to Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + files: | + release/dmf-init + release/SHA256SUMS diff --git a/README.md b/README.md index 39808f9..ffc7621 100644 --- a/README.md +++ b/README.md @@ -60,19 +60,30 @@ To get started, the `dmf-init` launcher wraps Docker for you; it starts the container with the correct flags, waits until it's ready, extracts the one-time launch link, and opens your browser. No clone required: -Read the launcher before you run it. Download it, read it, then run it: +Each release publishes the launcher together with a SHA-256 checksum file. The +command below downloads both for one tagged release, checks the launcher against +the checksum, and runs it only if the check passes. If the checksum file is +missing or doesn't match, the command stops before it runs anything. + ```bash -curl -fsSL https://raw.githubusercontent.com/dmfdeploy/dmf-init/main/bin/dmf-init -o dmf-init -less dmf-init -bash dmf-init up +version=v0.3.5 +base="https://github.com/dmfdeploy/dmf-init/releases/download/${version}" +curl -fsSLO "${base}/dmf-init" -O "${base}/SHA256SUMS" && + { sha256sum -c SHA256SUMS 2>/dev/null || shasum -a 256 -c SHA256SUMS; } && + bash dmf-init up ``` + -If you trust the source, you can pipe the launcher into your shell instead. This runs remote code without a chance to read it first: +The command uses `sha256sum` on Linux and falls back to `shasum -a 256` on macOS. +Run it in an empty directory, because it saves `dmf-init` and `SHA256SUMS` there. -```bash -curl -fsSL https://raw.githubusercontent.com/dmfdeploy/dmf-init/main/bin/dmf-init | bash -s -- up -``` +To choose a version, set `version` to any tag from the +[releases page](https://github.com/dmfdeploy/dmf-init/releases). Use a tag, never +`main`. A checksum from the same release catches a corrupted or partial download. +It doesn't prove who published the release, so read the launcher first if you +want to review it. To read it before you run it, replace the last line with +`less dmf-init`, then run `bash dmf-init up` yourself. From a clone, run `bin/dmf-init`: diff --git a/test/launcher/test-install-command.sh b/test/launcher/test-install-command.sh new file mode 100755 index 0000000..b729234 --- /dev/null +++ b/test/launcher/test-install-command.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Tests the install command documented in README.md, so the docs and the test +# can't drift apart. The command is extracted from the README, its release URL +# is pointed at a local directory, and it runs with no network. +# +# test/launcher/test-install-command.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1" >&2; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Pull the fenced block between the README markers. +awk '/install-command:start/{on=1;next} /install-command:end/{on=0} on && !/^```/' \ + "$ROOT/README.md" > "$WORK/cmd.raw" +[ -s "$WORK/cmd.raw" ] || { echo "no install command found in README.md" >&2; exit 1; } + +# Documented command must pin a tag, not a branch. +if grep -qE '^version=v[0-9]+\.[0-9]+\.[0-9]+' "$WORK/cmd.raw" && ! grep -q '/main' "$WORK/cmd.raw"; then + ok "command pins a version tag and never names main" +else + bad "command must pin a version tag and never name main" +fi + +# Redirect the release URL to a local release dir; replace the launcher run with +# a marker so a pass is visible and nothing real starts. +REL="$WORK/release" +sed -e "s|https://github.com/dmfdeploy/dmf-init/releases/download/|file://${REL}/|" \ + -e 's|bash dmf-init up|echo LAUNCHED > ../ran|' "$WORK/cmd.raw" > "$WORK/cmd.sh" +grep -q "file://${REL}/" "$WORK/cmd.sh" || { echo "URL rewrite failed" >&2; exit 1; } + +sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } + +# $1 label, $2 expected exit (0 = runs, 1 = refuses), $3 setup function name +run_case() { + local label="$1" want="$2" setup="$3" rc=0 + rm -rf "$REL" "$WORK/ran" "$WORK/run" + mkdir -p "$REL/v0.3.5" "$WORK/run/sub" + printf '#!/usr/bin/env bash\necho hello\n' > "$REL/v0.3.5/dmf-init" + "$setup" + ( cd "$WORK/run/sub" && bash "$WORK/cmd.sh" ) >"$WORK/out" 2>&1 || rc=$? + if [ "$want" = 0 ]; then + if [ "$rc" = 0 ] && [ -f "$WORK/run/ran" ]; then ok "$label"; else bad "$label (rc=$rc)"; cat "$WORK/out" >&2; fi + else + if [ "$rc" != 0 ] && [ ! -f "$WORK/run/ran" ]; then ok "$label"; else bad "$label (rc=$rc, launcher ran: $([ -f "$WORK/run/ran" ] && echo yes || echo no))"; cat "$WORK/out" >&2; fi + fi +} + +good_sums() { ( cd "$REL/v0.3.5" && sha dmf-init > SHA256SUMS ); } +wrong_sums() { ( cd "$REL/v0.3.5" && printf '%064d dmf-init\n' 0 > SHA256SUMS ); } +empty_sums() { : > "$REL/v0.3.5/SHA256SUMS"; } +no_sums() { :; } +tampered() { good_sums; printf 'echo pwned\n' >> "$REL/v0.3.5/dmf-init"; } + +echo "install command:" +run_case "runs the launcher when the checksum matches" 0 good_sums +run_case "refuses on a wrong checksum" 1 wrong_sums +run_case "refuses when the launcher changed after checksum" 1 tampered +run_case "refuses on an empty checksum file" 1 empty_sums +run_case "refuses when the checksum file is missing" 1 no_sums + +echo "$PASS passed, $FAIL failed" +[ "$FAIL" = 0 ] From 6dbde1ada96b2ac7652ddcae53251d6e7dce972b Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:31:05 +0100 Subject: [PATCH 2/6] docs(launcher): pin the install command to v0.3.6 v0.3.6 is the first release the launcher workflow builds, so it is the first one with a SHA256SUMS asset. The install test now takes the fixture release directory from the README's version= line, so the next version bump needs no test change. Refs dmfdeploy/dmfdeploy#736 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- README.md | 2 +- test/launcher/test-install-command.sh | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ffc7621..520437a 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ missing or doesn't match, the command stops before it runs anything. ```bash -version=v0.3.5 +version=v0.3.6 base="https://github.com/dmfdeploy/dmf-init/releases/download/${version}" curl -fsSLO "${base}/dmf-init" -O "${base}/SHA256SUMS" && { sha256sum -c SHA256SUMS 2>/dev/null || shasum -a 256 -c SHA256SUMS; } && diff --git a/test/launcher/test-install-command.sh b/test/launcher/test-install-command.sh index b729234..8e58e65 100755 --- a/test/launcher/test-install-command.sh +++ b/test/launcher/test-install-command.sh @@ -33,14 +33,19 @@ sed -e "s|https://github.com/dmfdeploy/dmf-init/releases/download/|file://${REL} -e 's|bash dmf-init up|echo LAUNCHED > ../ran|' "$WORK/cmd.raw" > "$WORK/cmd.sh" grep -q "file://${REL}/" "$WORK/cmd.sh" || { echo "URL rewrite failed" >&2; exit 1; } +# The fixture release directory follows the version the README pins. +VERSION="$(sed -n 's/^version=\(v[0-9][0-9.]*\).*/\1/p' "$WORK/cmd.raw" | head -n 1)" +[ -n "$VERSION" ] || { echo "no version= line in the install command" >&2; exit 1; } +DIR="$REL/$VERSION" + sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } # $1 label, $2 expected exit (0 = runs, 1 = refuses), $3 setup function name run_case() { local label="$1" want="$2" setup="$3" rc=0 rm -rf "$REL" "$WORK/ran" "$WORK/run" - mkdir -p "$REL/v0.3.5" "$WORK/run/sub" - printf '#!/usr/bin/env bash\necho hello\n' > "$REL/v0.3.5/dmf-init" + mkdir -p "$DIR" "$WORK/run/sub" + printf '#!/usr/bin/env bash\necho hello\n' > "$DIR/dmf-init" "$setup" ( cd "$WORK/run/sub" && bash "$WORK/cmd.sh" ) >"$WORK/out" 2>&1 || rc=$? if [ "$want" = 0 ]; then @@ -50,11 +55,11 @@ run_case() { fi } -good_sums() { ( cd "$REL/v0.3.5" && sha dmf-init > SHA256SUMS ); } -wrong_sums() { ( cd "$REL/v0.3.5" && printf '%064d dmf-init\n' 0 > SHA256SUMS ); } -empty_sums() { : > "$REL/v0.3.5/SHA256SUMS"; } +good_sums() { ( cd "$DIR" && sha dmf-init > SHA256SUMS ); } +wrong_sums() { ( cd "$DIR" && printf '%064d dmf-init\n' 0 > SHA256SUMS ); } +empty_sums() { : > "$DIR/SHA256SUMS"; } no_sums() { :; } -tampered() { good_sums; printf 'echo pwned\n' >> "$REL/v0.3.5/dmf-init"; } +tampered() { good_sums; printf 'echo pwned\n' >> "$DIR/dmf-init"; } echo "install command:" run_case "runs the launcher when the checksum matches" 0 good_sums From b83474c9235edbcd46ca29b8e7a8b7151b6f3881 Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:39:48 +0100 Subject: [PATCH 3/6] feat(launcher): pin the release launcher to its own image and bump to v0.4.0 The published launcher defaulted to the :latest image, so a launcher that passed its checksum could start a newer, unverified image. The release workflow now stamps the release tag into a copy of the launcher before it computes SHA256SUMS. The stamp fails unless the marker line appears exactly once. The image workflow pushes the semver tag without the leading "v", so the stamp does the same. The --image flag still overrides the default. This also bumps VERSION, pyproject.toml, and the README install command to 0.4.0, and adds the stamp tests to the launcher CI job. Refs dmfdeploy/dmfdeploy#736 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + .github/workflows/release-launcher.yml | 12 ++++- README.md | 4 +- VERSION | 2 +- bin/dmf-init | 5 +- pyproject.toml | 2 +- scripts/stamp-launcher-image.sh | 44 +++++++++++++++++ test/launcher/test-stamp-launcher-image.sh | 56 ++++++++++++++++++++++ 8 files changed, 121 insertions(+), 6 deletions(-) create mode 100755 scripts/stamp-launcher-image.sh create mode 100755 test/launcher/test-stamp-launcher-image.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 043b60d..a95a8a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: persist-credentials: false - name: Install command fails closed on a bad checksum run: test/launcher/test-install-command.sh + - name: Release stamp pins the launcher image + run: test/launcher/test-stamp-launcher-image.sh frontend: runs-on: ubuntu-24.04 diff --git a/.github/workflows/release-launcher.yml b/.github/workflows/release-launcher.yml index 9905bf9..fc8bd87 100644 --- a/.github/workflows/release-launcher.yml +++ b/.github/workflows/release-launcher.yml @@ -1,7 +1,8 @@ name: release-launcher # Publish the launcher and its SHA-256 checksum as Release assets for every v* # tag, so the documented install command can fetch a pinned version and verify -# it before running it. The assets are built from the tagged commit, never main. +# it before running it. The published launcher defaults to the image of the +# same release. The assets are built from the tagged commit, never main. on: push: tags: ["v*"] @@ -17,11 +18,18 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false - - name: Write checksum + - name: Stamp the release image and write checksum + env: + TAG: ${{ github.ref_name }} run: | set -euo pipefail mkdir release cp bin/dmf-init release/dmf-init + # Pin the default image to this release before hashing, so the + # checksum covers the stamped file. Fails if the marker is not + # found exactly once. + scripts/stamp-launcher-image.sh release/dmf-init "$TAG" + grep -n '^DEFAULT_IMAGE=' release/dmf-init # Run inside the directory so the file name in SHA256SUMS is plain # "dmf-init" and `sha256sum -c` works wherever the user downloads it. (cd release && sha256sum dmf-init > SHA256SUMS) diff --git a/README.md b/README.md index 520437a..5202349 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ missing or doesn't match, the command stops before it runs anything. ```bash -version=v0.3.6 +version=v0.4.0 base="https://github.com/dmfdeploy/dmf-init/releases/download/${version}" curl -fsSLO "${base}/dmf-init" -O "${base}/SHA256SUMS" && { sha256sum -c SHA256SUMS 2>/dev/null || shasum -a 256 -c SHA256SUMS; } && @@ -77,6 +77,8 @@ curl -fsSLO "${base}/dmf-init" -O "${base}/SHA256SUMS" && The command uses `sha256sum` on Linux and falls back to `shasum -a 256` on macOS. Run it in an empty directory, because it saves `dmf-init` and `SHA256SUMS` there. +The published launcher starts the container image from the same release. To use +a different image, pass `--image`. To choose a version, set `version` to any tag from the [releases page](https://github.com/dmfdeploy/dmf-init/releases). Use a tag, never diff --git a/VERSION b/VERSION index c2c0004..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.5 +0.4.0 diff --git a/bin/dmf-init b/bin/dmf-init index ceece42..d6c0ac7 100755 --- a/bin/dmf-init +++ b/bin/dmf-init @@ -14,7 +14,8 @@ # dmf-init down stop and remove the container # # Options for `up`: -# --image REF container image (default ghcr.io/dmfdeploy/dmf-init:latest) +# --image REF container image (default: ghcr.io/dmfdeploy/dmf-init, tagged +# to match a release launcher; :latest in a clone) # --port N host port to publish on (default 8000; container stays 8000) # --tls serve HTTPS (self-signed; DMF_TLS_ENABLED=true) # --repo-base-url U fetch component repos from a private mirror @@ -31,6 +32,8 @@ set -euo pipefail CONTAINER_NAME="dmf-init" LABEL_KEY="org.dmfdeploy.launcher" LABEL_VAL="dmf-init" +# The release workflow rewrites the next line (scripts/stamp-launcher-image.sh), +# so keep it as a single line in exactly this form. DEFAULT_IMAGE="ghcr.io/dmfdeploy/dmf-init:latest" DATA_ROOT="/tmp/dmf-init-data" INTERNAL_PORT=8000 diff --git a/pyproject.toml b/pyproject.toml index 9704601..e99657d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dmf-init" -version = "0.3.5" +version = "0.4.0" description = "DMF Init - stateless localhost bootstrap container for DMF environments" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/stamp-launcher-image.sh b/scripts/stamp-launcher-image.sh new file mode 100755 index 0000000..592dca6 --- /dev/null +++ b/scripts/stamp-launcher-image.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Pin a launcher's default image to a release. +# +# scripts/stamp-launcher-image.sh +# +# The release workflow runs this on a copy of bin/dmf-init before it computes +# the checksum, so a verified launcher starts the image from the same release +# instead of whatever :latest points to later. The image workflow pushes the +# semver tag without the leading "v", so the stamp drops it too. +# +# The script fails unless the marker line appears exactly once. A silent no-op +# would publish a launcher that is still pinned to :latest. +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +file="$1" +tag="$2" + +if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "tag must look like v1.2.3, got: $tag" >&2 + exit 2 +fi +[ -f "$file" ] || { echo "no such file: $file" >&2; exit 2; } + +image_repo="ghcr.io/dmfdeploy/dmf-init" +marker="DEFAULT_IMAGE=\"${image_repo}:latest\"" +stamped="DEFAULT_IMAGE=\"${image_repo}:${tag#v}\"" + +count="$(grep -cFx "$marker" "$file" || true)" +if [ "$count" -ne 1 ]; then + echo "expected exactly one line '$marker' in $file, found $count" >&2 + exit 1 +fi + +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +while IFS= read -r line || [ -n "$line" ]; do + if [ "$line" = "$marker" ]; then line="$stamped"; fi + printf '%s\n' "$line" +done < "$file" > "$tmp" +cat "$tmp" > "$file" diff --git a/test/launcher/test-stamp-launcher-image.sh b/test/launcher/test-stamp-launcher-image.sh new file mode 100755 index 0000000..5cf9ba5 --- /dev/null +++ b/test/launcher/test-stamp-launcher-image.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Tests scripts/stamp-launcher-image.sh with no network: the stamp changes +# exactly one line of the real launcher, and refuses a missing or duplicated +# marker or a malformed tag. +# +# test/launcher/test-stamp-launcher-image.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +STAMP="$ROOT/scripts/stamp-launcher-image.sh" +LAUNCHER="$ROOT/bin/dmf-init" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1" >&2; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +MARKER='DEFAULT_IMAGE="ghcr.io/dmfdeploy/dmf-init:latest"' + +# The repo launcher carries the marker exactly once. +if [ "$(grep -cFx "$MARKER" "$LAUNCHER")" = 1 ]; then ok "launcher has the marker once"; else bad "launcher marker count"; fi + +# Happy path: one changed line, right image ref, tag without the "v". +cp "$LAUNCHER" "$WORK/l" +if "$STAMP" "$WORK/l" v0.4.0 >"$WORK/out" 2>&1; then + changed="$(diff "$LAUNCHER" "$WORK/l" | grep -c '^>' || true)" + removed="$(diff "$LAUNCHER" "$WORK/l" | grep -c '^<' || true)" + if [ "$changed" = 1 ] && [ "$removed" = 1 ]; then ok "stamp changes exactly one line"; else bad "changed=$changed removed=$removed"; fi + if grep -qFx 'DEFAULT_IMAGE="ghcr.io/dmfdeploy/dmf-init:0.4.0"' "$WORK/l"; then ok "stamped image ref is 0.4.0 (no v)"; else bad "stamped ref wrong"; fi + if ! grep -q '^DEFAULT_IMAGE=.*:latest' "$WORK/l"; then ok "default image is no longer :latest"; else bad "default image still :latest"; fi + if [ -x "$LAUNCHER" ] && cmp -s <(tail -n +2 "$LAUNCHER" | grep -vF DEFAULT_IMAGE=) <(tail -n +2 "$WORK/l" | grep -vF DEFAULT_IMAGE=); then ok "rest of the file is byte-identical"; else bad "rest of file differs"; fi + # --image must still override: IMAGE starts from DEFAULT_IMAGE and the parser sets it. + if grep -q -- '--image' "$WORK/l" && grep -q "IMAGE=\"\$DEFAULT_IMAGE\"" "$WORK/l"; then ok "--image override path intact"; else bad "--image override path missing"; fi +else + bad "stamp failed on the real launcher"; cat "$WORK/out" >&2 +fi + +# Missing marker. +printf '#!/usr/bin/env bash\necho hi\n' > "$WORK/none" +if ! "$STAMP" "$WORK/none" v0.4.0 >/dev/null 2>&1; then ok "fails when marker is missing"; else bad "accepted a missing marker"; fi + +# Duplicated marker. +{ cat "$LAUNCHER"; printf '%s\n' "$MARKER"; } > "$WORK/dup" +cp "$WORK/dup" "$WORK/dup.orig" +if ! "$STAMP" "$WORK/dup" v0.4.0 >/dev/null 2>&1; then ok "fails when marker is duplicated"; else bad "accepted a duplicated marker"; fi +if cmp -s "$WORK/dup" "$WORK/dup.orig"; then ok "failed stamp leaves the file untouched"; else bad "file modified on failure"; fi + +# Malformed tags. +for t in 0.4.0 v0.4 latest 'v0.4.0-rc1' ''; do + cp "$LAUNCHER" "$WORK/t" + if ! "$STAMP" "$WORK/t" "$t" >/dev/null 2>&1; then ok "rejects tag '$t'"; else bad "accepted tag '$t'"; fi +done + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" = 0 ] From 3f9c4eb00812c5984653bfb104269ac3504dfece Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:02:32 +0100 Subject: [PATCH 4/6] fix(launcher): publish after the image and verify dmf-init specifically The install command now downloads each asset with its own curl call into a new temporary directory, requires SHA256SUMS to hold exactly one " dmf-init" line, and compares the digest of the downloaded file itself. A file already in the working directory can no longer run, and a checksum file that names another file no longer passes. The launcher job moves into publish-image.yml and runs only after the image build job succeeds. It stamps the launcher with the image digest from that build, so the published launcher points at exactly the image that was pushed. scripts/build-launcher-release.sh holds the stamp-then-checksum order, and a test checks that SHA256SUMS matches the final bytes. The README now says the launcher assets start at v0.4.0. Refs dmfdeploy/dmfdeploy#736 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + .github/workflows/publish-image.yml | 42 +++++++- .github/workflows/release-launcher.yml | 42 -------- README.md | 56 ++++++---- scripts/build-launcher-release.sh | 31 ++++++ scripts/stamp-launcher-image.sh | 33 +++--- test/launcher/test-build-launcher-release.sh | 50 +++++++++ test/launcher/test-install-command.sh | 102 +++++++++++++++---- test/launcher/test-stamp-launcher-image.sh | 27 +++-- 9 files changed, 286 insertions(+), 99 deletions(-) delete mode 100644 .github/workflows/release-launcher.yml create mode 100755 scripts/build-launcher-release.sh create mode 100755 test/launcher/test-build-launcher-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a95a8a7..8f8247f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,8 @@ jobs: run: test/launcher/test-install-command.sh - name: Release stamp pins the launcher image run: test/launcher/test-stamp-launcher-image.sh + - name: Release assets are built in the right order + run: test/launcher/test-build-launcher-release.sh frontend: runs-on: ubuntu-24.04 diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index d4905cd..551d83d 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -1,6 +1,8 @@ name: publish-image # Build the dmf-init container image (amd64+arm64) and push to GHCR per release -# (v* tag) or manual dispatch. Public repo -> free hosted runners; the built-in +# (v* tag) or manual dispatch. For a v* tag, a second job then publishes the +# launcher and its SHA-256 checksum as Release assets, pinned to the digest of +# the image that this run pushed. Public repo -> free hosted runners; the built-in # GITHUB_TOKEN publishes to ghcr.io/dmfdeploy/dmf-init (no PAT). The image fetches # the component repos at RUNTIME, so it needs rebuilding only when dmf-init is # released, not when playbooks change. @@ -20,6 +22,11 @@ concurrency: jobs: build-push: runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 @@ -39,6 +46,7 @@ jobs: type=semver,pattern={{version}} type=raw,value=latest - name: Build and push (amd64 + arm64) + id: build uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . @@ -48,3 +56,35 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + # Runs only after the image was built and pushed. A failed or skipped image + # job means no launcher assets, so a published launcher always has its image. + launcher: + needs: build-push + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Build the launcher and checksum + env: + TAG: ${{ github.ref_name }} + DIGEST: ${{ needs.build-push.outputs.digest }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "tag must look like v1.2.3, got: $TAG" >&2; exit 1; } + [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "no image digest from the build job: $DIGEST" >&2; exit 1; } + # The image job tags semver releases without the leading "v". + scripts/build-launcher-release.sh "ghcr.io/dmfdeploy/dmf-init:${TAG#v}@${DIGEST}" release + grep -n '^DEFAULT_IMAGE=' release/dmf-init + cat release/SHA256SUMS + - name: Attach to Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + files: | + release/dmf-init + release/SHA256SUMS diff --git a/.github/workflows/release-launcher.yml b/.github/workflows/release-launcher.yml deleted file mode 100644 index fc8bd87..0000000 --- a/.github/workflows/release-launcher.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: release-launcher -# Publish the launcher and its SHA-256 checksum as Release assets for every v* -# tag, so the documented install command can fetch a pinned version and verify -# it before running it. The published launcher defaults to the image of the -# same release. The assets are built from the tagged commit, never main. -on: - push: - tags: ["v*"] - -permissions: - contents: write - -jobs: - launcher: - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - name: Stamp the release image and write checksum - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - mkdir release - cp bin/dmf-init release/dmf-init - # Pin the default image to this release before hashing, so the - # checksum covers the stamped file. Fails if the marker is not - # found exactly once. - scripts/stamp-launcher-image.sh release/dmf-init "$TAG" - grep -n '^DEFAULT_IMAGE=' release/dmf-init - # Run inside the directory so the file name in SHA256SUMS is plain - # "dmf-init" and `sha256sum -c` works wherever the user downloads it. - (cd release && sha256sum dmf-init > SHA256SUMS) - cat release/SHA256SUMS - - name: Attach to Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - files: | - release/dmf-init - release/SHA256SUMS diff --git a/README.md b/README.md index 5202349..864246d 100644 --- a/README.md +++ b/README.md @@ -60,32 +60,54 @@ To get started, the `dmf-init` launcher wraps Docker for you; it starts the container with the correct flags, waits until it's ready, extracts the one-time launch link, and opens your browser. No clone required: -Each release publishes the launcher together with a SHA-256 checksum file. The -command below downloads both for one tagged release, checks the launcher against -the checksum, and runs it only if the check passes. If the checksum file is -missing or doesn't match, the command stops before it runs anything. +From v0.4.0 on, each release publishes the launcher together with a SHA-256 +checksum file, after the release's container image is built and pushed. Earlier +releases have neither asset. The command below downloads both for one tagged +release, checks the launcher against the checksum, and runs it only if the check +passes. If a download fails, or the checksum file is missing, malformed, or +doesn't match, the command stops before it runs anything. + +The command does the following, in order: + +1. Changes to a new empty temporary directory, so a file that already exists in + your current directory can never run. +2. Downloads `dmf-init` and `SHA256SUMS` with one `curl` call each, so a failed + download stops the command. +3. Requires `SHA256SUMS` to hold exactly one line: a 64-character lowercase hex + digest, two spaces, and the name `dmf-init`. +4. Computes the digest of `dmf-init` with `sha256sum` (Linux) or `shasum` + (macOS), and compares it with the digest from `SHA256SUMS`. +5. Runs `bash dmf-init up` only if the digests are identical. ```bash version=v0.4.0 base="https://github.com/dmfdeploy/dmf-init/releases/download/${version}" -curl -fsSLO "${base}/dmf-init" -O "${base}/SHA256SUMS" && - { sha256sum -c SHA256SUMS 2>/dev/null || shasum -a 256 -c SHA256SUMS; } && + +cd "$(mktemp -d)" && + curl -fsSL -o dmf-init "${base}/dmf-init" && + curl -fsSL -o SHA256SUMS "${base}/SHA256SUMS" && + [ "$(grep -c '' SHA256SUMS)" -eq 1 ] && + expected="$(sed -n -E 's/^([0-9a-f]{64}) dmf-init$/\1/p' SHA256SUMS)" && + [ -n "${expected}" ] && + actual="$({ sha256sum dmf-init 2>/dev/null || shasum -a 256 dmf-init; } | cut -d ' ' -f 1)" && + [ "${actual}" = "${expected}" ] && bash dmf-init up ``` -The command uses `sha256sum` on Linux and falls back to `shasum -a 256` on macOS. -Run it in an empty directory, because it saves `dmf-init` and `SHA256SUMS` there. -The published launcher starts the container image from the same release. To use -a different image, pass `--image`. - -To choose a version, set `version` to any tag from the -[releases page](https://github.com/dmfdeploy/dmf-init/releases). Use a tag, never -`main`. A checksum from the same release catches a corrupted or partial download. -It doesn't prove who published the release, so read the launcher first if you -want to review it. To read it before you run it, replace the last line with -`less dmf-init`, then run `bash dmf-init up` yourself. +The command saves `dmf-init` and `SHA256SUMS` in the new directory and leaves +your shell there. The published launcher starts the container image that was +built for the same release, pinned by digest. To use a different image, pass +`--image`. + +To choose a version, set `version` to a tag from the +[releases page](https://github.com/dmfdeploy/dmf-init/releases) that lists both +`dmf-init` and `SHA256SUMS` as assets. Use a tag, never `main`. A checksum from +the same release catches a corrupted or partial download. It doesn't prove who +published the release, so read the launcher first if you want to review it. To +read it before you run it, replace the last line with `less dmf-init`, then run +`bash dmf-init up` yourself. From a clone, run `bin/dmf-init`: diff --git a/scripts/build-launcher-release.sh b/scripts/build-launcher-release.sh new file mode 100755 index 0000000..4f5efdb --- /dev/null +++ b/scripts/build-launcher-release.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build the launcher release assets. +# +# scripts/build-launcher-release.sh +# +# Writes /dmf-init (the launcher pinned to ) and +# /SHA256SUMS (one line for that file). The stamp happens before the +# checksum, so the checksum covers the bytes that users download. The release +# workflow calls this script and attaches both files unchanged; a test checks +# that order. +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +ref="$1" +outdir="$2" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +mkdir -p "$outdir" +cp "$here/../bin/dmf-init" "$outdir/dmf-init" +"$here/stamp-launcher-image.sh" "$outdir/dmf-init" "$ref" + +# Hash from inside the directory so the file name in SHA256SUMS is plain +# "dmf-init", which the documented install command requires. +if command -v sha256sum >/dev/null 2>&1; then + (cd "$outdir" && sha256sum dmf-init > SHA256SUMS) +else + (cd "$outdir" && shasum -a 256 dmf-init > SHA256SUMS) +fi diff --git a/scripts/stamp-launcher-image.sh b/scripts/stamp-launcher-image.sh index 592dca6..a67c8e1 100755 --- a/scripts/stamp-launcher-image.sh +++ b/scripts/stamp-launcher-image.sh @@ -1,33 +1,40 @@ #!/usr/bin/env bash -# Pin a launcher's default image to a release. +# Pin a launcher's default image to an exact image reference. # -# scripts/stamp-launcher-image.sh +# scripts/stamp-launcher-image.sh # -# The release workflow runs this on a copy of bin/dmf-init before it computes -# the checksum, so a verified launcher starts the image from the same release -# instead of whatever :latest points to later. The image workflow pushes the -# semver tag without the leading "v", so the stamp drops it too. +# is ghcr.io/dmfdeploy/dmf-init with a tag, a digest, or both: +# ghcr.io/dmfdeploy/dmf-init:0.4.0 +# ghcr.io/dmfdeploy/dmf-init@sha256:<64 hex> +# ghcr.io/dmfdeploy/dmf-init:0.4.0@sha256:<64 hex> +# +# scripts/build-launcher-release.sh runs this on a copy of bin/dmf-init before +# it computes the checksum, so a verified launcher starts the image that the +# release built instead of whatever :latest points to later. # # The script fails unless the marker line appears exactly once. A silent no-op -# would publish a launcher that is still pinned to :latest. +# would publish a launcher that is still pinned to :latest. On any failure the +# launcher file stays as it was. set -euo pipefail if [ "$#" -ne 2 ]; then - echo "usage: $0 " >&2 + echo "usage: $0 " >&2 exit 2 fi file="$1" -tag="$2" +ref="$2" -if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "tag must look like v1.2.3, got: $tag" >&2 +image_repo="ghcr.io/dmfdeploy/dmf-init" +tag_re=':[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}' +digest_re='@sha256:[0-9a-f]{64}' +if ! [[ "$ref" =~ ^${image_repo//./\\.}(${tag_re})?(${digest_re})?$ ]] || [ "$ref" = "$image_repo" ]; then + echo "image ref must be ${image_repo} with a :tag and/or @sha256:<64 hex> digest, got: $ref" >&2 exit 2 fi [ -f "$file" ] || { echo "no such file: $file" >&2; exit 2; } -image_repo="ghcr.io/dmfdeploy/dmf-init" marker="DEFAULT_IMAGE=\"${image_repo}:latest\"" -stamped="DEFAULT_IMAGE=\"${image_repo}:${tag#v}\"" +stamped="DEFAULT_IMAGE=\"${ref}\"" count="$(grep -cFx "$marker" "$file" || true)" if [ "$count" -ne 1 ]; then diff --git a/test/launcher/test-build-launcher-release.sh b/test/launcher/test-build-launcher-release.sh new file mode 100755 index 0000000..d9d97c8 --- /dev/null +++ b/test/launcher/test-build-launcher-release.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Tests scripts/build-launcher-release.sh with no network. The checks run on +# the files the script leaves in the output directory, which are the files the +# release workflow attaches. If a change stamps the launcher after it writes the +# checksum, SHA256SUMS no longer matches dmf-init and this test fails. +# +# test/launcher/test-build-launcher-release.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUILD="$ROOT/scripts/build-launcher-release.sh" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1" >&2; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +DIGEST="sha256:$(printf 'a%.0s' $(seq 64))" +REF="ghcr.io/dmfdeploy/dmf-init:0.4.0@${DIGEST}" +OUT="$WORK/out" + +digest_of() { if command -v sha256sum >/dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d ' ' -f 1; } + +if "$BUILD" "$REF" "$OUT" >"$WORK/log" 2>&1; then + ok "build succeeds" +else + bad "build failed"; cat "$WORK/log" >&2 +fi + +if [ "$(find "$OUT" -type f | wc -l | tr -d ' ')" = 2 ] && [ -f "$OUT/dmf-init" ] && [ -f "$OUT/SHA256SUMS" ]; then ok "output holds exactly dmf-init and SHA256SUMS"; else bad "unexpected output files"; fi + +if grep -qFx "DEFAULT_IMAGE=\"${REF}\"" "$OUT/dmf-init"; then ok "published launcher carries the stamped ref"; else bad "stamped ref missing from the published launcher"; fi +if ! grep -q ':latest"$' <(grep '^DEFAULT_IMAGE=' "$OUT/dmf-init"); then ok "published launcher is no longer :latest"; else bad "still :latest"; fi + +if [ "$(grep -c '' "$OUT/SHA256SUMS")" = 1 ] && grep -qE '^[0-9a-f]{64} dmf-init$' "$OUT/SHA256SUMS"; then ok "SHA256SUMS is one line in the documented form"; else bad "SHA256SUMS form"; fi + +want="$(cut -d ' ' -f 1 "$OUT/SHA256SUMS")" +if [ "$(digest_of "$OUT/dmf-init")" = "$want" ]; then ok "SHA256SUMS matches the final published bytes"; else bad "SHA256SUMS does not match the published launcher"; fi + +# The checksum of the unstamped launcher must differ, so the check above can +# only pass when the stamp came first. +if [ "$(digest_of "$ROOT/bin/dmf-init")" != "$want" ]; then ok "checksum is not that of the unstamped launcher"; else bad "checksum covers the unstamped launcher"; fi + +# A bad ref publishes nothing usable: the script fails and writes no checksum. +rm -rf "$WORK/bad" +if ! "$BUILD" "ghcr.io/dmfdeploy/dmf-init:latest@nope" "$WORK/bad" >/dev/null 2>&1 && [ ! -e "$WORK/bad/SHA256SUMS" ]; then ok "bad image ref fails before any checksum is written"; else bad "bad ref accepted or checksum written"; fi + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" = 0 ] diff --git a/test/launcher/test-install-command.sh b/test/launcher/test-install-command.sh index 8e58e65..3043028 100755 --- a/test/launcher/test-install-command.sh +++ b/test/launcher/test-install-command.sh @@ -26,11 +26,11 @@ else bad "command must pin a version tag and never name main" fi -# Redirect the release URL to a local release dir; replace the launcher run with -# a marker so a pass is visible and nothing real starts. +# Redirect the release URL to a local release dir. The fixture launchers write +# a marker file, so a pass is visible and nothing real starts. REL="$WORK/release" sed -e "s|https://github.com/dmfdeploy/dmf-init/releases/download/|file://${REL}/|" \ - -e 's|bash dmf-init up|echo LAUNCHED > ../ran|' "$WORK/cmd.raw" > "$WORK/cmd.sh" + "$WORK/cmd.raw" > "$WORK/cmd.sh" grep -q "file://${REL}/" "$WORK/cmd.sh" || { echo "URL rewrite failed" >&2; exit 1; } # The fixture release directory follows the version the README pins. @@ -38,35 +38,103 @@ VERSION="$(sed -n 's/^version=\(v[0-9][0-9.]*\).*/\1/p' "$WORK/cmd.raw" | head - [ -n "$VERSION" ] || { echo "no version= line in the install command" >&2; exit 1; } DIR="$REL/$VERSION" +# Two PATH directories of symlinks, so the command sees only one checksum tool. +# Everything else the command needs is linked into both. +mkbin() { + local dir="$1" tool; shift + mkdir -p "$dir" + for tool in bash curl sed grep cut mktemp rm cat wc env perl dirname uname "$@"; do + ln -sf "$(command -v "$tool")" "$dir/$tool" + done +} +HAVE_SHA256SUM=0; command -v sha256sum >/dev/null && HAVE_SHA256SUM=1 +HAVE_SHASUM=0; command -v shasum >/dev/null && HAVE_SHASUM=1 +[ "$HAVE_SHA256SUM" = 1 ] && mkbin "$WORK/bin-sha256sum" sha256sum +[ "$HAVE_SHASUM" = 1 ] && mkbin "$WORK/bin-shasum" shasum + sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } -# $1 label, $2 expected exit (0 = runs, 1 = refuses), $3 setup function name +export RAN_FILE="$WORK/ran" +export TMPDIR="$WORK/tmp"; mkdir -p "$TMPDIR" + +# shellcheck disable=SC2016 # $RAN_FILE expands when the fixture runs +fixture_launcher() { printf '#!/usr/bin/env bash\necho LAUNCHED > "$RAN_FILE"\n'; } + +# $1 tool label, $2 PATH dir, $3 case label, $4 expected (0 = runs, 1 = refuses), $5 setup function run_case() { - local label="$1" want="$2" setup="$3" rc=0 - rm -rf "$REL" "$WORK/ran" "$WORK/run" + local tool="$1" bindir="$2" label="$3" want="$4" setup="$5" rc=0 + rm -rf "$REL" "$RAN_FILE" "$WORK/run" mkdir -p "$DIR" "$WORK/run/sub" - printf '#!/usr/bin/env bash\necho hello\n' > "$DIR/dmf-init" + fixture_launcher > "$DIR/dmf-init" "$setup" - ( cd "$WORK/run/sub" && bash "$WORK/cmd.sh" ) >"$WORK/out" 2>&1 || rc=$? + ( cd "$WORK/run/sub" && PATH="$bindir" bash "$WORK/cmd.sh" ) >"$WORK/out" 2>&1 || rc=$? if [ "$want" = 0 ]; then - if [ "$rc" = 0 ] && [ -f "$WORK/run/ran" ]; then ok "$label"; else bad "$label (rc=$rc)"; cat "$WORK/out" >&2; fi + if [ "$rc" = 0 ] && [ -f "$RAN_FILE" ]; then ok "[$tool] $label"; else bad "[$tool] $label (rc=$rc)"; cat "$WORK/out" >&2; fi else - if [ "$rc" != 0 ] && [ ! -f "$WORK/run/ran" ]; then ok "$label"; else bad "$label (rc=$rc, launcher ran: $([ -f "$WORK/run/ran" ] && echo yes || echo no))"; cat "$WORK/out" >&2; fi + if [ "$rc" != 0 ] && [ ! -f "$RAN_FILE" ]; then ok "[$tool] $label"; else bad "[$tool] $label (rc=$rc, launcher ran: $([ -f "$RAN_FILE" ] && echo yes || echo no))"; cat "$WORK/out" >&2; fi fi } good_sums() { ( cd "$DIR" && sha dmf-init > SHA256SUMS ); } -wrong_sums() { ( cd "$DIR" && printf '%064d dmf-init\n' 0 > SHA256SUMS ); } +wrong_sums() { printf '%064d dmf-init\n' 0 > "$DIR/SHA256SUMS"; } empty_sums() { : > "$DIR/SHA256SUMS"; } no_sums() { :; } tampered() { good_sums; printf 'echo pwned\n' >> "$DIR/dmf-init"; } -echo "install command:" -run_case "runs the launcher when the checksum matches" 0 good_sums -run_case "refuses on a wrong checksum" 1 wrong_sums -run_case "refuses when the launcher changed after checksum" 1 tampered -run_case "refuses on an empty checksum file" 1 empty_sums -run_case "refuses when the checksum file is missing" 1 no_sums +# An unrelated file exists in the download directory and the checksum names it. +unrelated_sums() { + printf 'unrelated\n' > "$DIR/unrelated" + ( cd "$DIR" && sha unrelated > SHA256SUMS ) +} +# The checksum file lists dmf-init and an unrelated file. +extra_entry_sums() { + printf 'unrelated\n' > "$DIR/unrelated" + ( cd "$DIR" && { sha dmf-init; sha unrelated; } > SHA256SUMS ) +} +good_then_junk() { ( cd "$DIR" && { sha dmf-init; printf 'junk'; } > SHA256SUMS ); } +duplicate_sums() { ( cd "$DIR" && { sha dmf-init; sha dmf-init; } > SHA256SUMS ); } +blank_line_sums() { ( cd "$DIR" && { sha dmf-init; printf '\n'; } > SHA256SUMS ); } +one_space_sums() { ( cd "$DIR" && printf '%s dmf-init\n' "$(sha dmf-init | cut -d' ' -f1)" > SHA256SUMS ); } +upper_sums() { ( cd "$DIR" && printf '%s dmf-init\n' "$(sha dmf-init | cut -d' ' -f1 | tr a-f A-F)" > SHA256SUMS ); } +short_sums() { ( cd "$DIR" && printf '%s dmf-init\n' "$(sha dmf-init | cut -c1-63)" > SHA256SUMS ); } +other_name_sums() { ( cd "$DIR" && sha dmf-init | sed 's/dmf-init/other/' > SHA256SUMS ); } +path_name_sums() { ( cd "$DIR" && sha dmf-init | sed 's| dmf-init| ./dmf-init|' > SHA256SUMS ); } +crlf_sums() { ( cd "$DIR" && sha dmf-init | sed 's/$/\r/' > SHA256SUMS ); } +garbage_sums() { printf 'not a checksum file\n' > "$DIR/SHA256SUMS"; } + +# The starting directory already holds a launcher that would write the marker. +plant_launcher() { fixture_launcher > "$WORK/run/sub/dmf-init"; } +plant_pair() { plant_launcher; ( cd "$WORK/run/sub" && sha dmf-init > SHA256SUMS ); } + +missing_launcher() { good_sums; rm -f "$DIR/dmf-init"; } +missing_launcher_planted() { plant_pair; good_sums; rm -f "$DIR/dmf-init"; } +missing_sums_planted() { plant_pair; } + +for tool in sha256sum shasum; do + if [ "$tool" = sha256sum ]; then bindir="$WORK/bin-sha256sum"; have="$HAVE_SHA256SUM"; else bindir="$WORK/bin-shasum"; have="$HAVE_SHASUM"; fi + if [ "$have" != 1 ]; then echo " skip [$tool] not installed on this machine"; continue; fi + echo "install command, checksum tool: $tool" + run_case "$tool" "$bindir" "runs the launcher when the checksum matches" 0 good_sums + run_case "$tool" "$bindir" "refuses on a wrong checksum" 1 wrong_sums + run_case "$tool" "$bindir" "refuses when the launcher changed after checksum" 1 tampered + run_case "$tool" "$bindir" "refuses on an empty checksum file" 1 empty_sums + run_case "$tool" "$bindir" "refuses when the checksum file is missing" 1 no_sums + run_case "$tool" "$bindir" "refuses when the sums name an unrelated file" 1 unrelated_sums + run_case "$tool" "$bindir" "refuses extra entries beside dmf-init" 1 extra_entry_sums + run_case "$tool" "$bindir" "refuses a trailing line without a newline" 1 good_then_junk + run_case "$tool" "$bindir" "refuses a duplicated dmf-init line" 1 duplicate_sums + run_case "$tool" "$bindir" "refuses a trailing blank line" 1 blank_line_sums + run_case "$tool" "$bindir" "refuses one space between digest and name" 1 one_space_sums + run_case "$tool" "$bindir" "refuses an uppercase digest" 1 upper_sums + run_case "$tool" "$bindir" "refuses a short digest" 1 short_sums + run_case "$tool" "$bindir" "refuses a different file name" 1 other_name_sums + run_case "$tool" "$bindir" "refuses a ./ path in the name" 1 path_name_sums + run_case "$tool" "$bindir" "refuses CRLF line endings" 1 crlf_sums + run_case "$tool" "$bindir" "refuses a non-checksum file" 1 garbage_sums + run_case "$tool" "$bindir" "refuses a missing launcher (second transfer ok)" 1 missing_launcher + run_case "$tool" "$bindir" "missing launcher never runs a dmf-init already in the start directory" 1 missing_launcher_planted + run_case "$tool" "$bindir" "missing checksum never runs a dmf-init already in the start directory" 1 missing_sums_planted +done echo "$PASS passed, $FAIL failed" [ "$FAIL" = 0 ] diff --git a/test/launcher/test-stamp-launcher-image.sh b/test/launcher/test-stamp-launcher-image.sh index 5cf9ba5..0f019bd 100755 --- a/test/launcher/test-stamp-launcher-image.sh +++ b/test/launcher/test-stamp-launcher-image.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Tests scripts/stamp-launcher-image.sh with no network: the stamp changes # exactly one line of the real launcher, and refuses a missing or duplicated -# marker or a malformed tag. +# marker or a malformed image ref. # # test/launcher/test-stamp-launcher-image.sh set -euo pipefail @@ -21,13 +21,15 @@ MARKER='DEFAULT_IMAGE="ghcr.io/dmfdeploy/dmf-init:latest"' # The repo launcher carries the marker exactly once. if [ "$(grep -cFx "$MARKER" "$LAUNCHER")" = 1 ]; then ok "launcher has the marker once"; else bad "launcher marker count"; fi -# Happy path: one changed line, right image ref, tag without the "v". +D64="$(printf 'b%.0s' $(seq 64))" +REPO=ghcr.io/dmfdeploy/dmf-init +# Happy path: one changed line, exactly the ref we pass. cp "$LAUNCHER" "$WORK/l" -if "$STAMP" "$WORK/l" v0.4.0 >"$WORK/out" 2>&1; then +if "$STAMP" "$WORK/l" "$REPO:0.4.0@sha256:$D64" >"$WORK/out" 2>&1; then changed="$(diff "$LAUNCHER" "$WORK/l" | grep -c '^>' || true)" removed="$(diff "$LAUNCHER" "$WORK/l" | grep -c '^<' || true)" if [ "$changed" = 1 ] && [ "$removed" = 1 ]; then ok "stamp changes exactly one line"; else bad "changed=$changed removed=$removed"; fi - if grep -qFx 'DEFAULT_IMAGE="ghcr.io/dmfdeploy/dmf-init:0.4.0"' "$WORK/l"; then ok "stamped image ref is 0.4.0 (no v)"; else bad "stamped ref wrong"; fi + if grep -qFx "DEFAULT_IMAGE=\"$REPO:0.4.0@sha256:$D64\"" "$WORK/l"; then ok "stamped image ref is tag plus digest"; else bad "stamped ref wrong"; fi if ! grep -q '^DEFAULT_IMAGE=.*:latest' "$WORK/l"; then ok "default image is no longer :latest"; else bad "default image still :latest"; fi if [ -x "$LAUNCHER" ] && cmp -s <(tail -n +2 "$LAUNCHER" | grep -vF DEFAULT_IMAGE=) <(tail -n +2 "$WORK/l" | grep -vF DEFAULT_IMAGE=); then ok "rest of the file is byte-identical"; else bad "rest of file differs"; fi # --image must still override: IMAGE starts from DEFAULT_IMAGE and the parser sets it. @@ -38,18 +40,25 @@ fi # Missing marker. printf '#!/usr/bin/env bash\necho hi\n' > "$WORK/none" -if ! "$STAMP" "$WORK/none" v0.4.0 >/dev/null 2>&1; then ok "fails when marker is missing"; else bad "accepted a missing marker"; fi +if ! "$STAMP" "$WORK/none" "$REPO:0.4.0" >/dev/null 2>&1; then ok "fails when marker is missing"; else bad "accepted a missing marker"; fi # Duplicated marker. { cat "$LAUNCHER"; printf '%s\n' "$MARKER"; } > "$WORK/dup" cp "$WORK/dup" "$WORK/dup.orig" -if ! "$STAMP" "$WORK/dup" v0.4.0 >/dev/null 2>&1; then ok "fails when marker is duplicated"; else bad "accepted a duplicated marker"; fi +if ! "$STAMP" "$WORK/dup" "$REPO:0.4.0" >/dev/null 2>&1; then ok "fails when marker is duplicated"; else bad "accepted a duplicated marker"; fi if cmp -s "$WORK/dup" "$WORK/dup.orig"; then ok "failed stamp leaves the file untouched"; else bad "file modified on failure"; fi -# Malformed tags. -for t in 0.4.0 v0.4 latest 'v0.4.0-rc1' ''; do +# Tag-only and digest-only refs are accepted. +for r in "$REPO:0.4.0" "$REPO@sha256:$D64"; do cp "$LAUNCHER" "$WORK/t" - if ! "$STAMP" "$WORK/t" "$t" >/dev/null 2>&1; then ok "rejects tag '$t'"; else bad "accepted tag '$t'"; fi + if "$STAMP" "$WORK/t" "$r" >/dev/null 2>&1 && grep -qFx "DEFAULT_IMAGE=\"$r\"" "$WORK/t"; then ok "accepts '$r'"; else bad "rejected '$r'"; fi +done + +# Malformed refs leave the file untouched. +for r in '' "$REPO" "$REPO:" "$REPO@sha256:abc" "$REPO@sha256:${D64}0" "$REPO@sha256:$(printf %s "$D64" | tr a-f A-F)" "$REPO:0.4.0@md5:$D64" \ + "other.io/dmfdeploy/dmf-init:0.4.0" "$REPO:0.4.0 extra" "$REPO:a\"b" 'v0.4.0'; do + cp "$LAUNCHER" "$WORK/t" + if ! "$STAMP" "$WORK/t" "$r" >/dev/null 2>&1 && cmp -s "$LAUNCHER" "$WORK/t"; then ok "rejects ref '$r'"; else bad "accepted ref '$r'"; fi done printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" From 43d17db6e5ac1774baeaecb84f020c6da0ecd739 Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:10:11 +0100 Subject: [PATCH 5/6] fix(launcher): stop the install command when mktemp fails On macOS, `cd ""` succeeds and stays in the current directory, so a failed mktemp let the downloads overwrite files there and run a launcher from it. The README command now creates the directory into a variable and requires a non-empty path to an existing directory before it changes into it. The install-command test now serves a local HTTP fixture that returns 404 and 500 responses with valid bodies, so removing `curl -f` fails a test. It also shadows mktemp with a failing stub in bash and zsh and checks that planted files and a symlink stay unchanged. CI requires python3 for the HTTP cases. Refs dmfdeploy/dmfdeploy#736 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + README.md | 11 ++- test/launcher/test-install-command.sh | 130 +++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f8247f..cfd5ddf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: with: persist-credentials: false - name: Install command fails closed on a bad checksum + env: + REQUIRE_HTTP_FIXTURE: "1" run: test/launcher/test-install-command.sh - name: Release stamp pins the launcher image run: test/launcher/test-stamp-launcher-image.sh diff --git a/README.md b/README.md index 864246d..f48baea 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,10 @@ doesn't match, the command stops before it runs anything. The command does the following, in order: -1. Changes to a new empty temporary directory, so a file that already exists in - your current directory can never run. +1. Creates a new empty temporary directory and changes to it, so a file that + already exists in your current directory can never run or be overwritten. If + the directory can't be created, the command stops before it downloads + anything. 2. Downloads `dmf-init` and `SHA256SUMS` with one `curl` call each, so a failed download stops the command. 3. Requires `SHA256SUMS` to hold exactly one line: a 64-character lowercase hex @@ -84,7 +86,10 @@ The command does the following, in order: version=v0.4.0 base="https://github.com/dmfdeploy/dmf-init/releases/download/${version}" -cd "$(mktemp -d)" && +dir="$(mktemp -d)" && + [ -n "${dir}" ] && + [ -d "${dir}" ] && + cd "${dir}" && curl -fsSL -o dmf-init "${base}/dmf-init" && curl -fsSL -o SHA256SUMS "${base}/SHA256SUMS" && [ "$(grep -c '' SHA256SUMS)" -eq 1 ] && diff --git a/test/launcher/test-install-command.sh b/test/launcher/test-install-command.sh index 3043028..161cb9e 100755 --- a/test/launcher/test-install-command.sh +++ b/test/launcher/test-install-command.sh @@ -12,7 +12,12 @@ ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1" >&2; } WORK="$(mktemp -d)" -trap 'rm -rf "$WORK"' EXIT +SERVER_PID="" +cleanup() { + if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true; fi + rm -rf "$WORK" +} +trap cleanup EXIT # Pull the fenced block between the README markers. awk '/install-command:start/{on=1;next} /install-command:end/{on=0} on && !/^```/' \ @@ -136,5 +141,128 @@ for tool in sha256sum shasum; do run_case "$tool" "$bindir" "missing checksum never runs a dmf-init already in the start directory" 1 missing_sums_planted done + +# --- HTTP error statuses --------------------------------------------------- +# file:// URLs fail on a missing file even without `curl -f`, so they can't show +# that the command rejects an HTTP error page. A local server returns real +# status codes with a body that would pass the checksum if curl kept it. +# CI sets REQUIRE_HTTP_FIXTURE=1 so a missing python3 fails instead of skipping. +cat > "$WORK/server.py" <<'PY' +import http.server, os, sys + +ROOT, PORTFILE = sys.argv[1], sys.argv[2] +# The first path segment picks the behaviour: it names the file that answers +# with an error status, or "ok" for none. The error response still carries the +# real file as its body. +FAIL = {"launcher404": ("dmf-init", 404), "sums500": ("SHA256SUMS", 500)} + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + path = os.path.normpath(os.path.join(ROOT, self.path.lstrip("/"))) + mode = self.path.lstrip("/").split("/", 1)[0] + if not path.startswith(ROOT + os.sep) or not os.path.isfile(path): + self.send_response(404); self.end_headers(); return + status = 200 + name, code = FAIL.get(mode, (None, 200)) + if os.path.basename(path) == name: + status = code + with open(path, "rb") as f: + body = f.read() + self.send_response(status) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *a): + pass + +srv = http.server.HTTPServer(("127.0.0.1", 0), Handler) +with open(PORTFILE, "w") as f: + f.write(str(srv.server_address[1])) +srv.serve_forever() +PY + +start_server() { + mkdir -p "$WORK/http" + python3 -I "$WORK/server.py" "$WORK/http" "$WORK/port" & + SERVER_PID=$! + local i + for i in $(seq 1 50); do [ -s "$WORK/port" ] && return 0; sleep 0.1; done + return 1 +} + +# $1 tool label, $2 PATH dir, $3 mode (ok|launcher404|sums500), $4 expected, $5 label +run_http_case() { + local tool="$1" bindir="$2" mode="$3" want="$4" label="$5" rc=0 + rm -rf "$WORK/http"/* "$RAN_FILE" "$WORK/run" + mkdir -p "$WORK/http/$mode/$VERSION" "$WORK/run/sub" + fixture_launcher > "$WORK/http/$mode/$VERSION/dmf-init" + ( cd "$WORK/http/$mode/$VERSION" && sha dmf-init > SHA256SUMS ) + sed -e "s|https://github.com/dmfdeploy/dmf-init/releases/download/|http://127.0.0.1:${PORT}/${mode}/|" \ + "$WORK/cmd.raw" > "$WORK/cmd-http.sh" + grep -q "http://127.0.0.1:${PORT}/${mode}/" "$WORK/cmd-http.sh" || { echo "URL rewrite failed" >&2; exit 1; } + ( cd "$WORK/run/sub" && PATH="$bindir" bash "$WORK/cmd-http.sh" ) >"$WORK/out" 2>&1 || rc=$? + local stray; stray="$(ls -A "$WORK/run/sub")" + if [ "$want" = 0 ]; then + if [ "$rc" = 0 ] && [ -f "$RAN_FILE" ] && [ -z "$stray" ]; then ok "[$tool] $label"; else bad "[$tool] $label (rc=$rc)"; cat "$WORK/out" >&2; fi + else + if [ "$rc" != 0 ] && [ ! -f "$RAN_FILE" ] && [ -z "$stray" ]; then ok "[$tool] $label"; else bad "[$tool] $label (rc=$rc, launcher ran: $([ -f "$RAN_FILE" ] && echo yes || echo no), start dir: ${stray:-empty})"; cat "$WORK/out" >&2; fi + fi +} + +if ! command -v python3 >/dev/null; then + if [ "${REQUIRE_HTTP_FIXTURE:-0}" = 1 ]; then bad "python3 is required for the HTTP status cases"; else echo " skip HTTP status cases: python3 not installed"; fi +elif ! start_server; then + bad "HTTP fixture server did not start" +else + PORT="$(cat "$WORK/port")" + for tool in sha256sum shasum; do + if [ "$tool" = sha256sum ]; then bindir="$WORK/bin-sha256sum"; have="$HAVE_SHA256SUM"; else bindir="$WORK/bin-shasum"; have="$HAVE_SHASUM"; fi + [ "$have" = 1 ] || continue + echo "install command over HTTP, checksum tool: $tool" + run_http_case "$tool" "$bindir" ok 0 "runs the launcher on 200 responses" + run_http_case "$tool" "$bindir" launcher404 1 "refuses a 404 launcher with a body that matches the checksum" + run_http_case "$tool" "$bindir" sums500 1 "refuses a 500 checksum file with a valid body" + done +fi + +# --- mktemp failure -------------------------------------------------------- +# On macOS `cd ""` succeeds and stays put, so a failed mktemp must stop the +# command before any download. The start directory holds a launcher and a +# matching checksum that the release would also match, and SHA256SUMS is a +# symlink whose target a download could overwrite. +mktemp_fail_case() { + local shell_name="$1" shell_path="$2" rc=0 + rm -rf "$REL" "$RAN_FILE" "$WORK/run" + mkdir -p "$DIR" "$WORK/run/sub" + fixture_launcher > "$DIR/dmf-init" + good_sums + mkdir -p "$WORK/failbin" + mkbin "$WORK/failbin" sha256sum + [ "$HAVE_SHA256SUM" = 1 ] || mkbin "$WORK/failbin" shasum + rm -f "$WORK/failbin/mktemp" + printf '#!/bin/sh\nexit 1\n' > "$WORK/failbin/mktemp"; chmod +x "$WORK/failbin/mktemp" + fixture_launcher > "$WORK/run/sub/dmf-init" + printf 'VICTIM\n' > "$WORK/run/victim" + ln -s ../victim "$WORK/run/sub/SHA256SUMS" + local before_launcher before_victim + before_launcher="$(cat "$WORK/run/sub/dmf-init")"; before_victim="$(cat "$WORK/run/victim")" + ( cd "$WORK/run/sub" && PATH="$WORK/failbin" "$shell_path" "$WORK/cmd.sh" ) >"$WORK/out" 2>&1 || rc=$? + if [ "$rc" != 0 ] && [ ! -f "$RAN_FILE" ] \ + && [ "$(cat "$WORK/run/sub/dmf-init")" = "$before_launcher" ] \ + && [ "$(cat "$WORK/run/victim")" = "$before_victim" ] \ + && [ -L "$WORK/run/sub/SHA256SUMS" ] \ + && [ "$(ls -A "$WORK/run/sub" | wc -l | tr -d ' ')" = 2 ]; then + ok "[$shell_name] a failed mktemp stops before any download or run" + else + bad "[$shell_name] a failed mktemp stops before any download or run (rc=$rc, launcher ran: $([ -f "$RAN_FILE" ] && echo yes || echo no))"; cat "$WORK/out" >&2 + fi +} + +echo "install command, mktemp failure" +if [ "$HAVE_SHA256SUM" = 1 ] || [ "$HAVE_SHASUM" = 1 ]; then + mktemp_fail_case bash "$(command -v bash)" + if command -v zsh >/dev/null; then mktemp_fail_case zsh "$(command -v zsh)"; else echo " skip [zsh] not installed on this machine"; fi +fi + echo "$PASS passed, $FAIL failed" [ "$FAIL" = 0 ] From 67b086d6f053e77ced1dddea7488f9eacc5f536d Mon Sep 17 00:00:00 2001 From: znerol2 <6800371+znerol2@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:10:23 +0100 Subject: [PATCH 6/6] test(launcher): satisfy shellcheck in the install command test Refs dmfdeploy/dmfdeploy#736 Signed-off-by: znerol2 <6800371+znerol2@users.noreply.github.com> --- test/launcher/test-install-command.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/launcher/test-install-command.sh b/test/launcher/test-install-command.sh index 161cb9e..fb4f772 100755 --- a/test/launcher/test-install-command.sh +++ b/test/launcher/test-install-command.sh @@ -185,8 +185,7 @@ start_server() { mkdir -p "$WORK/http" python3 -I "$WORK/server.py" "$WORK/http" "$WORK/port" & SERVER_PID=$! - local i - for i in $(seq 1 50); do [ -s "$WORK/port" ] && return 0; sleep 0.1; done + for _ in $(seq 1 50); do [ -s "$WORK/port" ] && return 0; sleep 0.1; done return 1 } @@ -251,7 +250,7 @@ mktemp_fail_case() { && [ "$(cat "$WORK/run/sub/dmf-init")" = "$before_launcher" ] \ && [ "$(cat "$WORK/run/victim")" = "$before_victim" ] \ && [ -L "$WORK/run/sub/SHA256SUMS" ] \ - && [ "$(ls -A "$WORK/run/sub" | wc -l | tr -d ' ')" = 2 ]; then + && [ "$(find "$WORK/run/sub" -mindepth 1 | wc -l | tr -d ' ')" = 2 ]; then ok "[$shell_name] a failed mktemp stops before any download or run" else bad "[$shell_name] a failed mktemp stops before any download or run (rc=$rc, launcher ran: $([ -f "$RAN_FILE" ] && echo yes || echo no))"; cat "$WORK/out" >&2