diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85ffc96..cfd5ddf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,22 @@ 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 + 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 + - name: Release assets are built in the right order + run: test/launcher/test-build-launcher-release.sh + frontend: runs-on: ubuntu-24.04 timeout-minutes: 10 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/README.md b/README.md index 39808f9..f48baea 100644 --- a/README.md +++ b/README.md @@ -60,19 +60,59 @@ 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: - -```bash -curl -fsSL https://raw.githubusercontent.com/dmfdeploy/dmf-init/main/bin/dmf-init -o dmf-init -less dmf-init -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: - +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. 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 + 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 -curl -fsSL https://raw.githubusercontent.com/dmfdeploy/dmf-init/main/bin/dmf-init | bash -s -- up +version=v0.4.0 +base="https://github.com/dmfdeploy/dmf-init/releases/download/${version}" + +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 ] && + 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 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/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/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 new file mode 100755 index 0000000..a67c8e1 --- /dev/null +++ b/scripts/stamp-launcher-image.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Pin a launcher's default image to an exact image reference. +# +# scripts/stamp-launcher-image.sh +# +# 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. On any failure the +# launcher file stays as it was. +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +file="$1" +ref="$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; } + +marker="DEFAULT_IMAGE=\"${image_repo}:latest\"" +stamped="DEFAULT_IMAGE=\"${ref}\"" + +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-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 new file mode 100755 index 0000000..fb4f772 --- /dev/null +++ b/test/launcher/test-install-command.sh @@ -0,0 +1,267 @@ +#!/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)" +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 && !/^```/' \ + "$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. 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}/|" \ + "$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" + +# 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; } + +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 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" + fixture_launcher > "$DIR/dmf-init" + "$setup" + ( cd "$WORK/run/sub" && PATH="$bindir" bash "$WORK/cmd.sh" ) >"$WORK/out" 2>&1 || rc=$? + if [ "$want" = 0 ]; then + 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 "$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() { printf '%064d dmf-init\n' 0 > "$DIR/SHA256SUMS"; } +empty_sums() { : > "$DIR/SHA256SUMS"; } +no_sums() { :; } +tampered() { good_sums; printf 'echo pwned\n' >> "$DIR/dmf-init"; } + +# 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 + + +# --- 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=$! + for _ 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" ] \ + && [ "$(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 + 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 ] diff --git a/test/launcher/test-stamp-launcher-image.sh b/test/launcher/test-stamp-launcher-image.sh new file mode 100755 index 0000000..0f019bd --- /dev/null +++ b/test/launcher/test-stamp-launcher-image.sh @@ -0,0 +1,65 @@ +#!/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 image ref. +# +# 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 + +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" "$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=\"$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. + 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" "$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" "$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 + +# 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" "$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" +[ "$FAIL" = 0 ]