diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfdd808..685b593 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: tests: env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FIXTURE_REF: ${{ github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -66,7 +66,7 @@ jobs: tests-fedora: env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FIXTURE_REF: ${{ github.event.pull_request.head.sha || github.sha }} runs-on: ubuntu-latest timeout-minutes: 30 container: fedora:latest diff --git a/README.md b/README.md index ff00ee0..7e85b38 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ asset directly. ### Requirements -- `curl` and `jq` — always -- `minisign` and `base64` — only when the project configures `pubkey` +- `curl` and a POSIX `awk`/`sed`/`grep` — always (every supported system has them) +- `minisign` and `base64` — only when the project configures `pubkey`; a missing + `minisign` is offered for installation through the system package manager - `dpkg-deb`/`rpm` — to inspect `.deb`/`.rpm` packages before installing - `tar` — for macOS `.app.tar.gz` installs - `sudo` and the relevant package manager (apt/dnf/zypper, or `dpkg`/`rpm` directly) — @@ -27,9 +28,10 @@ asset directly. 1. **The engine** (`install.sh`, this repo) knows nothing about any specific project. - - It reads the project's Tauri updater manifest to pick the right platform asset, or - looks up a `.deb`/`.rpm` on the GitHub Releases API directly — no exact filename - needs to be configured anywhere. + - It reads the project's Tauri updater manifest to pick the right platform asset — + the AppImage, `.app`, or a `.deb`/`.rpm` the manifest declares under its own key. + The manifest is the only source of assets: no filename is configured anywhere, and + nothing is rediscovered from the GitHub API. - On Linux, when a native package manager (apt/dnf/zypper) is detected and both a native package (`.deb`/`.rpm`) and an AppImage are available, it asks which to install (native is the default) — so updates can keep flowing through the system @@ -94,7 +96,7 @@ Lives at the root of each project's own repo. | `schema_version` | ![yes](https://img.shields.io/badge/Yes-brightgreen) | Must currently be `1` — the engine hard-errors on anything else | | `project_name` | ![yes](https://img.shields.io/badge/Yes-brightgreen) | Used for default install/uninstall paths, desktop-entry and icon file names, and log messages. Letters, digits, `.`, `_`, `-` only, and cannot be exactly `.` or `..` — it ends up directly in filesystem paths | | `command_name` | ![no](https://img.shields.io/badge/No-red) | The command users type in a terminal (`modrex`, `refract`). Defaults to `project_name`; same character rules. It names the AppImage/macOS symlink in `install_dir` and is what the engine looks up on `PATH` to report availability. Native packages must ship it as `/usr/bin/{command_name}` themselves — the engine reports what a package actually provides, it never renames or aliases | -| `github_repo` | ![no](https://img.shields.io/badge/No-red) | `owner/repo`. Used to look up `.deb`/`.rpm` assets via the GitHub Releases API when apt/dnf/zypper is detected — without it, Linux package-manager users fall back to the AppImage | +| `github_repo` | ![no](https://img.shields.io/badge/No-red) | Deprecated. Accepted and ignored: native packages come from the manifest's `linux-*-deb`/`-rpm` keys, never from the GitHub Releases API. Slated for removal in the next major | | `manifest_url` | ![yes](https://img.shields.io/badge/Yes-brightgreen) | Tauri updater manifest URL, normally the `/releases/latest/download/latest.json` alias. Must be `https://` | | `pubkey` | ![no](https://img.shields.io/badge/No-red) | The raw minisign public key string (starts with `RW...`), *not* the value stored directly in `tauri.conf.json`'s `plugins.updater.pubkey` — that field is itself base64-encoded content of a full minisign pubkey file (comment line + key line). Decode it once (`echo "$TAURI_PUBKEY" \| base64 -d`) and take only the second line. Once set, the engine requires `minisign` and a valid signature — it refuses to install rather than silently skip verification. Leave unset until the project has real signing configured | | `preferred_variant` | ![no](https://img.shields.io/badge/No-red) | Per-OS suffix to prefer when the manifest has multiple platform-key variants for the same OS/arch (e.g. `linux-x86_64-deb` vs `linux-x86_64-appimage`). Takes priority over `.deb`/`.rpm` discovery via `github_repo` | @@ -170,17 +172,10 @@ Nothing is claimed about future terminals, because nothing about them can be obs ## Known limitations -- `.deb`/`.rpm` installs, discovered via the GitHub Releases API, are never - signature-verified — those assets aren't part of the signed Tauri updater manifest, - so mget doesn't currently consume any separate integrity metadata (hash or detached - signature) for them. Only the - manifest-sourced asset (normally the AppImage) gets minisign verification when a - project configures `pubkey`. The downloaded package's architecture/name metadata *is* - checked before install — that catches a wrong-architecture asset and rejects packages - whose metadata can't be read, but it's not the same guarantee as verifying it's the - artifact the maintainer actually intended to release. -- The GitHub Releases API is unauthenticated and rate-limited to 60 requests/hour per - IP — fine for individual installs, but worth knowing. +- A `.deb`/`.rpm` is installed only when the manifest declares it under + `linux-{arch}-deb`/`-rpm` (Tauri's `createUpdaterArtifacts` writes these keys, with + signatures). A project whose manifest lacks them gets the AppImage on every Linux + system, package manager or not. - Lines that earlier engine releases appended to shell rc files are left in place; they are harmless and no longer written. @@ -225,8 +220,8 @@ that catches the most common way that discipline slips. Changes that are deliber held back for the next major are listed in [docs/planned-breaking-changes.md](docs/planned-breaking-changes.md). -The two API-resolved forms add a third fetch (GitHub tags/releases API) on top of the -two the Worker already makes, subject to the same 60 req/hour/IP limit noted above. +The two API-resolved forms add a third fetch (GitHub tags API, unauthenticated and +rate-limited to 60 requests/hour per IP) on top of the two the Worker already makes. ### Quoting config values safely @@ -378,6 +373,6 @@ CFG_INSTALL_DIR='$HOME/.local/bin' \ sh install.sh --dry-run ``` -`sh tests/run.sh` runs the behaviour tests (needs `jq`, `minisign`, network access, +`sh tests/run.sh` runs the behaviour tests (needs `jq` as the reference JSON reader, `minisign`, network access, and either apt with passwordless `sudo` or dnf as root; each case uses a throwaway `HOME`). `TESTS="t_sudo_is_refused_before_network"` selects individual cases. diff --git a/docs/planned-breaking-changes.md b/docs/planned-breaking-changes.md index 9baa5b8..8b5dd52 100644 --- a/docs/planned-breaking-changes.md +++ b/docs/planned-breaking-changes.md @@ -20,6 +20,17 @@ gets removed, the migration, and the condition that makes it safe to execute. - **Migration:** consumers delete the key; nothing else changes. - **Safe when:** the next major is cut. +## Remove `github_repo` + +- **Now:** read and ignored. Native packages are taken from the manifest's + `linux-{arch}-deb`/`-rpm` keys; the GitHub Releases API is never queried. +- **Kept because:** removing a `CFG_*` variable is a breaking change by this + repository's rule, and both consumer configs still set the key. +- **Then:** the key is unknown to the engine and absent from the schema. +- **Removes:** one assignment in `install.sh`, one row in the README. +- **Migration:** consumers delete the key; nothing else changes. +- **Safe when:** the next major is cut. + ## Remove `macos_executable_name` - **Now:** optional override; the engine reads `CFBundleExecutable` from the bundle's diff --git a/install.sh b/install.sh index 9ce0cd2..dce90ff 100644 --- a/install.sh +++ b/install.sh @@ -139,15 +139,64 @@ err() { [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && printf '\033[1;31merror:\033[0m % have() { command -v "$1" >/dev/null 2>&1; } have curl || err "curl is required" -have jq || err "jq is required (e.g. 'apt install jq' / 'brew install jq')" - -# Signature verification tools are checked here rather than at their point of -# use in verify_signature(), which runs only after the asset is fully -# downloaded — failing there wastes a download on a problem knowable upfront. -if [ -n "$CFG_PUBKEY" ]; then - have minisign || err "minisign is required to verify $CFG_PROJECT_NAME's signature (e.g. 'pacman -S minisign' / 'apt install minisign' / 'dnf install minisign' / 'brew install minisign')" - have base64 || err "base64 is required for signature verification" -fi +[ -z "$CFG_PUBKEY" ] || have base64 || err "base64 is required for signature verification" + +# Flattens a JSON document to one "pathvalue" line per scalar, arrays +# indexed numerically. Deliberately narrow: only \" \\ \/ are decoded, every +# other escape (\n \t \uXXXX ...) is kept as its escaped text, and raw control +# characters are rejected, so a value can never contain a newline or a tab and +# forge another line or split a path. Keys are joined with "." — the paths the +# engine looks up never contain one. Malformed or truncated input exits 2. +json_flat() { + awk ' + function ws() { while (substr(s, i, 1) ~ /[ \t\r\n]/) i++ } + function str( c, r) { + i++; r = "" + while ((c = substr(s, i, 1)) != "\"") { + if (c == "" || c ~ /[\001-\037]/) exit 2 + if (c == "\\") { + i++; c = substr(s, i, 1) + if (c == "u") { + if (substr(s, i + 1, 4) !~ /^[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]$/) exit 2 + c = "\\u" substr(s, i + 1, 4); i += 4 + } else if (c ~ /^[bfnrt]$/) c = "\\" c + else if (c != "\"" && c != "\\" && c != "/") exit 2 + } + r = r c; i++ + } + i++; return r + } + function val(p, c, k, n) { + ws(); c = substr(s, i, 1) + if (c == "{") { + i++; ws() + while (substr(s, i, 1) != "}") { + if (substr(s, i, 1) != "\"") exit 2 + k = str(); ws(); if (substr(s, i, 1) != ":") exit 2 + i++; val(p == "" ? k : p "." k); ws() + if (substr(s, i, 1) == ",") { i++; ws(); if (substr(s, i, 1) == "}") exit 2 } else if (substr(s, i, 1) != "}") exit 2 + } + i++ + } else if (c == "[") { + i++; n = 0; ws() + while (substr(s, i, 1) != "]") { + val(p "." n); n++; ws() + if (substr(s, i, 1) == ",") { i++; ws(); if (substr(s, i, 1) == "]") exit 2 } else if (substr(s, i, 1) != "]") exit 2 + } + i++ + } else if (c == "\"") print p "\t" str() + else { + n = ""; while (substr(s, i, 1) ~ /[-+.0-9A-Za-z]/) { n = n substr(s, i, 1); i++ } + if (n !~ /^(true|false|null|-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?)$/) exit 2 + print p "\t" n + } + } + { s = s $0 "\n" } + END { i = 1; val(""); ws(); if (i <= length(s)) exit 2 } + ' "$1" +} + +flat_get() { awk -F '\t' -v k="$2" '$1 == k { v = substr($0, length(k) + 2); f = 1 } END { if (f) print v }' "$1"; } # In the common curl-pipe-to-sh invocation, fd 0 (stdin) is the piped script # source, not the terminal — reading a prompt from it would consume script @@ -255,10 +304,11 @@ fetch_manifest() { info "fetching release manifest" curl_download "$CFG_MANIFEST_URL" "$WORK_DIR/manifest.json" \ || err "failed to fetch $CFG_MANIFEST_URL" + json_flat "$WORK_DIR/manifest.json" > "$WORK_DIR/manifest.flat" \ + || err "release manifest is not valid JSON: $CFG_MANIFEST_URL" } -json_get() { jq -r ".$1" "$WORK_DIR/manifest.json"; } -platform_field() { jq -r ".platforms[\"$1\"].$2 // empty" "$WORK_DIR/manifest.json"; } +platform_field() { flat_get "$WORK_DIR/manifest.flat" "platforms.$1.$2"; } # Tauri's bare {os}-{arch} key is the AppImage, but a project that publishes # per-format variants (…-deb/…-rpm/…-appimage) may omit the bare key entirely. @@ -285,12 +335,7 @@ preferred_variant_for() { # -L alone only means "follow redirects," not "stay on https," so a github.com # URL that happened to redirect to plain http would otherwise be followed. curl_download() { - local url out - url="$1"; out="$2"; shift 2 - case "$url" in - https://api.github.com/*) set -- ${GITHUB_TOKEN:+-H} ${GITHUB_TOKEN:+"Authorization: Bearer $GITHUB_TOKEN"} ;; - esac - curl -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --retry 2 "$@" "$url" -o "$out" + curl -fsSL --proto '=https' --proto-redir '=https' --connect-timeout 10 --retry 2 "$1" -o "$2" } # Same as curl_download but with visible progress — for the actual asset @@ -302,76 +347,36 @@ curl_download_progress() { curl -fL --proto '=https' --proto-redir '=https' --connect-timeout 10 --retry 2 --progress-bar "$1" -o "$2" } -# Looks up the actual asset list from the GitHub Releases API rather than -# guessing a filename convention — a bundler/packaging change just changes -# what's in this list, nothing to keep in sync by hand. Cached in WORK_DIR so -# repeated lookups (deb, then rpm) don't refetch. -fetch_github_release() { - [ -z "${GH_RELEASE_FETCHED:-}" ] || return 0 - [ -n "$CFG_GITHUB_REPO" ] || return 1 - curl_download "https://api.github.com/repos/$CFG_GITHUB_REPO/releases/latest" "$WORK_DIR/gh_release.json" \ - || { warn "failed to query GitHub releases API for $CFG_GITHUB_REPO"; return 1; } - GH_RELEASE_FETCHED=1 -} - -# Release asset whose filename ends in ".$1" (e.g. "deb", "rpm"). Among -# multiple matches (a project shipping separate amd64/arm64 packages), -# prefers one whose filename mentions this machine's architecture; falls -# back to the first match if none do. check_package_arch, run after -# download, remains the actual authority — this is just picking a sane -# candidate to try first, not a substitute for that check. -find_gh_asset_url() { - local ext arch_pattern - ext="$1" - case "$ARCH" in - x86_64) arch_pattern="x86_64|amd64|x64" ;; - aarch64) arch_pattern="aarch64|arm64" ;; - esac - jq -r --arg ext ".$ext" --arg re "$arch_pattern" ' - [.assets[] | select(.name | endswith($ext))] as $cands - | ($cands | map(select(.name | test($re; "i")))[0].browser_download_url) as $matched - | if $matched then $matched else ($cands[0].browser_download_url // empty) end - ' "$WORK_DIR/gh_release.json" -} - # Resolves a --appimage/--deb/--rpm flag directly, bypassing all # auto-detection (and the interactive prompt) since the user already # answered the question on the command line. resolve_forced_format() { - local base_key ext url + local base_key base_key="$1" case "$FORCE_FORMAT" in appimage) [ "$OS" = linux ] || err "--appimage is only meaningful on Linux" ASSET_KEY=$(appimage_key "$base_key") [ -n "$ASSET_KEY" ] || err "no AppImage asset published for $base_key" - ASSET_SOURCE=manifest ;; deb|rpm) [ "$OS" = linux ] || err "--$FORCE_FORMAT is only meaningful on Linux" - ext="$FORCE_FORMAT" - if fetch_github_release; then - url=$(find_gh_asset_url "$ext") - [ -n "$url" ] && { ASSET_SOURCE=pattern; ASSET_URL="$url"; return; } - fi - if [ -n "$(platform_field "${base_key}-${ext}" url)" ]; then - ASSET_SOURCE=manifest; ASSET_KEY="${base_key}-${ext}"; return - fi - err "no .$ext asset found for $CFG_PROJECT_NAME (checked GitHub release assets and manifest key '${base_key}-${ext}')" + ASSET_KEY="${base_key}-${FORCE_FORMAT}" + [ -n "$(platform_field "$ASSET_KEY" url)" ] || err "no .$FORCE_FORMAT asset published for $CFG_PROJECT_NAME (manifest key '$ASSET_KEY')" ;; esac } # Precedence: an explicit --appimage/--deb/--rpm flag wins outright; then an # explicit preferred_variant manifest key; then, on Linux with a detected -# package manager, a matching .deb/.rpm asset discovered via the GitHub -# Releases API or a manifest key — offered interactively against the AppImage -# alternative when both exist (see choose_variant); then the bare {os}-{arch} -# manifest key (AppImage on Linux, .app on macOS). +# package manager, the manifest's .deb/.rpm key — offered interactively +# against the AppImage alternative when both exist (see choose_variant); then +# the bare {os}-{arch} manifest key (AppImage on Linux, .app on macOS). The +# manifest is the only source of assets: a package it does not declare is not +# installed. resolve_asset_source() { - local base_key variant url + local base_key variant ext base_key="${OS}-${ARCH}" - ASSET_SOURCE="" if [ -n "$FORCE_FORMAT" ]; then resolve_forced_format "$base_key" @@ -380,50 +385,21 @@ resolve_asset_source() { variant=$(preferred_variant_for "$OS") if [ -n "$variant" ] && [ -n "$(platform_field "${base_key}-${variant}" url)" ]; then - ASSET_SOURCE=manifest; ASSET_KEY="${base_key}-${variant}"; return + ASSET_KEY="${base_key}-${variant}"; return fi # Tauri's bare Linux key is the AppImage. Honor an explicit AppImage # preference before native package discovery when no suffixed alias exists. if [ "$OS:$variant" = "linux:appimage" ] && [ -n "$(platform_field "$base_key" url)" ]; then - ASSET_SOURCE=manifest; ASSET_KEY="$base_key"; return + ASSET_KEY="$base_key"; return fi - if [ "$OS" = linux ]; then - case "$PKG_MANAGER" in - apt) - if fetch_github_release; then - url=$(find_gh_asset_url deb) - if [ -n "$url" ]; then - maybe_prefer_appimage "$base_key" deb apt \ - && { ASSET_SOURCE=manifest; ASSET_KEY="$APPIMAGE_KEY"; return; } - ASSET_SOURCE=pattern; ASSET_URL="$url"; return - fi - fi - if [ -n "$(platform_field "${base_key}-deb" url)" ]; then - maybe_prefer_appimage "$base_key" deb apt \ - && { ASSET_SOURCE=manifest; ASSET_KEY="$APPIMAGE_KEY"; return; } - ASSET_SOURCE=manifest; ASSET_KEY="${base_key}-deb"; return - fi - ;; - dnf|zypper) - if fetch_github_release; then - url=$(find_gh_asset_url rpm) - if [ -n "$url" ]; then - maybe_prefer_appimage "$base_key" rpm "$PKG_MANAGER" \ - && { ASSET_SOURCE=manifest; ASSET_KEY="$APPIMAGE_KEY"; return; } - ASSET_SOURCE=pattern; ASSET_URL="$url"; return - fi - fi - if [ -n "$(platform_field "${base_key}-rpm" url)" ]; then - maybe_prefer_appimage "$base_key" rpm "$PKG_MANAGER" \ - && { ASSET_SOURCE=manifest; ASSET_KEY="$APPIMAGE_KEY"; return; } - ASSET_SOURCE=manifest; ASSET_KEY="${base_key}-rpm"; return - fi - ;; - esac + case "$PKG_MANAGER" in apt) ext=deb ;; dnf|zypper) ext=rpm ;; *) ext="" ;; esac + if [ -n "$ext" ] && [ -n "$(platform_field "${base_key}-${ext}" url)" ]; then + if maybe_prefer_appimage "$base_key" "$ext" "$PKG_MANAGER"; then ASSET_KEY="$APPIMAGE_KEY"; else ASSET_KEY="${base_key}-${ext}"; fi + return fi - ASSET_SOURCE=manifest; ASSET_KEY="$base_key" + ASSET_KEY="$base_key" if [ "$OS" = linux ]; then variant=$(appimage_key "$base_key") if [ -n "$variant" ]; then @@ -445,15 +421,25 @@ download_asset() { curl_download_progress "$ASSET_URL" "$ASSET_FILE" || err "download failed: $ASSET_URL" } -# .deb/.rpm assets found via the GitHub API aren't part of the signed Tauri -# updater manifest, so there's nothing to verify them against. -fetch_pattern_asset() { - case "$ASSET_URL" in https://*) ;; *) err "asset URL is not https: $ASSET_URL" ;; esac - ASSET_FILE="$WORK_DIR/$(basename "${ASSET_URL%%\?*}")" - info "downloading $(basename "$ASSET_FILE")" - curl_download_progress "$ASSET_URL" "$ASSET_FILE" \ - || err "download failed: $ASSET_URL (does this release actually publish this asset?)" - warn "no manifest signature available for this asset — verifying via HTTPS only" +# Runs before anything is downloaded: a missing verifier is knowable upfront, +# and installing it needs the same consent and sudo the native package path +# already asks for. Nothing is installed without the prompt (or -y). +ensure_minisign() { + local install + [ -n "$CFG_PUBKEY" ] && [ "$DRY_RUN" -eq 0 ] || return 0 + have minisign && return 0 + case "$PKG_MANAGER" in + apt) install="apt-get install -y minisign" ;; + dnf) install="dnf install -y minisign" ;; + zypper) install="zypper --non-interactive install minisign" ;; + *) if have pacman; then install="pacman -S --noconfirm minisign"; elif have brew; then install="brew install minisign"; else install=""; fi ;; + esac + [ -n "$install" ] || err "minisign is required to verify $CFG_PROJECT_NAME's signature (e.g. 'apt install minisign' / 'dnf install minisign' / 'pacman -S minisign' / 'brew install minisign')" + info "minisign is required to verify $CFG_PROJECT_NAME's signature; it can be installed with: $install" + confirm "Install minisign now?" || err "minisign is required to verify $CFG_PROJECT_NAME's signature — install it and re-run" + case "$install" in brew*) ;; *) require_sudo ;; esac + $SUDO $install || err "installing minisign failed — install it manually and re-run" + have minisign || err "minisign is still not on PATH after installation — install it manually and re-run" } verify_signature() { @@ -461,10 +447,8 @@ verify_signature() { warn "no pubkey configured — skipping signature verification" return fi - have minisign || err "a pubkey is configured but minisign is not installed — refusing to install unverified" [ -n "$ASSET_SIG" ] || err "release manifest has no signature for this asset" - have base64 || err "base64 is required for signature verification" printf '%s' "$ASSET_SIG" | base64 -d > "$ASSET_FILE.minisig" 2>/dev/null \ || err "invalid base64 signature in updater manifest" minisign -V -P "$CFG_PUBKEY" -m "$ASSET_FILE" -x "$ASSET_FILE.minisig" \ @@ -520,11 +504,10 @@ check_package_arch() { esac } -# The GitHub-discovered .deb/.rpm comes from a separate API call than the -# Tauri updater manifest VERSION was read from — normally the same release, -# but not cross-checked by construction. A loose prefix match (rather than -# exact) tolerates rpm's "-1" release suffixes and similar formatting that -# don't indicate an actual version mismatch. +# The package's own version is compared with the manifest's: a mismatch means +# the manifest points at a package from another release. A loose prefix match +# (rather than exact) tolerates rpm's "-1" release suffixes and similar +# formatting that don't indicate an actual version mismatch. check_package_version() { local pkg_version pkg_version="$1" @@ -685,13 +668,20 @@ command_copies() { done } +load_record() { + : > "$WORK_DIR/record.flat" + [ -f "$CFG_UNINSTALL_MANIFEST" ] || return 0 + json_flat "$CFG_UNINSTALL_MANIFEST" > "$WORK_DIR/record.flat" || { : > "$WORK_DIR/record.flat"; return 1; } +} +record_get() { flat_get "$WORK_DIR/record.flat" "$1"; } +recorded_files() { awk -F '\t' '$1 ~ /^files\.[0-9]+$/ { print substr($0, index($0, "\t") + 1) }' "$WORK_DIR/record.flat"; } + recorded() { local owner - [ -f "$CFG_UNINSTALL_MANIFEST" ] || return 1 + recorded_files | grep -qxF -- "$1" && return 0 owner=$(dpkg -S "$1" 2>/dev/null | sed 's/[,:].*//' | head -n 1) [ -n "$owner" ] || owner=$(rpm -qf --queryformat '%{NAME}' "$1" 2>/dev/null || true) - jq -e --arg p "$1" --arg o "${owner:-/}" '(.files | index($p)) != null or (.files | any(endswith(":" + $o)))' \ - "$CFG_UNINSTALL_MANIFEST" >/dev/null 2>&1 + [ -n "$owner" ] && recorded_files | sed -n 's/^pkg:[a-z]*://p' | grep -qxF -- "$owner" } owner_label() { @@ -721,7 +711,7 @@ report_install() { [ -n "$INSTALLED_PATH" ] && [ "$p" -ef "$INSTALLED_PATH" ] && continue warn "another '$COMMAND_NAME' is on PATH: $p ($(owner_label "$p"))" done - if jq -e '(.files | any(startswith("pkg:"))) and (.files | any(startswith("/")))' "$CFG_UNINSTALL_MANIFEST" >/dev/null 2>&1; then + if recorded_files | grep -q '^pkg:' && recorded_files | grep -q '^/'; then warn "$CFG_PROJECT_NAME is installed both as a package and in $INSTALL_DIR; --uninstall removes both" fi } @@ -734,22 +724,32 @@ target_present() { esac } +json_string() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; } + write_uninstall_manifest() { - local tmp f + local tmp f sep tmp="$CFG_UNINSTALL_MANIFEST.tmp.$$" mkdir -p "$(dirname "$CFG_UNINSTALL_MANIFEST")" + load_record || true { - if jq -e --arg p "$CFG_PROJECT_NAME" --arg d "$INSTALL_DIR" '.project == $p and .install_dir == $d' "$CFG_UNINSTALL_MANIFEST" >/dev/null 2>&1; then - jq -r '.files[]?' "$CFG_UNINSTALL_MANIFEST" | while IFS= read -r f; do + if [ "$(record_get project)" = "$CFG_PROJECT_NAME" ] && [ "$(record_get install_dir)" = "$INSTALL_DIR" ]; then + recorded_files | while IFS= read -r f; do if target_present "$f"; then printf '%s\n' "$f"; fi done fi printf '%s\n' "$INSTALLED_FILES" - } | jq -R '.' | jq -s \ - --arg project "$CFG_PROJECT_NAME" --arg version "$VERSION" --arg install_dir "$INSTALL_DIR" \ - '{project:$project, version:$version, install_dir:$install_dir, files:(map(select(length>0)) | unique)}' \ - > "$tmp" + } | grep -v '^$' | sort -u > "$tmp.files" + ! LC_ALL=C grep -q '[[:cntrl:]]' "$tmp.files" || err "cannot record a path containing control characters" + { + printf '{"project":%s,"version":%s,"install_dir":%s,"files":[' \ + "$(json_string "$CFG_PROJECT_NAME")" "$(json_string "$VERSION")" "$(json_string "$INSTALL_DIR")" + sep="" + while IFS= read -r f; do printf '%s%s' "$sep" "$(json_string "$f")"; sep=","; done < "$tmp.files" + printf ']}\n' + } > "$tmp" + rm -f "$tmp.files" mv -f "$tmp" "$CFG_UNINSTALL_MANIFEST" + load_record } # Exact matches only — every path this script can ever install to is a fixed, @@ -806,28 +806,24 @@ remove_package() { do_uninstall() { local manifest_project manifest_install_dir files_list [ -f "$CFG_UNINSTALL_MANIFEST" ] || err "no install record found at $CFG_UNINSTALL_MANIFEST" + load_record || err "uninstall manifest is corrupt: $CFG_UNINSTALL_MANIFEST" - manifest_project=$(jq -er '.project' "$CFG_UNINSTALL_MANIFEST") \ - || err "uninstall manifest is corrupt: $CFG_UNINSTALL_MANIFEST" + manifest_project=$(record_get project) + [ -n "$manifest_project" ] || err "uninstall manifest is corrupt: $CFG_UNINSTALL_MANIFEST" [ "$manifest_project" = "$CFG_PROJECT_NAME" ] \ || err "uninstall manifest belongs to '$manifest_project', not '$CFG_PROJECT_NAME'" # Catches --install-dir or a config change since install with one clear # message, rather than letting every recorded path fail individually in # safe_remove further down. - manifest_install_dir=$(jq -er '.install_dir' "$CFG_UNINSTALL_MANIFEST") \ - || err "uninstall manifest is corrupt: $CFG_UNINSTALL_MANIFEST" + manifest_install_dir=$(record_get install_dir) [ "$manifest_install_dir" = "$INSTALL_DIR" ] \ || err "install_dir ('$INSTALL_DIR') differs from what was recorded at install time ('$manifest_install_dir') — uninstall would not find the real files. Re-run without --install-dir, or with the same install_dir used to install." confirm "Remove $CFG_PROJECT_NAME ($INSTALL_DIR/$COMMAND_NAME and related files)?" || err "aborted" - # Written to a file rather than piped straight into the loop: a pipeline's - # exit status is its last command's, so piping jq into a while-read loop would - # silently treat a jq failure as "nothing to uninstall" and still report success. files_list="$WORK_DIR/uninstall-files" - jq -r '.files[]' "$CFG_UNINSTALL_MANIFEST" > "$files_list" \ - || err "uninstall manifest is corrupt: $CFG_UNINSTALL_MANIFEST" + recorded_files > "$files_list" info "removing $CFG_PROJECT_NAME" while IFS= read -r f; do @@ -874,17 +870,14 @@ main() { detect_platform detect_pkg_manager + ensure_minisign fetch_manifest - VERSION=$(json_get version) + VERSION=$(flat_get "$WORK_DIR/manifest.flat" version) [ -n "$VERSION" ] && [ "$VERSION" != "null" ] || err "manifest did not report a version" resolve_asset_source - if [ "$ASSET_SOURCE" = manifest ]; then - info "resolved platform: $ASSET_KEY" - ASSET_URL=$(platform_field "$ASSET_KEY" url) - else - info "resolved via direct package URL ($PKG_MANAGER)" - fi + info "resolved platform: $ASSET_KEY" + ASSET_URL=$(platform_field "$ASSET_KEY" url) if [ "$DRY_RUN" -eq 1 ]; then echo "would install $CFG_PROJECT_NAME $VERSION" @@ -896,12 +889,8 @@ main() { exit 0 fi - if [ "$ASSET_SOURCE" = manifest ]; then - download_asset "$ASSET_KEY" - verify_signature - else - fetch_pattern_asset - fi + download_asset "$ASSET_KEY" + verify_signature install_asset write_uninstall_manifest report_install diff --git a/tests/fixtures/appimage-only.json b/tests/fixtures/appimage-only.json new file mode 100644 index 0000000..7dbd8cd --- /dev/null +++ b/tests/fixtures/appimage-only.json @@ -0,0 +1,9 @@ +{ + "version": "0.15.0", + "platforms": { + "linux-x86_64-appimage": { + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUWDJLc0ZoV0FEQXJ6dXpoYVE5TnZFSnc2SU41dlZRK1ptd3QzbU9RTHdycEhCK3Z5MVhQb0NMWnRLNm5QVHZ3WFBTVlpsUnBXTUluMTlQb0xaa1dyQmRZZ0Z5cXQvNXdRPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTE2ODUzCWZpbGU6TW9kcmV4XzAuMTUuMF9hbWQ2NC5BcHBJbWFnZQp2TFY2ZnpubjNsdlQrOGFrYUdzUXdaeVhnc0ZpbHppTGN2ZDVvZ0NJS2FhVVhOYjFpa0dDamtmTitlZEVQWUprVE9zR0ZKTU9RajY3S3J0MnRJVFlCdz09Cg==", + "url": "https://github.com/modrexio/modrex/releases/download/v0.15.0/modrex_x86_64.AppImage" + } + } +} diff --git a/tests/run.sh b/tests/run.sh index 1e2e762..b1f6bcf 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -1,6 +1,7 @@ #!/bin/sh set -u -ENGINE=$(cd "$(dirname "$0")/.." && pwd)/install.sh +ENGINE_DIR=$(cd "$(dirname "$0")/.." && pwd) +ENGINE=$ENGINE_DIR/install.sh OUT=$(mktemp) FAILED=0 SYS_PATH=/usr/local/bin:/usr/bin:/bin @@ -79,6 +80,8 @@ t_native_accumulates_with_appimage() { run -y [ "$STATUS" -eq 0 ] || fail "exit $STATUS" expect "via $PKG (provides: /usr/bin/modrex" + expect "signature verified" + refute "no manifest signature" expect "'modrex' is available in this terminal" expect "modrex is installed both as a package and in $HOME/.local/bin; --uninstall removes both" manifest_files | grep -q "^pkg:$PKG:modrex$" || fail "package not recorded" @@ -138,6 +141,7 @@ t_package_without_command_is_reported_not_rejected() { as_root rm -f /usr/bin/refract [ "$STATUS" -eq 0 ] || fail "exit $STATUS" expect "via $PKG (provides: /usr/bin/refract-tauri" + expect "signature verified" expect "this package does not provide a 'refract' command" expect "'refract' currently runs /usr/bin/refract (not installed by this installer)" run --uninstall -y @@ -177,10 +181,137 @@ t_dry_run_never_prompts() { refute "target:" } -ALL="t_appimage_not_on_path t_appimage_on_path_is_idempotent t_shadowing_is_reported +engine_functions() { sed -n "/^$1() {/,/^}/p" "$ENGINE"; } + +t_json_reader_matches_jq_and_rejects_bad_input() { + local d f k bad + d=$(mktemp -d) + eval "$(engine_functions json_flat); $(engine_functions flat_get)" + curl -fsSL https://github.com/modrexio/modrex/releases/latest/download/latest.json -o "$d/modrex.json" + curl -fsSL https://github.com/RefractMC/Refract_MC/releases/download/v1.4.0/latest.json -o "$d/refract.json" + for f in "$d/modrex.json" "$d/refract.json" "$ENGINE_DIR/tests/fixtures/appimage-only.json"; do + json_flat "$f" > "$d/flat" || fail "reader rejected $f" + [ "$(flat_get "$d/flat" version)" = "$(jq -r .version "$f")" ] || fail "version differs from jq for $f" + for k in $(jq -r '.platforms | keys[]' "$f"); do + [ "$(flat_get "$d/flat" "platforms.$k.url")" = "$(jq -r ".platforms[\"$k\"].url" "$f")" ] || fail "url differs from jq: $k" + [ "$(flat_get "$d/flat" "platforms.$k.signature")" = "$(jq -r ".platforms[\"$k\"].signature" "$f")" ] || fail "signature differs from jq: $k" + done + done + printf '%s' '{"s":"a\"b\\c\/d\n\t\b\f\u0041\ud83d\ude00","e":"","n":-1.5e3,"t":true,"z":null,"o":{},"a":[],"k":{"x":[1,{"y":"v"}]},"dup":1,"dup":2,"ab":"prefix"}' > "$d/edge.json" + json_flat "$d/edge.json" > "$d/flat" || fail "reader rejected edge.json" + [ "$(flat_get "$d/flat" s)" = 'a"b\c/d\n\t\b\f\u0041\ud83d\ude00' ] || fail "escape contract: got $(flat_get "$d/flat" s)" + grep -q '^e $' "$d/flat" || fail "empty string not emitted" + [ "$(flat_get "$d/flat" n)" = "-1.5e3" ] && [ "$(flat_get "$d/flat" t)" = "true" ] && [ "$(flat_get "$d/flat" z)" = "null" ] || fail "literals" + [ "$(flat_get "$d/flat" k.x.1.y)" = "v" ] && [ "$(flat_get "$d/flat" k.x.0)" = "1" ] || fail "nested array path" + [ "$(flat_get "$d/flat" dup)" = "2" ] || fail "duplicate key: last must win like jq" + [ -z "$(flat_get "$d/flat" a)" ] && [ -z "$(flat_get "$d/flat" k.x)" ] || fail "prefix must not match" + [ "$(grep -c . "$d/flat")" -eq 10 ] || fail "unexpected leaf count $(grep -c . "$d/flat")" + for bad in '{"a":1' '{"a":1}x' '{"a":"x y"}' '{"a":nope}' '{"a":"\q"}' '{a:1}' '[1,]' '' "$(printf '\357\273\277{}')"; do + printf '%s' "$bad" > "$d/bad.json" + json_flat "$d/bad.json" >/dev/null 2>&1; [ $? -eq 2 ] || fail "accepted malformed input: $bad" + done +} + +t_written_manifest_is_valid_json_with_escapes() { + new_home; modrex_env; PATH=$SYS_PATH + run --appimage + jq -e --arg d "$HOME/.local/bin" '.project == "modrex" and .install_dir == $d and (.files | index($d + "/modrex") != null)' "$HOME/.modrex/uninstall.json" >/dev/null \ + || fail "manifest not readable by jq or missing fields" + printf '{"project":"modrex","version":"0","install_dir":"%s","files":["%s/.local/bin/modrex","pkg:apt:a\\"b\\\\c"]}' "$HOME/.local/bin" "$HOME" > "$HOME/.modrex/uninstall.json" + run --appimage + jq -e . "$HOME/.modrex/uninstall.json" >/dev/null || fail "rewritten manifest is not valid JSON" + [ "$(jq -r '.files[]' "$HOME/.modrex/uninstall.json" | grep -c 'pkg:apt:')" -eq 0 ] || fail "vanished package entry should be pruned" + run --uninstall -y + [ "$STATUS" -eq 0 ] || fail "uninstall exit $STATUS" +} + +t_no_github_api_path() { + ! grep -q 'api.github.com' "$ENGINE" || fail "engine still references the GitHub API" + ! grep -qw 'jq' "$ENGINE" || fail "engine still references jq" + new_home; modrex_env; PATH=$SYS_PATH; export CFG_GITHUB_REPO=this/does-not-exist + run --dry-run -y + [ "$STATUS" -eq 0 ] || fail "exit $STATUS" + refute "GitHub" + expect "method: $NATIVE_EXT package" + expect "releases/download/" +} + +t_appimage_fallback_when_native_key_absent() { + [ -n "${FIXTURE_REF:-${GITHUB_SHA:-}}" ] || { echo " skipped: set FIXTURE_REF (the fixture must be reachable over https)"; return; } + new_home; modrex_env; PATH=$SYS_PATH + export CFG_MANIFEST_URL="https://raw.githubusercontent.com/${GITHUB_REPOSITORY:-modrexio/mget}/${FIXTURE_REF:-$GITHUB_SHA}/tests/fixtures/appimage-only.json" + run --dry-run -y + [ "$STATUS" -eq 0 ] || fail "exit $STATUS" + expect "resolved platform: linux-x86_64-appimage" + expect "target: $HOME/.local/bin/modrex" + run --dry-run --$NATIVE_EXT + [ "$STATUS" -ne 0 ] || fail "--$NATIVE_EXT must fail when the manifest has no such key" + expect "no .$NATIVE_EXT asset published for modrex (manifest key 'linux-x86_64-$NATIVE_EXT')" +} + +has_minisign() { hash -r; command -v minisign >/dev/null 2>&1; } +remove_minisign() { ! has_minisign || as_root $PKG remove -y minisign >/dev/null 2>&1; ! has_minisign || fail "could not remove minisign for the test"; } +restore_minisign() { has_minisign || as_root $PKG install -y minisign >/dev/null 2>&1; } + +no_tty() { if command -v setsid >/dev/null 2>&1; then setsid "$@"; else "$@"; fi; } + +t_dry_run_does_not_offer_to_install_minisign() { + new_home; modrex_env; PATH=$SYS_PATH; remove_minisign + run --dry-run -y + [ "$STATUS" -eq 0 ] || fail "exit $STATUS" + refute "minisign" + expect "would install modrex" + ! has_minisign || fail "dry run installed minisign" +} + +t_minisign_offer_accepted_with_yes() { + new_home; modrex_env; PATH=$SYS_PATH; remove_minisign + run --appimage -y + [ "$STATUS" -eq 0 ] || fail "exit $STATUS" + expect "minisign is required to verify modrex's signature; it can be installed with: $PKG" + expect "signature verified" + has_minisign || fail "minisign not installed" + run --uninstall -y +} + +t_minisign_offer_without_consent_refuses() { + new_home; modrex_env; PATH=$SYS_PATH; remove_minisign + no_tty sh "$ENGINE" --appimage >"$OUT" 2>&1 "$HOME/stub/$c"; chmod +x "$HOME/stub/$c"; done + PATH="$HOME/stub:$SYS_PATH" sh "$ENGINE" --appimage -y >"$OUT" 2>&1; STATUS=$? + [ "$STATUS" -ne 0 ] || fail "proceeded after a failed install" + expect "installing minisign failed" + refute "fetching release manifest" +} + +t_minisign_without_package_manager_keeps_the_plain_error() { + new_home; modrex_env; remove_minisign + mkdir -p "$HOME/onlybin" + for c in sh curl awk sed grep tr head cut sort id uname mktemp rm mkdir dirname basename cat wc ls readlink base64 env; do + ln -s "$(command -v $c)" "$HOME/onlybin/$c" 2>/dev/null || true + done + PATH="$HOME/onlybin" sh "$ENGINE" --appimage -y >"$OUT" 2>&1; STATUS=$? + restore_minisign + [ "$STATUS" -ne 0 ] || fail "proceeded without minisign" + expect "minisign is required to verify modrex's signature (e.g." + refute "Install minisign now" +} + +ALL="t_json_reader_matches_jq_and_rejects_bad_input t_written_manifest_is_valid_json_with_escapes t_no_github_api_path t_appimage_fallback_when_native_key_absent + t_appimage_not_on_path t_appimage_on_path_is_idempotent t_shadowing_is_reported t_native_accumulates_with_appimage t_uninstall_removes_recorded_reports_foreign t_dangling_symlink_is_reported t_v1_manifest_is_honoured t_missing_owned_files_are_tolerated - t_package_without_command_is_reported_not_rejected t_install_dir_change_starts_a_fresh_record t_sudo_is_refused_before_network t_dry_run_never_prompts" + t_package_without_command_is_reported_not_rejected t_install_dir_change_starts_a_fresh_record t_sudo_is_refused_before_network t_dry_run_never_prompts + t_dry_run_does_not_offer_to_install_minisign t_minisign_offer_accepted_with_yes t_minisign_offer_without_consent_refuses + t_minisign_install_failure_is_explicit t_minisign_without_package_manager_keeps_the_plain_error" for t in ${TESTS:-$ALL}; do echo "$t" before=$FAILED; $t