From 8118bf778d48d5cdfea16e34fdcdc0c474b069b0 Mon Sep 17 00:00:00 2001 From: ShulhaOleh Date: Sun, 20 Sep 2026 14:15:28 -0400 Subject: [PATCH] feat(installer): drop jq; the manifest is the only asset source Replace every jq call with a small POSIX awk reader that flattens JSON to path/value lines. Its contract is deliberately narrow: only \" \ \/ are decoded, other escapes stay as text, raw control characters and anything malformed exit 2, so a value can never forge a line or split a path. curl and the POSIX base are now the only hard requirements. Native .deb/.rpm packages are taken from the manifest's linux-*-deb/-rpm keys, which every release of both consumers has carried, and are therefore verified with the manifest signature like every other asset. The GitHub Releases API discovery, its token plumbing and the "verifying via HTTPS only" path are gone; github_repo is read and ignored. When a pubkey is configured and minisign is missing, offer to install it through the detected package manager (apt, dnf, zypper, pacman, brew) under the existing consent and sudo flow; declining, a failed install or no known manager leaves the explicit error. Dry runs never offer. The uninstall manifest is written with printf and read with the same reader; its shape is unchanged. --- .github/workflows/ci.yml | 4 +- README.md | 35 ++-- docs/planned-breaking-changes.md | 11 ++ install.sh | 291 ++++++++++++++---------------- tests/fixtures/appimage-only.json | 9 + tests/run.sh | 137 +++++++++++++- 6 files changed, 311 insertions(+), 176 deletions(-) create mode 100644 tests/fixtures/appimage-only.json 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