From 099c4235ae70f1127a05b4df681653764f408b69 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Fri, 21 Aug 2026 01:10:08 +0800 Subject: [PATCH 1/7] feat(hu): ship the plugins a release needs to be usable No release has ever contained a .wasm file, so a downloaded hu could not run any documented `hu meter` or `hu monitor` command: plugin dispatch had nothing to dispatch to. RELEASING.md claimed the pipeline built them. Adds the artifact set (binary tarballs, both plugins, an offline plugins tarball, a JSON index, the installer and SHA256SUMS), one packaging script both release platforms call so they cannot drift, a POSIX installer that verifies every download and refuses a mismatch, and `hu plugin install` / `uninstall` from a path, a URL or the release index. Discovery also learns a prefix-relative directory. Installing with a --prefix wrote plugins where discovery never looked, so the install reported success and `hu plugin list` was empty. --- .github/workflows/release.yml | 453 ++++++++++++++++++++- Cargo.lock | 1 + RELEASING.md | 25 +- crates/hiroz-union/Cargo.toml | 1 + crates/hiroz-union/src/main.rs | 131 +++++- crates/hiroz-union/src/plugin/install.rs | 421 +++++++++++++++++++ crates/hiroz-union/src/plugin/mod.rs | 2 + crates/hiroz-union/src/plugin/wasm/mod.rs | 21 +- crates/hiroz-union/tests/plugin_install.rs | 339 +++++++++++++++ crates/hiroz-union/wit/v0.1/hu-plugin.wit | 3 +- docs/tools/hu-install.md | 141 +++++++ docs/tools/hu-plugins.md | 13 + mkdocs.yml | 1 + scripts/build-hu-release.nu | 334 +++++++++++++++ scripts/ci/write-sha256sums.sh | 38 ++ scripts/install-hu.sh | 280 +++++++++++++ 16 files changed, 2174 insertions(+), 30 deletions(-) create mode 100644 crates/hiroz-union/src/plugin/install.rs create mode 100644 crates/hiroz-union/tests/plugin_install.rs create mode 100644 docs/tools/hu-install.md create mode 100755 scripts/build-hu-release.nu create mode 100755 scripts/ci/write-sha256sums.sh create mode 100755 scripts/install-hu.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96e53a844..9afc44cf6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,21 +129,30 @@ jobs: matrix: include: # hu (hiroz-union) — distro-agnostic, one binary per platform + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, + # and a default-feature build accepts the subcommand then refuses it + # at run time. It implies wasm-plugins, so nothing is lost. - bin: hu package: hiroz-union - features: "" + features: "web-plugins" artifact: bin-hu-x86_64-linux target: x86_64-unknown-linux-gnu os: ubuntu-latest + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, + # and a default-feature build accepts the subcommand then refuses it + # at run time. It implies wasm-plugins, so nothing is lost. - bin: hu package: hiroz-union - features: "" + features: "web-plugins" artifact: bin-hu-aarch64-linux target: aarch64-unknown-linux-gnu os: ubuntu-latest + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, + # and a default-feature build accepts the subcommand then refuses it + # at run time. It implies wasm-plugins, so nothing is lost. - bin: hu package: hiroz-union - features: "" + features: "web-plugins" artifact: bin-hu-aarch64-macos target: aarch64-apple-darwin os: macos-latest @@ -153,6 +162,10 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + with: + # wasm32-wasip2 builds the hu plugins. They are platform-independent, + # so only the primary leg (below) actually packages them. + targets: wasm32-wasip2 - name: Install Rust target (Linux cross-compile only) if: matrix.target == 'aarch64-unknown-linux-gnu' @@ -187,12 +200,44 @@ jobs: ${{ matrix.features != '' && format('--features {0} --no-default-features', matrix.features) || '' }} \ --target ${{ matrix.target }} + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + # HU_VERSION identifies the RELEASE (`0.2.0-rc1`); HU_CORE identifies the + # ASSETS (`0.2.0`). They differ only for a pre-release, because + # build-hu-release.nu names every file for the crate version — an rc ships + # the same crate as the release it rehearses. Using one string for both is + # what broke the first pre-release on the other channel. + - name: Derive version from tag + shell: bash + run: | + set -eu + V="${GITHUB_REF_NAME#v}" + echo "HU_VERSION=$V" >> "$GITHUB_ENV" + echo "HU_CORE=${V%%-*}" >> "$GITHUB_ENV" + echo ">>> tag=$GITHUB_REF_NAME version=$V core=${V%%-*}" + - name: Package binary shell: bash run: | - mkdir -p bin-dist - cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" \ - "bin-dist/${{ matrix.artifact }}" + # The shared script, never an ad-hoc `cp`: the tarball name and + # contents are the contract install-hu.sh reads. --binary-from packages + # what the cross legs already built; --no-sums because SHA256SUMS is + # assembled once, in the release job, over every leg's assets. + # + # --version arms the tag-vs-crate guard (build-hu-release.nu:69-77), + # so a tag that disagrees with the crate fails here instead of + # shipping a tarball whose name lies about its contents. The guard + # compares only the CORE version, so an `-rc` suffix is legal. + nu scripts/build-hu-release.nu \ + --binary-only \ + --version "$HU_VERSION" \ + --binary-from "target/${{ matrix.target }}/release/${{ matrix.bin }}" \ + --target "${{ matrix.target }}" \ + --no-sums \ + --out bin-dist - name: Upload artifact uses: actions/upload-artifact@v4 @@ -200,6 +245,87 @@ jobs: name: ${{ matrix.artifact }} path: bin-dist/* + build-hu-plugins: + name: Build hu WASM plugins + runs-on: ubuntu-latest + # `hu meter` and `hu monitor` are NOT in the hu binary — they are WASM + # components. Without this job the release ships a hu that cannot run any + # documented `hu meter`/`hu monitor` command. + # + # wasm32-wasip2 output is platform-independent, so this runs on exactly one + # leg. Building it in the per-target matrix would have three legs racing to + # upload the same asset name. + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip2 + + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + # HU_VERSION identifies the RELEASE (`0.2.0-rc1`); HU_CORE identifies the + # ASSETS (`0.2.0`). build-hu-release.nu names every file for the crate + # version, so a pre-release tag produces core-named assets by design. + # Deriving one string and using it for both is what made the first + # pre-release on the other channel build cleanly and then fail verifying + # filenames that by design never exist. + - name: Derive version from tag + shell: bash + run: | + set -eu + V="${GITHUB_REF_NAME#v}" + echo "HU_VERSION=$V" >> "$GITHUB_ENV" + echo "HU_CORE=${V%%-*}" >> "$GITHUB_ENV" + echo ">>> tag=$GITHUB_REF_NAME version=$V core=${V%%-*}" + + # build-hu-release.nu validates each .wasm by loading it as a component + # through `hu plugin validate`, which needs a HOST-NATIVE hu. This leg is + # x86_64 Linux, so it can build one — and it must, because this is the + # only job that publishes the .wasm files. Without it the channel that + # ships the plugins never once compiles them as components. + # + # Default features are enough (`wasm-plugins` provides `plugin validate`) + # and this binary is never packaged: the released hu comes from the + # build-binaries matrix. + - name: Build a host-native hu for plugin validation + shell: bash + run: cargo build --release --bin hu --package hiroz-union + + - name: Build plugins and index + shell: bash + run: | + # The single packaging script every release platform calls, so they + # cannot drift in what they ship. + # --version arms the tag-vs-crate guard (build-hu-release.nu:69-77); + # it compares only the CORE version, so `-rc` is legal. + # --no-sums: SHA256SUMS is assembled once in the release job over + # every asset. A per-job file here would cover only the plugins and + # verify clean while saying nothing about the binary. + nu scripts/build-hu-release.nu --plugins-only --version "$HU_VERSION" --no-sums --out hu-dist + + - name: Verify the plugin artifact set + shell: bash + run: | + set -e + # HU_CORE, not HU_VERSION: the assets are named for the crate. + for f in "hu_meter-$HU_CORE.wasm" \ + "hu_monitor-$HU_CORE.wasm" \ + "hu-plugins-$HU_CORE.tar.gz" \ + "hu-plugins-$HU_CORE.json"; do + test -s "hu-dist/$f" || { echo "missing or empty: hu-dist/$f"; exit 1; } + done + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bin-hu-plugins + path: hu-dist/* + build-go-libs: name: Build libhiroz (${{ matrix.distro }}, ${{ matrix.target }}) runs-on: ${{ matrix.os }} @@ -321,20 +447,103 @@ jobs: run: .venv/bin/python -c "import hiroz_py; print('hiroz_py import ok')" smoke-test-binaries: - name: Smoke test binaries - needs: [build-binaries] - runs-on: ubuntu-latest + name: Smoke test binaries (${{ matrix.target }}) + needs: [build-binaries, build-hu-plugins] + runs-on: ${{ matrix.os }} + # One leg per platform whose tarball the release publishes and whose host + # this workflow can run on. Until this was a matrix the macOS tarball was + # built, packaged and published without ever being unpacked or executed by + # anything — a broken macOS artifact would have shipped green. The + # aarch64-linux tarball has no leg here because no GitHub-hosted runner can + # execute it; it stays unexercised by this workflow, deliberately and + # visibly. + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + artifact: bin-hu-x86_64-linux + target: x86_64-unknown-linux-gnu + - os: macos-latest + artifact: bin-hu-aarch64-macos + target: aarch64-apple-darwin steps: - - name: Download hu binary (x86_64) + # MUST be first. `actions/checkout` cleans the working directory, so a + # checkout after the downloads deletes the `dist/` they just populated + # and the next step dies on `cd: dist: No such file or directory`. This + # job had never run -- release.yml triggers only on a `v*` tag -- so the + # ordering was wrong from the day it was written and nothing could say so. + - uses: actions/checkout@v4 + + - name: Download hu binary uses: actions/download-artifact@v4 with: - name: bin-hu-x86_64-linux + name: ${{ matrix.artifact }} + path: dist/ + + # The .wasm plugins are wasm32-wasip2 and platform-independent, so both + # legs consume the single artifact the one plugins job produced. + - name: Download hu plugins + uses: actions/download-artifact@v4 + with: + name: bin-hu-plugins path: dist/ - - name: Run --help + # Assemble the same SHA256SUMS the release job will, so this test + # installs from artifacts shaped exactly like the published ones. + # + # Written for both hosts. macOS ships neither `sha256sum` nor GNU find's + # `-printf`, so the Linux-only spellings this step used to carry would + # have failed the macOS leg on its first line — before testing anything + # about the artifact. + - name: Assemble SHA256SUMS + shell: bash + run: scripts/ci/write-sha256sums.sh dist + + # The real question is not "does the binary start" but "can a user who + # downloaded this release install it the documented way and then run + # `hu meter`". So this uses the actual installer rather than hand-placing + # files: it is the only thing that exercises the artifact *shape*, which + # is where this channel was broken — it shipped a bare ELF while the + # installer expects a tarball. + # + # No target is passed: install-hu.sh's own detect_target must pick this + # host's tarball out of dist/. On the macOS leg that is the only thing + # that exercises the Darwin/arm64 arm of that function against a real + # release layout. + - name: Install from the release artifacts, as a user would + shell: bash run: | - chmod +x dist/bin-hu-x86_64-linux - dist/bin-hu-x86_64-linux --help + set -e + HUHOME="$RUNNER_TEMP/huhome" + mkdir -p "$HUHOME" + HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist + unset HU_PLUGIN_PATH + HOME="$HUHOME" "$HUHOME/.local/bin/hu" --version + out="$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" plugin list)" + echo "$out" + echo "$out" | grep -q meter || { echo "FAIL: meter not discovered"; exit 1; } + echo "$out" | grep -q monitor || { echo "FAIL: monitor not discovered"; exit 1; } + + # Prove the refusal fires. A checksum check that has never rejected + # anything is unverified, not safe. + - name: A corrupted asset must be refused + shell: bash + run: | + set -e + # Corrupt THIS leg's tarball. Corrupting a fixed target's would leave + # the macOS leg installing an intact tarball and passing a test that + # proved nothing. + tarball=$(echo dist/hu-*-${{ matrix.target }}.tar.gz) + test -f "$tarball" || { echo "FAIL: no tarball for ${{ matrix.target }}"; exit 1; } + printf 'X' | dd of="$tarball" bs=1 seek=100 conv=notrunc 2>/dev/null + HUHOME="$RUNNER_TEMP/huhome-bad" + mkdir -p "$HUHOME" + if HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" sh scripts/install-hu.sh --offline dist; then + echo "FAIL: installer accepted a corrupted tarball"; exit 1 + fi + test ! -e "$HUHOME/.local/bin/hu" || { echo "FAIL: installed despite refusing"; exit 1; } + echo "ok — corrupted asset refused, nothing installed" smoke-test-go: name: Smoke test Go library @@ -415,16 +624,84 @@ jobs: path: dist/ merge-multiple: true - - name: Create GitHub Release + # docs/tools/hu-install.md points readers at this URL, and nothing put the + # installer there. Staged BEFORE SHA256SUMS is assembled, so the checksum + # file covers it -- that step lists whatever is in dist/ when it runs, and + # an unlisted asset verifies clean while saying nothing about itself. + - name: Stage the installer as a release asset + shell: bash + run: | + set -e + test -f scripts/install-hu.sh || { + echo "FAIL: scripts/install-hu.sh missing from the checkout"; exit 1; } + # Parse-only. A syntactically broken installer is worse than an + # absent one: `curl … | sh` executes it up to the parse error, so a + # partial run can leave a half-installed prefix behind. This costs + # nothing and cannot pass a file that would not parse on the reader's + # machine. + sh -n scripts/install-hu.sh + mkdir -p dist + cp scripts/install-hu.sh dist/install-hu.sh + chmod 0755 dist/install-hu.sh + echo "staged dist/install-hu.sh ($(wc -c < dist/install-hu.sh) bytes)" + + # One checksum file over every asset, generated here rather than per + # job. Per-job files were the bug: the plugins job emitted a SHA256SUMS + # covering only the plugins, so `sha256sum -c` passed while never + # checking the binary, and install-hu.sh refused the binary as unlisted. + - name: Assemble SHA256SUMS over the complete asset set + shell: bash + run: | + set -e + scripts/ci/write-sha256sums.sh dist + cd dist + # The assembly is shared; these two assertions are not. A release with + # no hu tarball, or with the installer the docs point at left + # uncovered, is the defect this step replaces. + grep -q 'hu-.*\.tar\.gz' SHA256SUMS \ + || { echo "FAIL: no hu tarball covered by SHA256SUMS"; exit 1; } + grep -q ' install-hu\.sh$' SHA256SUMS \ + || { echo "FAIL: install-hu.sh is not covered by SHA256SUMS"; exit 1; } + + # Draft, then promoted by `publish-release`, then withdrawn by + # `withdraw-release` if the post-publish checks fail. Publish-then-verify + # rather than verify-then-publish because GitHub does not serve draft + # assets for a download test. `prerelease` comes from the tag shape, so a + # rehearsal tag never takes the "Latest" badge. + - name: Create GitHub Release (draft) uses: softprops/action-gh-release@v2 with: + draft: true name: ${{ github.ref_name }} body_path: CHANGELOG.md files: dist/** + # Semver: everything after the first `-` IS the pre-release + # identifier, so a hyphen is the whole test. Enumerating rc/alpha/beta + # is both longer and narrower -- it would publish `v0.2.0-smoke-test` + # as a full release and hand it the Latest badge. + prerelease: ${{ contains(github.ref_name, '-') }} + + # Promote the draft so the public download path exists. Everything below + # installs from that URL anonymously, which is the only way to exercise URL + # construction -- the offline path never builds one, which is how an + # installer that fetched a path the host did not serve reached a release. + publish-release: + name: Publish the draft release + needs: [release] + runs-on: ubuntu-latest + steps: + - name: Promote + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + gh release edit "${{ github.ref_name }}" \ + --repo "${{ github.repository }}" --draft=false + echo "published ${{ github.ref_name }}" smoke-test-release-install: name: Smoke test install from release URL - needs: [release] + needs: [publish-release] runs-on: ubuntu-latest steps: - name: Set up Python @@ -450,9 +727,153 @@ jobs: - name: Import test run: .venv/bin/python -c "import hiroz_py; print('hiroz_py install-from-release ok')" + - uses: actions/checkout@v4 + + # Install `hu` from the live release URLs, the way a reader of + # docs/tools/hu-install.md does. This is the only test that exercises + # URL construction — the installer once built asset paths this host does + # not serve, and no offline test could catch that because the offline + # path never builds a URL. + # + # No credential: this repo is public, and requiring one here would hide + # a regression where the installer starts demanding a token it does not + # need. + - name: Install hu from the published release + run: | + set -e + TAG="${{ github.ref_name }}" + # VER is the RELEASE identity and belongs in the download path and in + # `--version`, which install-hu.sh itself splits into a core version + # for the filenames. CORE is what the BINARY reports, because it is + # built from the crate: an rc tag v0.2.0-rc1 produces a hu that prints + # `hu 0.2.0`. Asserting on VER here fails every pre-release. + VER="${TAG#v}" + CORE="${VER%%-*}" + HUHOME="$RUNNER_TEMP/hu-from-release" + mkdir -p "$HUHOME" + # GitHub serves release assets from a stable path shape, + # so only the base directory differs. That is exactly what + # HU_RELEASE_BASE overrides. + export HU_RELEASE_BASE="https://github.com/${{ github.repository }}/releases/download/$TAG" + # Download-then-run, exactly as docs/tools/hu-install.md instructs, + # against the published asset rather than the repo copy. + # `&&` propagates curl's status, so a 404 stops here. Piping instead + # would not: a pipeline's status is its last command's, so `sh` would + # read empty stdin, exit 0, and pass this step having installed + # nothing. Measured both ways -- rc=22 with the `&&`, rc=0 with a pipe. + cd "$RUNNER_TEMP" + env -u HU_RELEASE_TOKEN HOME="$HUHOME" HU_PREFIX="$HUHOME/.local" \ + bash -c "curl -fsSL '$HU_RELEASE_BASE/install-hu.sh' -o install-hu.sh \ + && HU_RELEASE_BASE='$HU_RELEASE_BASE' HU_VERSION='$VER' sh install-hu.sh" + cd "$GITHUB_WORKSPACE" + unset HU_PLUGIN_PATH || true + got=$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" --version) + echo "installed: $got" + test "$got" = "hu $CORE" || { echo "FAIL: expected 'hu $CORE', got '$got'"; exit 1; } + out=$(HOME="$HUHOME" "$HUHOME/.local/bin/hu" plugin list) + echo "$out" + echo "$out" | grep -q meter || { echo "FAIL: meter missing"; exit 1; } + echo "$out" | grep -q monitor || { echo "FAIL: monitor missing"; exit 1; } + echo "HUHOME=$HUHOME" >> "$GITHUB_ENV" + echo "HU_RELEASE_BASE=$HU_RELEASE_BASE" >> "$GITHUB_ENV" + + # The docs' first instruction is `curl -fsSL /install-hu.sh | sh`. + # Publishing the file is one claim; the URL resolving is another, and + # only fetching it from the live release tests the second. Every release + # before this one served a 404 here. + - name: The documented installer URL must serve the installer + run: | + set -e + curl -fsSL "$HU_RELEASE_BASE/install-hu.sh" -o fetched-install-hu.sh + # Parse it as the reader's shell would. `curl … | sh` gives no + # opportunity to inspect first, so a broken asset runs partially. + sh -n fetched-install-hu.sh + # And it must be the file this tag was cut from, not a stale asset + # carried over from an earlier release. + diff -u scripts/install-hu.sh fetched-install-hu.sh \ + || { echo "FAIL: published install-hu.sh differs from the tagged source"; exit 1; } + echo "ok — the documented one-liner URL serves this tag's installer" + + - name: Install nushell + uses: hustcer/setup-nu@v3 + with: + version: "0.113.1" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + # From the source checkout, not the artifact: `hu` cannot generate its own + # traffic, because no release ships message definitions (#309, G2). With + # only `hu router` the suite measures an empty graph and decays into an + # exit-status check -- which a truncated plugin passes. + - name: Build the traffic fixture from source + run: cargo build --release --example z_pubsub -p hiroz + + - name: Reproduce the documented commands against the published release + run: | + set -eu + unset HU_PLUGIN_PATH || true + HOME="$HUHOME" "$HUHOME/.local/bin/hu" router > router.log 2>&1 & + ROUTER_PID=$! + sleep 5 + # A router that died on startup shows up as a dozen unrelated + # measurement failures, so assert on it directly. + kill -0 "$ROUTER_PID" 2>/dev/null || { + echo "FAIL: router died on startup"; tail -20 router.log; exit 1; } + # Exit status must not pass through a pipe, and the log must be + # printed whichever way this goes. + set +e + nu scripts/test-hu-docs-repro.nu \ + --home "$HUHOME" \ + --publisher "$PWD/target/release/examples/z_pubsub" \ + --require-traffic > repro.log 2>&1 + rc=$? + set -e + cat repro.log + kill "$ROUTER_PID" 2>/dev/null || true + test "$rc" -eq 0 || { + echo "FAIL: the published release does not reproduce its own docs"; exit 1; } + + # If the published release cannot install itself, or cannot reproduce its own + # documentation, put it back in the drawer. A draft is invisible to everyone + # without push access and keeps its assets, so the run can be diagnosed from + # exactly what shipped. The tag survives -- withdrawing a release does not + # delete it -- so the fix is a new tag rather than a rewritten one. + # + # This is the half of "draft first" that is actually reachable, given that + # GitHub will not serve draft assets for the download test above. + withdraw-release: + name: Withdraw the release if verification failed + needs: [publish-release, smoke-test-release-install] + # NOT `failure()`: that is true when ANY ancestor fails, and this job's + # ancestors reach back to build-binaries. A failed build skips release and + # publish-release, then this job would still run and try to withdraw a + # release that was never created -- going red with a caption implying a bad + # release is live. Withdraw only what publish-release actually published. + if: ${{ always() + && needs.publish-release.result == 'success' + && needs.smoke-test-release-install.result == 'failure' }} + runs-on: ubuntu-latest + steps: + - name: Return the release to draft + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + gh release edit "${{ github.ref_name }}" \ + --repo "${{ github.repository }}" --draft=true + echo "WITHDRAWN: ${{ github.ref_name }} is a draft again — it did not verify." + echo "The tag still exists. Fix, then cut a new tag; do not move this one." + publish-crates: name: Publish core crates to crates.io needs: [smoke-test-release-install] + # A pre-release tag rehearses the pipeline; it must not publish to + # crates.io. crates.io versions come from the crate manifests, not from the + # tag, so an rc tag would try to publish the SAME version the real release + # already published and end the run red on a rehearsal that otherwise + # passed. Skipping keeps the rc a clean rehearsal signal. + if: ${{ !contains(github.ref_name, '-') }} runs-on: ubuntu-latest permissions: contents: read diff --git a/Cargo.lock b/Cargo.lock index a8c811d25..993717257 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1974,6 +1974,7 @@ dependencies = [ "serde_json", "serde_yaml", "serial_test", + "sha2", "tokio", "tracing", "tracing-subscriber", diff --git a/RELEASING.md b/RELEASING.md index 4881215a6..6e415953d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,14 +6,20 @@ This document covers the full release process: local dry-run, CI smoke test, and Before releasing, bump the version in all three places consistently: -| File | Field | -|------|-------| -| `Cargo.toml` | `[workspace.package] version` | -| `crates/hiroz-msgs/python/pyproject.toml` | `version` | -| `crates/hiroz-py/pyproject.toml` | `version` | +| File | Field | Controls | +|------|-------|----------| +| `Cargo.toml` | `[workspace.package] version` | **every crate under `crates/`** — they all inherit it, so this one row governs the crates.io versions, every `hu` release asset name, and what `hu --version` prints | +| `crates/hiroz-msgs/python/pyproject.toml` | `version` | the `hiroz-msgs-py` wheel | +| `crates/hiroz-py/pyproject.toml` | `version` | the `hiroz-py` wheel | The `hiroz-py` wheel depends on `hiroz-msgs-py>=` — update that lower bound too when bumping. +**One version governs every Rust crate, and a check enforces it.** `hiroz`, `hiroz-protocol` and `hiroz-union` each used to carry a literal `version`, which meant `cargo publish --workspace` could leave a published crate behind at the old number while the tag said otherwise, and a `v0.2.0` tag could produce `hu` assets named `0.1.0`. They now inherit, and `scripts/test-release-version-semantics.sh` fails if any crate under `crates/` reintroduces a literal. + +`hu` keeps an independent release *cadence* through its own `hu-v*` tags — you can cut a `hu` release between workspace releases — but not an independent *number*. + +> **Do not bump the WIT world alongside the product version.** `hu:plugin@0.1.0` is the plugin **ABI contract**, not a product version, and the two move on different clocks. It lives in three places that must agree — `HOST_WIT_WORLD` in `crates/hiroz-union/src/plugin/install.rs`, the `WIT_WORLD` constant in `scripts/build-hu-release.nu`, and the `package` line of `crates/hiroz-union/wit/v0.1/hu-plugin.wit` — and `install.rs` compares it to a release index by **exact string equality**. Bump the string and `hu plugin install ` refuses every index still declaring the old world, with a message telling the user to upgrade `hu` — for a change that never happened. Rename the package in `hu-plugin.wit` as well and the breakage is real rather than cosmetic: plugins built against the old package no longer instantiate. Change it only when the interface in `hu-plugin.wit` changes incompatibly, and then change all three sites in the same commit. + ## Step 1 — Local dry-run (optional) Build the Python wheels locally to catch obvious issues before touching CI: @@ -39,7 +45,9 @@ Before tagging a real version, verify the entire CI release pipeline works end-t ./scripts/test-release-workflow.nu ``` -This pushes `v0.0.0-smoke-test`, waits for all CI jobs to pass (builds, smoke tests, release creation), then reports the result. The script requires `gh` CLI authenticated to the repo. +This pushes `v-smoke-test` — e.g. `v0.1.0-smoke-test` — waits for all CI jobs to pass (builds, smoke tests, release creation), then reports the result. The script requires `gh` CLI authenticated to the repo. + +The tag carries the current workspace version deliberately. `build-hu-release.nu` cross-checks a tag's core version against it and fails the build on a mismatch, so a fixed tag like `v0.0.0-smoke-test` dies at the first packaging step. The `-smoke-test` suffix makes it a semver pre-release, so it publishes as a pre-release and skips the crates.io step. ```bash # Push only — skip the polling wait @@ -52,10 +60,11 @@ This pushes `v0.0.0-smoke-test`, waits for all CI jobs to pass (builds, smoke te The CI pipeline exercises: - All wheel builds (jazzy + humble × x86_64 Linux, aarch64 Linux, aarch64 macOS) -- The `hu` binary build, plus the `hu-meter` / `hu-monitor` WASM plugins (`hu_meter.wasm` / `hu_monitor.wasm`, `wasm32-wasip2` target) +- The `hu` binary build (built with `--features web-plugins`, so the documented `hu web` subcommand works in the artifact users download) +- The `hu-meter` / `hu-monitor` WASM plugins (`hu_meter.wasm` / `hu_monitor.wasm`, `wasm32-wasip2`), plus `hu-plugins-.tar.gz` and the `hu-plugins-.json` index, built once in the `build-hu-plugins` job — the output is platform-independent, so it is not part of the per-target matrix - All Go library builds (`libhiroz` static + shared) - Python smoke test: install into venv, `import hiroz_py` -- Binary smoke test: `--help` + 3-second runtime check (no crash) +- Binary smoke test: `--help`, plus a clean-install check that unpacks the plugins into `~/.local/share/hu/plugins` with `HU_PLUGIN_PATH` unset and asserts `hu plugin list` finds `meter` and `monitor` - Go smoke test: CGO compilation against the downloaded `.a` - Install-from-release-URL test: `pip install` from the actual GitHub Release artifacts diff --git a/crates/hiroz-union/Cargo.toml b/crates/hiroz-union/Cargo.toml index a7d855741..082dcdb5a 100644 --- a/crates/hiroz-union/Cargo.toml +++ b/crates/hiroz-union/Cargo.toml @@ -28,6 +28,7 @@ tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } zenoh = { workspace = true } chrono = { version = "0.4", features = ["serde"] } +sha2 = "0.10" anyhow = { workspace = true } wasmtime = { version = "38", features = [ "component-model", diff --git a/crates/hiroz-union/src/main.rs b/crates/hiroz-union/src/main.rs index e89f846ac..068e07c57 100644 --- a/crates/hiroz-union/src/main.rs +++ b/crates/hiroz-union/src/main.rs @@ -39,6 +39,9 @@ impl From for core::engine::Backend { #[derive(Parser)] #[command( name = "hu", + // Releases ship versioned artifacts and the install docs tell users to run + // `hu --version` to check what they got, so the binary has to answer. + version, about = "Plugin platform and TUI for the hiroz ROS 2 ecosystem", disable_help_subcommand = true )] @@ -117,6 +120,20 @@ enum PluginAction { /// Path to the .wasm plugin file path: String, }, + /// Install a plugin from a local file, a URL, or a name in the registry + Install { + /// Path to a .wasm file, a URL, or a plugin name (e.g. `meter`) + source: String, + + /// Plugin index URL to resolve a name against (also HU_PLUGIN_REGISTRY) + #[arg(long, value_name = "URL")] + registry: Option, + }, + /// Remove an installed plugin + Uninstall { + /// Plugin name as shown by `hu plugin list` (e.g. `meter`) + name: String, + }, } #[tokio::main] @@ -140,6 +157,12 @@ async fn main() -> Result<(), Box> { Some(Commands::Plugin { action: PluginAction::Validate { path }, }) => return run_plugin_validate(path, cli.json), + Some(Commands::Plugin { + action: PluginAction::Install { source, registry }, + }) => return run_plugin_install(source, registry.as_deref(), cli.json), + Some(Commands::Plugin { + action: PluginAction::Uninstall { name }, + }) => return run_plugin_uninstall(name, cli.json), Some(Commands::Router { listen, config }) => { return run_router(listen.clone(), config.clone()).await; } @@ -318,14 +341,23 @@ fn run_plugin_list(json: bool) -> Result<(), Box = plugins .iter() .map(|(name, path)| { + let m = meta(name); serde_json::json!({ "name": name, "path": path.to_string_lossy(), "kind": "wasm", + "version": m.map(|m| m.version.clone()), + "source": m.map(|m| m.source.clone()).unwrap_or_else(|| "unmanaged".into()), }) }) .collect(); @@ -333,18 +365,111 @@ fn run_plugin_list(json: bool) -> Result<(), Box"); return Ok(()); } - println!("{:<20} PATH", "PLUGIN"); - println!("{}", "-".repeat(60)); + println!("{:<16} {:<10} {:<10} PATH", "PLUGIN", "VERSION", "SOURCE"); + println!("{}", "-".repeat(78)); for (name, path) in &plugins { - println!("{:<20} {}", name, path.to_string_lossy()); + let m = meta(name); + println!( + "{:<16} {:<10} {:<10} {}", + name, + m.map(|m| m.version.as_str()).unwrap_or("-"), + m.map(|m| source_label(&m.source)).unwrap_or("unmanaged"), + path.to_string_lossy() + ); } } Ok(()) } } +#[cfg(feature = "wasm-plugins")] +fn source_label(source: &str) -> &'static str { + if source.starts_with("http://") || source.starts_with("https://") { + "download" + } else if source == "local" { + "local" + } else { + "installed" + } +} + +fn run_plugin_install( + source: &str, + registry: Option<&str>, + json: bool, +) -> Result<(), Box> { + #[cfg(not(feature = "wasm-plugins"))] + { + let _ = (source, registry, json); + eprintln!("WASM plugin support not compiled in."); + std::process::exit(1); + } + #[cfg(feature = "wasm-plugins")] + { + match plugin::install::install(source, registry) { + Ok(path) => { + if json { + println!( + "{}", + serde_json::json!({"status": "installed", "path": path.to_string_lossy()}) + ); + } else { + println!("installed {}", path.display()); + println!("verify with: hu plugin list"); + } + Ok(()) + } + Err(e) => { + if json { + println!("{}", serde_json::json!({"error": e.to_string()})); + } else { + eprintln!("error: {e}"); + } + std::process::exit(1); + } + } + } +} + +fn run_plugin_uninstall( + name: &str, + json: bool, +) -> Result<(), Box> { + #[cfg(not(feature = "wasm-plugins"))] + { + let _ = (name, json); + eprintln!("WASM plugin support not compiled in."); + std::process::exit(1); + } + #[cfg(feature = "wasm-plugins")] + { + match plugin::install::uninstall(name) { + Ok(path) => { + if json { + println!( + "{}", + serde_json::json!({"status": "removed", "path": path.to_string_lossy()}) + ); + } else { + println!("removed {}", path.display()); + } + Ok(()) + } + Err(e) => { + if json { + println!("{}", serde_json::json!({"error": e.to_string()})); + } else { + eprintln!("error: {e}"); + } + std::process::exit(1); + } + } + } +} + fn run_plugin_validate( path: &str, json: bool, diff --git a/crates/hiroz-union/src/plugin/install.rs b/crates/hiroz-union/src/plugin/install.rs new file mode 100644 index 000000000..6f5c227f1 --- /dev/null +++ b/crates/hiroz-union/src/plugin/install.rs @@ -0,0 +1,421 @@ +//! Installing and removing WASM plugins. +//! +//! `hu meter` and `hu monitor` are not built into the binary — they are +//! `.wasm` components discovered on the plugin path. A user who downloads +//! `hu` therefore has no plugins at all until something puts them there. +//! This module is that something. +//! +//! Three sources are accepted, in decreasing order of how much we can check: +//! +//! - a **local path**, validated as a component before it is accepted; +//! - a **URL**, downloaded and, if a `.sha256` sits alongside it, verified; +//! - a **name** resolved through a release index, which carries a checksum +//! and the WIT world the plugin was built against. +//! +//! Note on trust: the checksums here protect against corruption and accidental +//! substitution. They are *not* authenticity — nothing is signed, and the +//! plugin permission model is self-declared by the plugin (see the WIT source). +//! Installing a plugin means trusting whoever wrote it. + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +use super::wasm::{plugin_search_dirs, sanitize_plugin_stem, validate_plugin_static}; + +/// WIT world this build of `hu` hosts. A plugin built against a different +/// world will not instantiate, so we refuse it up front with a readable +/// message instead of letting wasmtime fail later with a link error. +pub const HOST_WIT_WORLD: &str = "hu:plugin@0.1.0"; + +const DEFAULT_REGISTRY_ENV: &str = "HU_PLUGIN_REGISTRY"; + +#[derive(Debug, Deserialize)] +struct RegistryIndex { + #[allow(dead_code)] + schema: u32, + wit_world: String, + plugins: Vec, +} + +#[derive(Debug, Deserialize)] +struct RegistryEntry { + name: String, + file: String, + version: String, + sha256: String, +} + +/// Record of what was installed, so `hu plugin list` can tell a released +/// plugin from one a developer dropped in by hand. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct InstalledDb { + #[serde(default)] + pub plugins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstalledEntry { + pub name: String, + pub file: String, + pub version: String, + pub source: String, +} + +/// The directory installs write to: always the last search dir, which is the +/// per-user one. `$HU_PLUGIN_PATH` entries are deliberately not written to — +/// those point at build trees during development and are not ours to manage. +pub fn install_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; + Ok(home.join(".local/share/hu/plugins")) +} + +fn db_path() -> Result { + Ok(install_dir()?.join("installed.json")) +} + +pub fn load_db() -> InstalledDb { + db_path() + .ok() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +fn save_db(db: &InstalledDb) -> Result<()> { + let path = db_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, serde_json::to_string_pretty(db)?) + .with_context(|| format!("writing {}", path.display())) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().iter().map(|b| format!("{b:02x}")).collect() +} + +/// Drop a trailing `-` from a plugin filename stem. +/// +/// Only a suffix made purely of digits and dots counts, so `hu_meter-0.1.0` +/// loses its version while a plugin genuinely named `hu_my-tool` keeps its +/// name. Conservative on purpose: mangling a legitimate name would silently +/// rename someone's subcommand. +fn strip_version_suffix(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((head, tail)) + if !head.is_empty() + && !tail.is_empty() + && tail.chars().all(|c| c.is_ascii_digit() || c == '.') + && tail.chars().any(|c| c.is_ascii_digit()) => + { + head + } + _ => stem, + } +} + +fn is_url(s: &str) -> bool { + s.starts_with("http://") || s.starts_with("https://") +} + +/// Download over `curl`. `hu` deliberately carries no HTTP client — pulling in +/// a TLS stack for an occasional convenience command is a poor trade, and +/// `curl` is present anywhere a user could have downloaded `hu` in the first +/// place. +fn http_get(url: &str) -> Result> { + let mut cmd = std::process::Command::new("curl"); + // `--fail` matters: without it an HTTP error page is written to stdout and + // we would cheerfully install a 404 as a plugin. + cmd.args(["-fsSL", "--", url]); + if let Ok(token) = std::env::var("HU_RELEASE_TOKEN") { + cmd.arg("-H").arg(format!("Authorization: token {token}")); + } + let out = cmd + .output() + .with_context(|| "running curl (is it installed?)")?; + if !out.status.success() { + bail!( + "download failed for {url}: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(out.stdout) +} + +/// Accept a `.wasm` payload: check the world, check it compiles as a +/// component, then place it. Returns the installed path. +fn accept( + bytes: &[u8], + file_name: &str, + expected_sha: Option<&str>, + source: &str, + version: &str, +) -> Result { + if let Some(want) = expected_sha { + let got = sha256_hex(bytes); + if !want.eq_ignore_ascii_case(&got) { + bail!( + "checksum mismatch for {file_name}\n expected {want}\n got {got}\n\ + The download is corrupt or has been altered. Nothing was installed." + ); + } + } + + // The file name comes from a URL or an index we did not write, so it is + // attacker-influenced. Reuse the same sanitizer the plugin work dirs use: + // it collapses `..` and separators, so the result is one safe segment and + // cannot escape the install dir. + // + // Strip the version first. Release assets are named `hu_meter-0.1.0.wasm`, + // and discovery derives the subcommand from the filename — so keeping the + // suffix would install `hu meter-0_1_0` instead of `hu meter`, i.e. the + // documented command would not exist. Must happen before sanitizing, which + // turns the dots into underscores and makes the suffix unrecognizable. + let stem = Path::new(file_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + let safe = sanitize_plugin_stem(strip_version_suffix(stem)); + + let dir = install_dir()?; + std::fs::create_dir_all(&dir)?; + + // Validate before it lands on the plugin path, using a temp file — a + // component that will not compile must never be discoverable, not even + // briefly. + let tmp = dir.join(format!(".{safe}.wasm.partial")); + std::fs::write(&tmp, bytes).with_context(|| format!("writing {}", tmp.display()))?; + let validation = validate_plugin_static(&tmp); + if let Err(e) = validation { + let _ = std::fs::remove_file(&tmp); + bail!("{file_name} is not a loadable WASM component: {e}"); + } + + let dest = dir.join(format!("{safe}.wasm")); + std::fs::rename(&tmp, &dest).with_context(|| format!("installing {}", dest.display()))?; + + let display_name = safe + .strip_prefix("hu_") + .or_else(|| safe.strip_prefix("hu-")) + .unwrap_or(&safe) + .to_string(); + + let mut db = load_db(); + db.plugins.retain(|p| p.name != display_name); + db.plugins.push(InstalledEntry { + name: display_name, + file: format!("{safe}.wasm"), + version: version.to_string(), + source: source.to_string(), + }); + save_db(&db)?; + + Ok(dest) +} + +/// Install from a local path, a URL, or a name resolved through the registry. +pub fn install(source: &str, registry: Option<&str>) -> Result { + let local = Path::new(source); + if local.exists() { + let bytes = std::fs::read(local).with_context(|| format!("reading {source}"))?; + let name = local + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("plugin.wasm"); + // A sibling `.sha256` is honoured when present; its absence is not a + // failure for a local file the user already has in hand. + let sidecar = local.with_extension("wasm.sha256"); + let expected = std::fs::read_to_string(&sidecar) + .ok() + .and_then(|s| s.split_whitespace().next().map(str::to_string)); + return accept(&bytes, name, expected.as_deref(), source, "local"); + } + + if is_url(source) { + let bytes = http_get(source)?; + let name = source.rsplit('/').next().unwrap_or("plugin.wasm"); + let expected = http_get(&format!("{source}.sha256")) + .ok() + .and_then(|b| String::from_utf8(b).ok()) + .and_then(|s| s.split_whitespace().next().map(str::to_string)); + return accept(&bytes, name, expected.as_deref(), source, "url"); + } + + install_from_registry(source, registry) +} + +/// The index published alongside this `hu`'s own release. +/// +/// Without a default, `hu plugin install meter` refused until the user found +/// and exported a URL — for the plugins this very binary was released with. +/// Pinned to `CARGO_PKG_VERSION` rather than "latest" so a plugin always +/// matches the host that installs it; the WIT-world check below is the +/// backstop, not the first line of defence. +fn default_registry_url() -> String { + format!( + "https://github.com/ZettaScaleLabs/hiroz/releases/download/v{v}/hu-plugins-{v}.json", + v = env!("CARGO_PKG_VERSION") + ) +} + +fn install_from_registry(name: &str, registry: Option<&str>) -> Result { + let index_url = registry + .map(str::to_string) + .or_else(|| std::env::var(DEFAULT_REGISTRY_ENV).ok()) + .unwrap_or_else(default_registry_url); + + let raw = http_get(&index_url).with_context(|| { + format!( + "fetching the plugin index at {index_url}\n\ + '{name}' is not an existing file or a URL, so it was looked up in the index \ + published with this hu. Override with {DEFAULT_REGISTRY_ENV} or --registry, or \ + install from a downloaded file:\n hu plugin install ./hu_{name}.wasm" + ) + })?; + let index: RegistryIndex = serde_json::from_slice(&raw) + .with_context(|| format!("parsing plugin index at {index_url}"))?; + + if index.wit_world != HOST_WIT_WORLD { + bail!( + "plugin index targets WIT world {} but this hu hosts {}.\n\ + Install a release matching this hu, or upgrade hu.", + index.wit_world, + HOST_WIT_WORLD + ); + } + + let entry = index + .plugins + .iter() + .find(|p| p.name == name) + .ok_or_else(|| { + let available: Vec<&str> = index.plugins.iter().map(|p| p.name.as_str()).collect(); + anyhow!( + "no plugin named '{name}' in the index. Available: {}", + if available.is_empty() { + "(none)".to_string() + } else { + available.join(", ") + } + ) + })?; + + // Assets sit next to the index. + let base = index_url + .rsplit_once('/') + .map(|(b, _)| b.to_string()) + .unwrap_or_default(); + let asset_url = format!("{base}/{}", entry.file); + let bytes = http_get(&asset_url)?; + + accept( + &bytes, + &entry.file, + Some(&entry.sha256), + &asset_url, + &entry.version, + ) +} + +/// Remove an installed plugin by its subcommand name. +pub fn uninstall(name: &str) -> Result { + let dir = install_dir()?; + let candidates = [ + dir.join(format!("hu_{name}.wasm")), + dir.join(format!("hu-{name}.wasm")), + dir.join(format!("{name}.wasm")), + ]; + let found = candidates.iter().find(|p| p.exists()).ok_or_else(|| { + // Point at the real cause when the plugin is on the path but not in + // the dir we manage — removing it is not ours to do. + let elsewhere = plugin_search_dirs() + .into_iter() + .filter(|d| *d != dir) + .any(|d| { + ["hu_", "hu-", ""] + .iter() + .any(|p| d.join(format!("{p}{name}.wasm")).exists()) + }); + if elsewhere { + anyhow!( + "'{name}' is loaded from a directory on $HU_PLUGIN_PATH, not from {}. \ + Remove it there, or unset $HU_PLUGIN_PATH.", + dir.display() + ) + } else { + anyhow!("no installed plugin named '{name}' in {}", dir.display()) + } + })?; + + std::fs::remove_file(found).with_context(|| format!("removing {}", found.display()))?; + + let mut db = load_db(); + db.plugins.retain(|p| p.name != name); + let _ = save_db(&db); + + Ok(found.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha256_matches_known_vector() { + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn traversal_in_a_downloaded_name_cannot_escape_the_install_dir() { + // The name is attacker-influenced; every separator and dot must be + // collapsed so the result stays one segment. + for evil in ["../../../etc/passwd", "..", "a/b/c", "hu_../x"] { + let stem = Path::new(evil) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + let safe = sanitize_plugin_stem(stem); + assert!(!safe.contains('/'), "{evil} → {safe}"); + assert!(!safe.contains(".."), "{evil} → {safe}"); + assert_eq!(Path::new(&safe).components().count(), 1, "{evil} → {safe}"); + } + } + + #[test] + fn release_asset_names_install_under_their_plain_subcommand_name() { + // Regression: installing the release asset `hu_meter-0.1.0.wasm` gave + // the subcommand `hu meter-0_1_0`, so the documented `hu meter` did + // not exist after installing exactly what the release publishes. + assert_eq!(strip_version_suffix("hu_meter-0.1.0"), "hu_meter"); + assert_eq!(strip_version_suffix("hu_monitor-1.2.3"), "hu_monitor"); + assert_eq!(strip_version_suffix("hu_meter-12"), "hu_meter"); + } + + #[test] + fn a_name_that_merely_contains_a_hyphen_is_left_alone() { + // Renaming someone's subcommand because it has a hyphen would be + // worse than leaving a version on. + assert_eq!(strip_version_suffix("hu_my-tool"), "hu_my-tool"); + assert_eq!(strip_version_suffix("hu_meter"), "hu_meter"); + assert_eq!(strip_version_suffix("hu_a-b-c"), "hu_a-b-c"); + assert_eq!(strip_version_suffix("-1.0"), "-1.0"); + assert_eq!(strip_version_suffix("hu_x-"), "hu_x-"); + assert_eq!(strip_version_suffix("hu_x-..."), "hu_x-..."); + } + + #[test] + fn url_detection_does_not_treat_a_path_as_a_url() { + assert!(is_url("https://example.com/hu_meter.wasm")); + assert!(is_url("http://example.com/hu_meter.wasm")); + assert!(!is_url("./hu_meter.wasm")); + assert!(!is_url("meter")); + assert!(!is_url("/home/u/hu_meter.wasm")); + } +} diff --git a/crates/hiroz-union/src/plugin/mod.rs b/crates/hiroz-union/src/plugin/mod.rs index ce1d9f82e..94e94e12f 100644 --- a/crates/hiroz-union/src/plugin/mod.rs +++ b/crates/hiroz-union/src/plugin/mod.rs @@ -1 +1,3 @@ +#[cfg(feature = "wasm-plugins")] +pub mod install; pub mod wasm; diff --git a/crates/hiroz-union/src/plugin/wasm/mod.rs b/crates/hiroz-union/src/plugin/wasm/mod.rs index 50ed46897..41017b683 100644 --- a/crates/hiroz-union/src/plugin/wasm/mod.rs +++ b/crates/hiroz-union/src/plugin/wasm/mod.rs @@ -430,7 +430,7 @@ fn iter_wasm_files() -> impl Iterator { /// outside `[A-Za-z0-9_-]` — including `.` and path separators — to `_`, so the /// result is always a single safe segment (`..` becomes `__`); fall back to /// `"unknown"` only for an empty stem. -fn sanitize_plugin_stem(plugin_stem: &str) -> String { +pub(crate) fn sanitize_plugin_stem(plugin_stem: &str) -> String { let cleaned: String = plugin_stem .chars() .map(|c| { @@ -656,16 +656,33 @@ pub fn validate_plugin_static(path: &std::path::Path) -> Result { Ok(format!("OK: {} is a valid WASM component", path.display())) } -fn plugin_search_dirs() -> Vec { +pub(crate) fn plugin_search_dirs() -> Vec { let mut dirs = Vec::new(); if let Ok(paths) = std::env::var("HU_PLUGIN_PATH") { for p in std::env::split_paths(&paths) { dirs.push(p); } } + // Prefix-relative, derived from where THIS binary sits. `install-hu.sh + // --prefix /opt/hu` writes the binary to /opt/hu/bin/hu and the plugins to + // /opt/hu/share/hu/plugins; without this the install succeeds, reports + // "installed plugin hu_meter.wasm", and then `hu plugin list` -- which the + // installer's own closing message tells the user to run -- is empty, + // because discovery only ever looked under $HOME. + // + // Both CI callers set HU_PREFIX and HOME to the same scratch directory, + // which is the one configuration where that bug cannot appear. + if let Ok(exe) = std::env::current_exe() + && let Some(prefix) = exe.parent().and_then(|bin| bin.parent()) + { + dirs.push(prefix.join("share/hu/plugins")); + } if let Some(home) = dirs::home_dir() { dirs.push(home.join(".local/share/hu/plugins")); } + // A default-prefix install makes the two paths above identical, and a + // duplicated directory would list every plugin twice. + dirs.dedup(); dirs } diff --git a/crates/hiroz-union/tests/plugin_install.rs b/crates/hiroz-union/tests/plugin_install.rs new file mode 100644 index 000000000..37278a801 --- /dev/null +++ b/crates/hiroz-union/tests/plugin_install.rs @@ -0,0 +1,339 @@ +//! `hu plugin install` over the network: URL and registry sources. +//! +//! These drive the **real binary** via `CARGO_BIN_EXE_hu` rather than calling +//! the functions directly. That is not a stylistic choice: `hiroz-union` is a +//! binary-only crate with no lib target, so an integration test cannot `use` +//! it. Driving the CLI also covers argument parsing and exit status, which is +//! what a user and a script actually depend on. +//! +//! Every test here is a **refusal**. Those are the paths that had never once +//! executed — including the WIT world-mismatch check, which is the kind of +//! guard that looks correct forever and is never proven to fire. +//! +//! The success paths are **not** here, because they need a genuine WASM +//! component and this crate cannot build one: the plugins are a separate, +//! `exclude`d, `wasm32-wasip2` workspace. They live at the end of +//! `scripts/ci/hu-tests.sh`, which has already built real plugins by that +//! point — install by URL with a `.sha256` sidecar, install by registry name +//! through a served index, dispatch, and uninstall. +//! +//! An earlier version of this comment claimed that script and the +//! docs-reproduction suite already covered them. Neither did: the script +//! touched only `plugin validate` and `plugin list`, and every +//! `hu plugin install` line in the docs is `skip`. The claim is why nobody +//! noticed for so long — a comment asserting coverage is as good at hiding a +//! gap as a doc asserting behaviour. +//! +//! The server is a few lines of `std::net` on a loopback ephemeral port: no +//! new dependency, and no network access, so these stay runnable offline. + +use std::{ + collections::HashMap, + io::{BufRead, BufReader, Write}, + net::{TcpListener, TcpStream}, + path::PathBuf, + process::Command, + sync::Arc, +}; + +/// A canned response: HTTP status and body. +type Route = (u16, Vec); + +/// Serve a fixed route table on loopback until the test drops the handle. +/// Returns the base URL. +fn serve(routes: HashMap) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let port = listener.local_addr().unwrap().port(); + let routes = Arc::new(routes); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let routes = Arc::clone(&routes); + std::thread::spawn(move || handle(stream, &routes)); + } + }); + + format!("http://127.0.0.1:{port}") +} + +fn handle(mut stream: TcpStream, routes: &HashMap) { + let mut reader = BufReader::new(match stream.try_clone() { + Ok(s) => s, + Err(_) => return, + }); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + // "GET /path HTTP/1.1" + let path = request_line.split_whitespace().nth(1).unwrap_or("/"); + + let (status, body) = routes + .get(path) + .cloned() + .unwrap_or((404, b"not found".to_vec())); + + let head = format!( + "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); +} + +struct Outcome { + ok: bool, + output: String, + home: PathBuf, +} + +impl Outcome { + /// Nothing may be left in the plugin directory after a refusal — a + /// partially-written plugin is worse than none, because discovery would + /// pick it up. + fn installed_plugins(&self) -> Vec { + let dir = self.home.join(".local/share/hu/plugins"); + std::fs::read_dir(dir) + .map(|rd| { + rd.flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default() + } +} + +/// Run `hu plugin install ` with an isolated HOME. +fn install(args: &[&str]) -> Outcome { + let home = std::env::temp_dir().join(format!( + "hu-install-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let out = Command::new(env!("CARGO_BIN_EXE_hu")) + .arg("plugin") + .arg("install") + .args(args) + .env("HOME", &home) + // Discovery must not reach a build tree during these tests. + .env_remove("HU_PLUGIN_PATH") + .env_remove("HU_PLUGIN_REGISTRY") + .output() + .expect("run hu"); + + let output = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + Outcome { + ok: out.status.success(), + output, + home, + } +} + +fn index_json(world: &str, file: &str, sha: &str) -> Vec { + format!( + r#"{{"schema":1,"hu_version":"0.1.0","wit_world":"{world}", + "plugins":[{{"name":"meter","file":"{file}","version":"0.1.0", + "sha256":"{sha}","world":"hu-cli-plugin","description":"d"}}]}}"# + ) + .into_bytes() +} + +#[test] +fn url_install_refuses_a_404_instead_of_installing_the_error_page() { + let base = serve(HashMap::new()); // every path 404s + let r = install(&[&format!("{base}/hu_meter.wasm")]); + + assert!(!r.ok, "a 404 must fail, output was:\n{}", r.output); + assert!( + r.output.contains("download failed") || r.output.contains("error"), + "should say the download failed, got:\n{}", + r.output + ); + assert!( + r.installed_plugins().is_empty(), + "nothing may be installed after a 404, found {:?}", + r.installed_plugins() + ); +} + +#[test] +fn url_install_refuses_a_checksum_mismatch() { + let mut routes = HashMap::new(); + routes.insert("/hu_meter.wasm".to_string(), (200, b"payload".to_vec())); + // Sidecar advertises a hash the payload does not have. + routes.insert("/hu_meter.wasm.sha256".to_string(), (200, vec![b'0'; 64])); + let base = serve(routes); + + let r = install(&[&format!("{base}/hu_meter.wasm")]); + assert!(!r.ok, "checksum mismatch must fail:\n{}", r.output); + assert!( + r.output.contains("checksum mismatch"), + "should name the mismatch, got:\n{}", + r.output + ); + assert!(r.installed_plugins().is_empty()); +} + +#[test] +fn url_install_refuses_bytes_that_are_not_a_component() { + let mut routes = HashMap::new(); + // No sidecar, so the checksum step is skipped and validation is what has + // to catch this. + routes.insert( + "/hu_meter.wasm".to_string(), + (200, b"definitely not a wasm component".to_vec()), + ); + let base = serve(routes); + + let r = install(&[&format!("{base}/hu_meter.wasm")]); + assert!(!r.ok, "a non-component must fail:\n{}", r.output); + assert!( + r.output.contains("not a loadable WASM component"), + "should say it is not loadable, got:\n{}", + r.output + ); + assert!( + r.installed_plugins().is_empty(), + "the partial file must be cleaned up, found {:?}", + r.installed_plugins() + ); +} + +#[test] +fn registry_install_refuses_a_wit_world_this_hu_does_not_host() { + let mut routes = HashMap::new(); + routes.insert( + "/index.json".to_string(), + ( + 200, + index_json("hu:plugin@9.9.9", "hu_meter-0.1.0.wasm", &"0".repeat(64)), + ), + ); + let base = serve(routes); + + let r = install(&["meter", "--registry", &format!("{base}/index.json")]); + assert!(!r.ok, "a world mismatch must fail:\n{}", r.output); + assert!( + r.output.contains("9.9.9") && r.output.contains("hu:plugin@"), + "the message must name both worlds so the user can act, got:\n{}", + r.output + ); + assert!(r.installed_plugins().is_empty()); +} + +#[test] +fn registry_install_refuses_an_unknown_name_and_lists_what_exists() { + let mut routes = HashMap::new(); + routes.insert( + "/index.json".to_string(), + ( + 200, + index_json("hu:plugin@0.1.0", "hu_meter-0.1.0.wasm", &"0".repeat(64)), + ), + ); + let base = serve(routes); + + let r = install(&["nosuchplugin", "--registry", &format!("{base}/index.json")]); + assert!(!r.ok, "an unknown name must fail:\n{}", r.output); + assert!( + r.output.contains("meter"), + "should list what IS available, got:\n{}", + r.output + ); +} + +#[test] +fn registry_install_without_a_registry_says_how_to_configure_one() { + // Not a URL and not an existing file, with no registry configured: the + // message has to name the way out, or the user is stuck. + let r = install(&["meter"]); + assert!(!r.ok); + assert!( + r.output.contains("HU_PLUGIN_REGISTRY") && r.output.contains("--registry"), + "should name both ways to configure a registry, got:\n{}", + r.output + ); +} + +// --------------------------------------------------------------- uninstall +// +// `hu plugin uninstall` had never run either. Its interesting behaviour is +// the second branch: a plugin visible on `$HU_PLUGIN_PATH` is discoverable +// but not ours to delete, and saying "not installed" there would be actively +// misleading — the user can see it in `hu plugin list`. + +/// Run `hu plugin uninstall `, optionally with a plugin dir on +/// `$HU_PLUGIN_PATH`. +fn uninstall(name: &str, plugin_path: Option<&std::path::Path>) -> Outcome { + let home = std::env::temp_dir().join(format!( + "hu-uninstall-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu")); + cmd.arg("plugin") + .arg("uninstall") + .arg(name) + .env("HOME", &home); + match plugin_path { + Some(p) => cmd.env("HU_PLUGIN_PATH", p), + None => cmd.env_remove("HU_PLUGIN_PATH"), + }; + + let out = cmd.output().expect("run hu"); + let output = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + Outcome { + ok: out.status.success(), + output, + home, + } +} + +#[test] +fn uninstall_refuses_a_plugin_that_is_not_installed() { + let r = uninstall("nosuchplugin", None); + assert!(!r.ok, "removing nothing must fail:\n{}", r.output); + assert!( + r.output + .contains("no installed plugin named 'nosuchplugin'"), + "should name what it looked for, got:\n{}", + r.output + ); +} + +#[test] +fn uninstall_explains_when_the_plugin_lives_on_hu_plugin_path() { + // A plugin here is discoverable — `hu plugin list` shows it — but it is + // not in the directory installs manage. "Not installed" would contradict + // what the user can see, so the message has to distinguish the two. + let dir = std::env::temp_dir().join(format!("hu-extpath-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("hu_meter.wasm"), b"not a real component").unwrap(); + + let r = uninstall("meter", Some(&dir)); + assert!(!r.ok, "must not claim success:\n{}", r.output); + assert!( + r.output.contains("HU_PLUGIN_PATH"), + "should point at the real location, not say 'not installed', got:\n{}", + r.output + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/hiroz-union/wit/v0.1/hu-plugin.wit b/crates/hiroz-union/wit/v0.1/hu-plugin.wit index 84165bd97..e4232ac93 100644 --- a/crates/hiroz-union/wit/v0.1/hu-plugin.wit +++ b/crates/hiroz-union/wit/v0.1/hu-plugin.wit @@ -64,7 +64,8 @@ interface types { denied, // The request was malformed (bad key expression, YAML/JSON, etc.). invalid(string), - // The underlying Zenoh transport failed. + // The underlying transport, or host-side setup such as schema + // discovery, failed. Carries a human-readable reason. transport(string), } diff --git a/docs/tools/hu-install.md b/docs/tools/hu-install.md new file mode 100644 index 000000000..47311dbb0 --- /dev/null +++ b/docs/tools/hu-install.md @@ -0,0 +1,141 @@ +# Installing hu + +`hu` ships as two separate things, and you need both: + +- the **`hu` binary** — the plugin host, the TUI, `stream`, `router`, `web` and `plugin` management; +- the **plugins** — `hu_meter.wasm` and `hu_monitor.wasm`. `hu meter` and `hu monitor` are not built into the binary. Without the plugins those subcommands do not exist. + +Everything here works with no ROS 2 install. `hu` only needs to reach a Zenoh router. + +## Quickest path + +Download the installer and run it. With no arguments it asks GitHub which release is newest and installs that: + + +```bash +curl -fsSL https://github.com/ZettaScaleLabs/hiroz/releases/latest/download/install-hu.sh -o install-hu.sh +sh install-hu.sh +``` + +**Download the installer, then run it — do not pipe it into a shell.** Two failure modes look like success if you pipe. A wrong URL makes `curl -fsSL` fail silently, `sh` then reads empty input and exits 0, so you see nothing and no error. And a connection that drops mid-transfer still executes every complete line that arrived, which can leave `hu` installed with no plugins. Downloading first makes `curl`'s exit status stop the install, and gives `sh` a complete file. + +That downloads the binary and the plugins, verifies both against `SHA256SUMS`, installs `hu` to `~/.local/bin/` and the plugins to `~/.local/share/hu/plugins/`. + +To install a specific version rather than the newest, pass `--version X.Y.Z`. To install from somewhere other than this project's GitHub releases, set `HU_RELEASE_BASE` to that release's download directory — then the installer looks nowhere else, which matters if you fetched the script from one place and its assets live in another. + +**`HU_RELEASE_BASE` is a release's download directory**, ending `/releases/download/v`. Point it at the release you were given; the filenames below it are the same either way. + +Set `--prefix` (or `HU_PREFIX`) to install somewhere other than `~/.local`. `hu` looks for plugins next to its own binary — under `/share/hu/plugins` — as well as in `~/.local/share/hu/plugins`, so a prefixed install finds its own plugins. + +Verify: + + +```bash +hu --version +hu plugin list +``` + +`hu plugin list` must show `meter` and `monitor`. If it is empty, the plugins did not install and every `hu meter` / `hu monitor` command will fail. + +## Credentials + +Whether you need a token depends on the release host, so the installer does not decide for you. It reads `$HU_RELEASE_TOKEN` from the environment and **never** carries one of its own. It never reads a credential from a file. + +If it finds one it sends it with every download. If it finds none it proceeds without one — a public release host needs no credential, and refusing up front would block installing from, say, a public GitHub release that anyone can `curl`. + +A missing credential therefore surfaces as a failed download, not as an early refusal, because that is the first point at which the host's requirement is actually known. The failure message names the variable to set. Nothing is ever installed from an error page: `curl --fail` makes an HTTP error an error, so a 401 or a 404 body is never written to disk and never unpacked. + +If you have no account, use the offline path below — it needs no network and no credential. + +## Offline install + +Someone hands you the release files; you install from a directory: + + +```bash +install-hu.sh --offline ./hu-release +``` + +The directory needs at least `SHA256SUMS` and the binary tarball for your platform. Include `hu-plugins-.tar.gz` to get `meter` and `monitor` too. Checksums are still enforced — a file with no entry in `SHA256SUMS` is refused, because an unlisted file is exactly what a substituted file looks like. + +## Manual install + +Verify the download first. `sha256sum -c` exits non-zero on a mismatch, so stop here if it does — do not extract a file that failed this check: + + +```bash +sha256sum -c SHA256SUMS +``` + +Then extract and install: + + +```bash +tar -xzf hu-0.1.0-x86_64-unknown-linux-gnu.tar.gz +install -Dm755 hu ~/.local/bin/hu + +mkdir -p ~/.local/share/hu/plugins +tar -xzf hu-plugins-0.1.0.tar.gz -C ~/.local/share/hu/plugins +``` + +## Installing plugins individually + +`hu plugin install` accepts a local file, a URL, or a name from a release index: + + +```bash +hu plugin install ./hu_meter-0.1.0.wasm +hu plugin install https://example.invalid/hu_meter-0.1.0.wasm +hu plugin install meter --registry "$BASE/hu-plugins-0.1.0.json" # $BASE as above +``` + +In every case the file is checked before it is accepted: + +- if a checksum is available — from the index, or from a `.sha256` sitting next to the file — it must match; +- the file must compile as a WASM component, checked in a temporary location so a broken plugin is never briefly discoverable; +- when installing by name, the index's WIT world must match the one this `hu` hosts, so a plugin built for a different `hu` is refused with a readable message instead of a link error later. + +Remove one with: + + +```bash +hu plugin uninstall meter +``` + +`hu plugin install` writes only to `~/.local/share/hu/plugins/`. Directories on `$HU_PLUGIN_PATH` are left alone — those point at build trees during development and are not the installer's to manage. + +### What the checksums do and do not buy you + +They protect against a corrupted or accidentally substituted download. They are **not** authenticity: nothing is signed, there is no registry that vouches for a publisher, and a plugin's declared permissions are self-reported by the plugin itself rather than enforced against it. Installing a plugin means trusting whoever wrote it. + +## Development builds + +To run plugins you are building from source, point `$HU_PLUGIN_PATH` at the build output instead of installing: + +```bash +export HU_PLUGIN_PATH=$PWD/crates/hiroz-union/plugins/target/wasm32-wasip2/release +``` + +`$HU_PLUGIN_PATH` is searched before `~/.local/share/hu/plugins/`, so a development build shadows an installed one of the same name. `hu plugin list` shows the path each plugin was loaded from, plus a `SOURCE` column — `unmanaged` means it was not installed by `hu plugin install`. + +## Uninstalling + + +```bash +rm ~/.local/bin/hu +rm -rf ~/.local/share/hu +``` + +## Platform coverage + +| Platform | Published | +|---|---| +| Linux x86_64 | yes | +| Linux aarch64 | yes, when built | +| macOS aarch64 | yes, when built | +| macOS x86_64 | no — build from source | +| Windows | no | + +The plugins are `wasm32-wasip2` and platform-independent: one `.wasm` runs everywhere `hu` does. + +A release covers only the platforms its build legs produced. If a tarball is missing from a release, it was not produced for that version. diff --git a/docs/tools/hu-plugins.md b/docs/tools/hu-plugins.md index 7391d1b69..32a8d6278 100644 --- a/docs/tools/hu-plugins.md +++ b/docs/tools/hu-plugins.md @@ -122,18 +122,31 @@ Then `cargo build --release` builds the component with no flags (after a one-tim Name the file `.wasm` — `hu` strips any `hu-` prefix when discovering plugins, so `hu-meter.wasm` registers as `meter` and is invoked by `hu meter `. +Prefer `hu plugin install`. It checks the file compiles as a WASM component **before** accepting it, and does that in a temporary location so a broken plugin is never briefly discoverable: + +```sh +# repro: skip my_hu_plugin.wasm is the plugin the reader has just written +hu plugin install target/wasm32-wasip2/release/my_hu_plugin.wasm +``` + +Copying the file by hand works too, and is what you want when iterating — but nothing validates it, and `hu plugin list` reads filenames without opening them, so a corrupt plugin looks identical to a working one until you run it: + ```sh mkdir -p ~/.local/share/hu/plugins +# repro: skip my_hu_plugin.wasm is the plugin the reader has just written cp target/wasm32-wasip2/release/my_hu_plugin.wasm \ ~/.local/share/hu/plugins/my-plugin.wasm ``` +`hu plugin uninstall ` removes one that `hu` installed. It refuses a plugin on `$HU_PLUGIN_PATH`, since that points at a build tree. + Start `hu` and press `5` to open the Plugins panel (TUI plugins), or run `hu my-plugin ` from the terminal (CLI plugins). If `hu` is already running in the TUI, you don't need to restart it — copy the `.wasm` into a plugin directory and press `R` on the Plugins panel to rescan and load it live. ### 6. Run it end-to-end The shipped template (`crates/hiroz-union/plugins/hu-plugin-template/`) is the crate above. Build it, point `HU_PLUGIN_PATH` at the output, and invoke it by its manifest name (`my-plugin`). Its `on_event` handler stores the `Startup` args and prints `hello from WASM!` on every `Tick` (`tick_ms = 1000`), so a running session emits one line per second until interrupted: + ```sh # CARGO_TARGET_DIR pins the output dir; a standalone --manifest-path build # otherwise writes under the plugin crate's own target/ directory. diff --git a/mkdocs.yml b/mkdocs.yml index 75a1855a8..1fbb2ab2f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,7 @@ nav: - hu Toolkit: - Overview: tools/why-hu.md - hu (Hiroz Union): tools/hu.md + - Installing hu: tools/hu-install.md - hu vs. ros2cli / rqt: tools/hu-vs-ros2cli.md - hu Plugin Authoring: tools/hu-plugins.md - Language Bindings: diff --git a/scripts/build-hu-release.nu b/scripts/build-hu-release.nu new file mode 100755 index 000000000..f1a5778e3 --- /dev/null +++ b/scripts/build-hu-release.nu @@ -0,0 +1,334 @@ +#!/usr/bin/env nu +# Produce the `hu` release artifact set into a dist directory. +# +# The single source of truth for what a `hu` release contains. Every release +# platform calls it, so they cannot drift apart in what they ship. +# +# hu--.tar.gz hu binary + LICENSE + install README +# hu_meter-.wasm wasm32-wasip2 — platform-independent +# hu_monitor-.wasm +# hu-plugins-.tar.gz both plugins, for offline install +# hu-plugins-.json index that `hu plugin install ` resolves +# install-hu.sh the installer the release notes tell users to curl +# SHA256SUMS covers every file above +# +# The plugins are the point: `hu meter` and `hu monitor` do not exist without +# them, because they are not built into the binary. `install-hu.sh` ships +# because the release notes tell users to curl it from the release. + +const PLUGIN_DIR = "crates/hiroz-union/plugins" +const WIT_WORLD = "hu:plugin@0.1.0" +const INSTALLER = "scripts/install-hu.sh" + +# Everything that ships. `name` is the subcommand (the `hu_`/`hu-` prefix is +# stripped at discovery), `dir` the crate directory, `stem` the .wasm filename. +const PLUGINS = [ + [name, dir, stem, world, description]; + ["meter" "hu-meter" "hu_meter" "hu-cli-plugin" "Topic rate, bandwidth, echo, publish, latency, graph and parameter introspection"] + ["monitor" "hu-monitor" "hu_monitor" "hu-cli-plugin" "Live graph events, graph snapshots, /rosout tailing and logger levels"] +] + +# `hiroz-union` inherits the workspace version (`version.workspace = true`), so +# read the workspace, not the crate. Reading the crate file used to work because +# it carried a literal version -- which meant `hu` could silently drift from the +# rest of the workspace, and a `v0.2.0` tag against a `0.1.0` crate failed the +# whole release at packaging. One version now governs both. +# +# Note the crate file no longer contains a quoted version at all, so the old +# `split row '"' | get 1` on it raises rather than returning a wrong answer. +def crate-version [] { + open --raw Cargo.toml + | lines + | skip until { |l| ($l | str trim) == "[workspace.package]" } + | where { |l| ($l | str trim | str starts-with "version") } + | first + | split row "\"" + | get 1 +} + +def sha256-of [path: string] { + open --raw $path | hash sha256 +} + +# Honour CARGO_TARGET_DIR — CI often redirects it, and assuming ./target makes +# the script silently look for artifacts that were written elsewhere. +def target-root [] { + $env.CARGO_TARGET_DIR? | default "target" +} + +def main [ + --target: string = "" # rust target triple; empty = host + --out: string = "dist" # output directory + --version: string = "" # expected version; must match the crate + --plugins-only # skip the host binary (for non-primary legs) + --binary-only # skip the plugins (they ship from one leg only) + --binary-from: string = "" # package this prebuilt hu instead of running cargo + --no-validate # skip `hu plugin validate` (cross-compiled legs) + --no-sums # skip SHA256SUMS; the caller assembles one +] { + let ver = (crate-version) + + # A tag that disagrees with the crate would mislabel every asset. Fail + # loudly rather than shipping `hu-0.2.0-...tar.gz` containing 0.1.0. + # + # Only the CORE version has to match. A semver pre-release suffix + # (`0.1.0-rc1`) is legitimate: it names a rehearsal of 0.1.0, built from + # the 0.1.0 source, and the assets it produces are 0.1.0 assets. Requiring + # an exact match here is what previously left no way to exercise the + # release pipeline except by publishing a real release and deleting it + # afterwards — a workaround, not a test. + let core = ($version | split row "-" | first) + if $version != "" and $core != $ver { + print $"FAIL: requested version ($version) has core ($core), but the hiroz-union crate is ($ver)" + exit 1 + } + if $version != "" and $core != $version { + print $" pre-release ($version) — assets are named for the core version ($ver)" + } + + mkdir $out + print $"hu release ($ver) → ($out)/" + + if not $plugins_only { + build-binary $ver $target $out $binary_from + } + if not $binary_only { + build-plugins $ver $out (not $no_validate) + # Staged on the same leg as the plugins, and deliberately not on a + # `--binary-only` one. Both are release-wide, platform-independent + # assets: there is exactly one correct copy per release, and the GitHub + # channel runs `--binary-only` once per target triple (three legs) and + # `--plugins-only` once. Staging unconditionally would have three legs + # racing to upload the same `install-hu.sh` under the same asset name — + # the same reason the plugins themselves ship from one leg only. + stage-installer $out + } + + # A SHA256SUMS covering only part of a release is worse than none: it + # verifies clean while saying nothing about the assets it omits, and + # `install-hu.sh` treats an unlisted file as a refusal. So when a caller + # assembles a release from several jobs, it must pass --no-sums here and + # generate one file over the complete set. + if $no_sums { + print " (SHA256SUMS skipped — caller assembles it over the full asset set)" + } else { + write-sums $out + } + print "" + ls $out | select name size | print +} + +def build-binary [ver: string, target: string, out: string, binary_from: string] { + # `--binary-from` packages a binary someone else built. It exists because + # the cross legs need `cargo zigbuild`, not `cargo build`, and pushing that + # knowledge in here would mean this script had to model every leg's build. + # What actually has to be shared is the *packaging* — the tarball name and + # contents — because that is the contract `install-hu.sh` reads. Letting a + # leg build however it likes and package through here is what keeps the + # channels from drifting. + let bin = if $binary_from != "" { + if not ($binary_from | path exists) { + print $"FAIL: --binary-from ($binary_from) does not exist" + exit 1 + } + print $"packaging prebuilt hu from ($binary_from)" + $binary_from + } else { + # `web-plugins` is deliberate: docs/tools/hu.md documents `hu web`, and + # a default-feature build does not have that subcommand at all. + # Shipping the default build means shipping a binary that fails a + # documented command. + let target_args = (if $target == "" { [] } else { ["--target" $target] }) + print $"building hu \(--features web-plugins\) ($target)" + (^cargo build --release -p hiroz-union --bin hu --features web-plugins ...$target_args) + + let root = (target-root) + let bin_dir = (if $target == "" { $"($root)/release" } else { $"($root)/($target)/release" }) + $"($bin_dir)/hu" + } + + if not ($bin | path exists) { + print $"FAIL: expected binary at ($bin), not found" + exit 1 + } + + let triple = (if $target == "" { host-triple } else { $target }) + let stage = $"($out)/.stage-hu" + rm -rf $stage + mkdir $stage + cp $bin $"($stage)/hu" + cp LICENSE $"($stage)/LICENSE" + install-readme $ver | save --force $"($stage)/README-install.md" + + let tar = $"($out)/hu-($ver)-($triple).tar.gz" + ^tar -czf $tar -C $stage hu LICENSE README-install.md + rm -rf $stage + print $" → ($tar)" +} + +def host-triple [] { + ^rustc -vV | lines | where { |l| $l | str starts-with "host: " } | first | str replace "host: " "" +} + +def build-plugins [ver: string, out: string, validate: bool] { + print "building WASM plugins (wasm32-wasip2)" + for p in $PLUGINS { + (^cargo build --release --target wasm32-wasip2 + --manifest-path $"($PLUGIN_DIR)/($p.dir)/Cargo.toml") + } + + # The plugins are their own workspace, so their artifacts land under the + # plugins dir — unless CARGO_TARGET_DIR redirects everything to one root, + # which is what CI does. Accept either. + let wasm_dir = $"($PLUGIN_DIR)/target/wasm32-wasip2/release" + let alt_dir = $"(target-root)/wasm32-wasip2/release" + + mut entries = [] + let stage = $"($out)/.stage-plugins" + rm -rf $stage + mkdir $stage + + for p in $PLUGINS { + let src = ( + [$"($wasm_dir)/($p.stem).wasm" $"($alt_dir)/($p.stem).wasm"] + | where { |c| $c | path exists } + | first + ) + if ($src | is-empty) { + print $"FAIL: ($p.stem).wasm not found in ($wasm_dir) or ($alt_dir)" + exit 1 + } + + # A plugin that does not compile as a component must never ship. Use a + # host-native `hu` — on a cross leg the freshly built binary cannot run + # here, which is what --no-validate is for. + if $validate { + let hu = $"(target-root)/release/hu" + if ($hu | path exists) { + let r = (do { ^$hu plugin validate $src } | complete) + if $r.exit_code != 0 { + print $"FAIL: ($src) did not validate as a WASM component" + print $r.stderr + exit 1 + } + print $" validated ($p.stem)" + } else { + print $"FAIL: --no-validate not given but no host-native hu at ($hu)" + exit 1 + } + } + + let dest = $"($out)/($p.stem)-($ver).wasm" + cp $src $dest + cp $src $"($stage)/($p.stem).wasm" + print $" → ($dest)" + + $entries = ($entries | append { + name: $p.name + file: $"($p.stem)-($ver).wasm" + version: $ver + sha256: (sha256-of $dest) + world: $p.world + description: $p.description + }) + } + + let tar = $"($out)/hu-plugins-($ver).tar.gz" + ^tar -czf $tar -C $stage ...($PLUGINS | each { |p| $"($p.stem).wasm" }) + rm -rf $stage + print $" → ($tar)" + + let index = { + schema: 1 + hu_version: $ver + wit_world: $WIT_WORLD + plugins: $entries + } + $index | to json --indent 2 | save --force $"($out)/hu-plugins-($ver).json" + print $" → ($out)/hu-plugins-($ver).json" +} + +# Ship the installer itself as a release asset. +# +# It is copied, not generated, so what a user curls is byte-for-byte the script +# in the repo at the tagged commit — and `SHA256SUMS` (written afterwards over +# the whole directory) covers it like any other asset, so a caller passing +# --no-sums still gets it listed when it assembles the sums over the full set. +def stage-installer [out: string] { + if not ($INSTALLER | path exists) { + print $"FAIL: ($INSTALLER) not found — the release notes tell users to curl it" + exit 1 + } + + # A release whose headline command is a syntactically broken shell script + # is worse than one with no installer at all: the failure lands on the + # user's machine, mid-install. `sh -n` parses without executing, so this + # costs nothing and runs on every leg that ships the file. + let syn = (do { ^sh -n $INSTALLER } | complete) + if $syn.exit_code != 0 { + print $"FAIL: ($INSTALLER) is not valid POSIX shell" + print $syn.stderr + exit 1 + } + + let dest = $"($out)/install-hu.sh" + cp $INSTALLER $dest + ^chmod 755 $dest + if ((ls $dest | first | get size) == 0b) { + print $"FAIL: staged ($dest) is empty" + exit 1 + } + print $" → ($dest)" +} + +def write-sums [out: string] { + # `sha256sum -c` format. SHA256SUMS cannot cover itself. + let sums = ( + ls $out + | where type == file + | get name + | each { |f| $f | path basename } + | where { |f| $f != "SHA256SUMS" } + | sort + | each { |f| $"(sha256-of $"($out)/($f)") ($f)" } + | str join "\n" + ) + $"($sums)\n" | save --force $"($out)/SHA256SUMS" + print $" → ($out)/SHA256SUMS" +} + +def install-readme [ver: string] { + $"# hu ($ver) + +`hu` is the command-line companion to the hiroz ROS 2 stack. It needs no ROS 2 +install and no daemon — only a reachable Zenoh router. + +## Install + +Copy the binary somewhere on your PATH: + + install -Dm755 hu ~/.local/bin/hu + +## Plugins + +`hu meter` and `hu monitor` are WASM plugins, not built into this binary. They +ship as a separate `hu-plugins-($ver).tar.gz`. Install them with: + + hu plugin install ./hu_meter-($ver).wasm + hu plugin install ./hu_monitor-($ver).wasm + +or extract the plugins tarball into `~/.local/share/hu/plugins/`. Verify with: + + hu plugin list + +If that list is empty, `hu meter` and `hu monitor` will not work. + +## Verify this download + + sha256sum -c SHA256SUMS + +## Documentation + +https://zettascalelabs.github.io/hiroz/ +" +} diff --git a/scripts/ci/write-sha256sums.sh b/scripts/ci/write-sha256sums.sh new file mode 100755 index 000000000..55d6825ff --- /dev/null +++ b/scripts/ci/write-sha256sums.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Write SHA256SUMS over every file in a directory, then verify it. +# +# scripts/ci/write-sha256sums.sh dist +# +# One implementation, because there were two and they drifted: the release job +# used GNU-only `find -printf` and a bare `sha256sum`, so it would have failed +# on any host without coreutils. +# +# Two traps this exists to hold: +# - The file list is captured BEFORE the redirect. A redirect creates its +# target first, so `find ... > SHA256SUMS` lists SHA256SUMS itself with the +# hash of an empty file, and `-c` then fails an otherwise perfect set. +# - `shasum -a 256` emits and checks the same format as `sha256sum`, which +# matters because install-hu.sh reads this file with the same fallback. +set -eu + +DIR="${1:?usage: write-sha256sums.sh }" +[ -d "$DIR" ] || { echo "write-sha256sums: $DIR is not a directory" >&2; exit 1; } + +if command -v sha256sum > /dev/null 2>&1; then + SUM="sha256sum" +else + SUM="shasum -a 256" +fi +echo "write-sha256sums: using $SUM in $DIR" + +cd "$DIR" +rm -f SHA256SUMS +# `-exec basename` rather than `-printf`, which is GNU-only. +files=$(find . -maxdepth 1 -type f -exec basename {} \; | sort) +[ -n "$files" ] || { echo "write-sha256sums: $DIR holds no files" >&2; exit 1; } +printf '%s\n' "$files" | xargs $SUM > SHA256SUMS + +echo "write-sha256sums: covered $(wc -l < SHA256SUMS) files" +cat SHA256SUMS +# Must verify clean, which also proves it does not list itself. +$SUM -c SHA256SUMS diff --git a/scripts/install-hu.sh b/scripts/install-hu.sh new file mode 100755 index 000000000..ec78de6cc --- /dev/null +++ b/scripts/install-hu.sh @@ -0,0 +1,280 @@ +#!/bin/sh +# Install `hu` and its WASM plugins from a release. +# +# curl -fsSL /install-hu.sh | sh +# ./install-hu.sh --offline ./downloaded-dir +# +# A token is needed only if the release host is private. +# When one is needed it is read from the environment only, and is NEVER +# embedded in this script. If you have no account, use --offline with files +# someone handed you — that path needs no network and no credentials. +# +# Environment: +# HU_RELEASE_BASE base URL of the release assets (overrides the default) +# HU_VERSION version to install (default: the latest published) +# HU_RELEASE_TOKEN API token, only needed if the release host is private +# HU_PREFIX install prefix (default: $HOME/.local) + +set -eu + +# Release attachments are served from +# ///releases/download// +# and the tag for version X is hu-vX. HU_RELEASE_BASE overrides the whole +# directory, which is what makes it possible to point at a smoke-test tag whose +# filenames carry a different version than its tag. +DEFAULT_HOST="https://github.com" +DEFAULT_REPO_PATH="ZettaScaleLabs/hiroz" +BASE="${HU_RELEASE_BASE:-}" +PREFIX="${HU_PREFIX:-$HOME/.local}" +BIN_DIR="$PREFIX/bin" +PLUGIN_DIR="$PREFIX/share/hu/plugins" +OFFLINE_DIR="" +TARGET="" +VERSION="${HU_VERSION:-}" + +die() { printf 'install-hu: %s\n' "$*" >&2; exit 1; } +info() { printf 'install-hu: %s\n' "$*"; } + +usage() { + cat <<'EOF' +Usage: install-hu.sh [--offline DIR] [--version X.Y.Z] [--prefix DIR] + + --offline DIR install from already-downloaded artifacts in DIR + (no network, no token required) + --version version to install + --prefix install prefix (default: $HOME/.local) +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --offline) OFFLINE_DIR="${2:-}"; [ -n "$OFFLINE_DIR" ] || die "--offline needs a directory"; shift 2 ;; + --version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;; + --prefix) PREFIX="${2:-}"; [ -n "$PREFIX" ] || die "--prefix needs a value" + BIN_DIR="$PREFIX/bin"; PLUGIN_DIR="$PREFIX/share/hu/plugins"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + +# ------------------------------------------------------------ platform detect + +detect_target() { + os="$(uname -s)" + arch="$(uname -m)" + case "$os/$arch" in + Linux/x86_64) echo "x86_64-unknown-linux-gnu" ;; + Linux/aarch64|Linux/arm64) echo "aarch64-unknown-linux-gnu" ;; + Darwin/arm64) echo "aarch64-apple-darwin" ;; + Darwin/x86_64) die "macOS x86_64 is not published; build from source" ;; + *) die "unsupported platform $os/$arch" ;; + esac +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + die "need sha256sum or shasum to verify downloads; refusing to install unverified files" + fi +} + +# Verify FILE against the SHA256SUMS in DIR. A missing entry is a failure, not +# a pass — an unlisted file is exactly what a substituted file looks like. +verify() { + _file="$1"; _sums="$2" + _name="$(basename "$_file")" + # Normalize the recorded name before comparing: `sha256sum ./*` writes + # "./file", and binary mode writes "*file". Both are ordinary ways to + # produce a SHA256SUMS, and neither should read as "no entry" — which is + # a refusal, so being strict here fails closed on a valid download. + _want="$(awk -v n="$_name" ' + { f = $2; sub(/^\.\//, "", f); sub(/^\*/, "", f); if (f == n) print $1 } + ' "$_sums" | head -n1)" + [ -n "$_want" ] || die "$_name has no entry in SHA256SUMS — refusing to install" + _got="$(sha256_of "$_file")" + if [ "$_want" != "$_got" ]; then + die "checksum mismatch for $_name + expected $_want + got $_got +This download is corrupt or has been altered. Nothing was installed." + fi +} + +# ------------------------------------------------------------------- download + +# Public releases need no credential. A token is read only from the environment, +# never from a file: an installer that goes looking for credentials on disk is +# the wrong shape, and it embeds none of its own. +resolve_token() { + if [ -n "${HU_RELEASE_TOKEN:-}" ]; then + printf '%s' "$HU_RELEASE_TOKEN" + return 0 + fi + return 1 +} + +# Ask the release host which version is newest, so `curl ... | sh` works with +# no arguments. The header promised this default long before anything +# implemented it, and the installer died demanding HU_VERSION instead. +# +# /releases/latest excludes drafts and pre-releases, which is what we want: a +# bare install should never land on a rehearsal tag. +resolve_latest() { + curl -fsSL "https://api.github.com/repos/$DEFAULT_REPO_PATH/releases/latest" 2>/dev/null \ + | grep -m1 '"tag_name"' \ + | sed 's/.*"tag_name" *: *"//; s/".*//; s/^v//' +} + +fetch() { + _url="$1"; _dest="$2" + # --fail so an HTTP error is an error: without it curl writes the 404 body + # to disk and we would happily "install" an HTML page as a binary. + # No `-k`. The release host presents a real, publicly-trusted certificate, + # so verification succeeds normally — and skipping it here would undo the + # point of the checksums below, since an attacker able to intercept the + # download could serve their own SHA256SUMS alongside it. + # + # The Authorization header is sent only when a token was found. A public + # release host needs none, and demanding one up front would make this + # refuse to install from, say, a public GitHub release that anyone can + # curl. Whether a credential is required is the *host's* business; this + # only reports it if the download actually fails. + if [ -n "$TOKEN" ]; then + _ok=0 + curl -fsSL -H "Authorization: token $TOKEN" "$_url" -o "$_dest" || _ok=$? + else + _ok=0 + curl -fsSL "$_url" -o "$_dest" || _ok=$? + fi + if [ "$_ok" -ne 0 ]; then + if [ -n "$TOKEN" ]; then + die "failed to download $_url +If this is an auth failure, check that your token is valid for the release host." + fi + die "failed to download $_url +No credential was used. If the release host is private, set HU_RELEASE_TOKEN. +" + fi +} + +# --------------------------------------------------------------------- install + +TMP="" +STAGE="" +# `return 0` is load-bearing: this runs as an EXIT trap, and under `set -e` a +# falsy last command here becomes the script's exit status. Without it an +# offline install (where TMP is empty) succeeded and still exited 1. +cleanup() { + [ -n "$TMP" ] && rm -rf "$TMP" + [ -n "$STAGE" ] && rm -rf "$STAGE" + return 0 +} +trap cleanup EXIT INT TERM + +if [ -n "$OFFLINE_DIR" ]; then + [ -d "$OFFLINE_DIR" ] || die "$OFFLINE_DIR is not a directory" + SRC="$OFFLINE_DIR" + info "offline install from $SRC" +else + # A missing credential is not fatal here. Public release hosts need none, + # and refusing up front would block installing from, say, a public GitHub + # release. If the host does require one, `fetch` says so when the download + # fails — which is also the only point at which we actually know. + TOKEN="$(resolve_token || true)" + + TMP="$(mktemp -d)" + SRC="$TMP" + TARGET="$(detect_target)" + + if [ -z "$VERSION" ]; then + VERSION="$(resolve_latest || true)" + [ -n "$VERSION" ] || die "could not determine the latest version from + $DEFAULT_HOST/$DEFAULT_REPO_PATH +Pass one explicitly: --version X.Y.Z (or set HU_VERSION)." + info "latest release is $VERSION" + fi + + # A pre-release tag and its asset filenames do NOT carry the same version. + # The tag is the full `hu-v0.1.0-rc1`, but build-hu-release.nu names every + # asset for the CORE version (`hu-0.1.0-...`), because an rc ships the same + # crate as the release it rehearses. For a normal release the two strings + # are identical, which is exactly why conflating them survived until the + # first pre-release was cut and every file 404'd. + CORE="${VERSION%%-*}" + + if [ -z "$BASE" ]; then + BASE="$DEFAULT_HOST/$DEFAULT_REPO_PATH/releases/download/v$VERSION" + fi + + info "downloading hu $VERSION for $TARGET" + [ "$CORE" != "$VERSION" ] && info " pre-release: assets are named for core version $CORE" + info " from $BASE" + fetch "$BASE/SHA256SUMS" "$SRC/SHA256SUMS" + fetch "$BASE/hu-$CORE-$TARGET.tar.gz" "$SRC/hu-$CORE-$TARGET.tar.gz" + fetch "$BASE/hu-plugins-$CORE.tar.gz" "$SRC/hu-plugins-$CORE.tar.gz" +fi + +[ -f "$SRC/SHA256SUMS" ] || die "SHA256SUMS not found in $SRC — refusing to install unverified files" + +# Find the artifacts present in SRC. +# +# The offline path never reached detect_target, so this used to be a bare +# `ls | head -n1` -- lexical order. A directory holding a whole release sorts +# aarch64-apple-darwin first, so an x86_64 Linux user who downloaded every +# asset (which docs/tools/hu-install.md invites: "at least SHA256SUMS and the +# binary tarball") installed the macOS binary. It checksum-verified and exited +# 0, because that tarball really is in SHA256SUMS; the failure surfaced later +# as `Exec format error` with nothing pointing back here. +[ -n "$TARGET" ] || TARGET="$(detect_target)" +BIN_TAR="$(ls "$SRC"/hu-*-"$TARGET".tar.gz 2>/dev/null | head -n1 || true)" +if [ -z "$BIN_TAR" ]; then + # Name what was looked for and what is present: on the offline path the + # user assembled this directory themselves, so the actionable fact is + # which target is missing, not that "no tarball" was found. + found="$(ls "$SRC"/hu-*-*.tar.gz 2>/dev/null | grep -v -- '-plugins-' | sed 's|.*/| |' || true)" + # A full `if`, not `[ -n "$found" ] && die`: under `set -e` a falsy AND-list + # is the shape that made a successful offline install exit 1 once already. + if [ -n "$found" ]; then + die "no hu tarball for $TARGET in $SRC. Present: +$found" + fi +fi +PLUGIN_TAR="$(ls "$SRC"/hu-plugins-*.tar.gz 2>/dev/null | head -n1 || true)" +[ -n "$BIN_TAR" ] || die "no hu binary tarball found in $SRC" + +verify "$BIN_TAR" "$SRC/SHA256SUMS" +[ -n "$PLUGIN_TAR" ] && verify "$PLUGIN_TAR" "$SRC/SHA256SUMS" + +STAGE="$(mktemp -d)" + +tar -xzf "$BIN_TAR" -C "$STAGE" +[ -f "$STAGE/hu" ] || die "binary tarball did not contain hu" + +mkdir -p "$BIN_DIR" "$PLUGIN_DIR" +install -m 755 "$STAGE/hu" "$BIN_DIR/hu" +info "installed $BIN_DIR/hu" + +if [ -n "$PLUGIN_TAR" ]; then + tar -xzf "$PLUGIN_TAR" -C "$STAGE" + for w in "$STAGE"/*.wasm; do + [ -f "$w" ] || continue + install -m 644 "$w" "$PLUGIN_DIR/$(basename "$w")" + info "installed plugin $(basename "$w")" + done +else + info "no plugins tarball found — 'hu meter' and 'hu monitor' will not be available" +fi + +# ---------------------------------------------------------------- post-install + +case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) info "note: $BIN_DIR is not on your PATH; add it to use 'hu' directly" ;; +esac + +info "done. Verify with:" +info " $BIN_DIR/hu --version" +info " $BIN_DIR/hu plugin list" From 9110f53c04775e723cc6e2ad5723568fbdd48c37 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 03:48:11 +0800 Subject: [PATCH 2/7] fix(hu): repair two defects an adversarial review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 — the release workflow called a script this branch does not carry. `smoke-test-release-install` ran `scripts/test-hu-docs-repro.nu`, which the docs-coverage branch adds, not this one. Every `v*` tag would build, promote the release, fail that step, withdraw the release back to draft and skip crates.io. No release could complete. The workflow triggers on tags only, so no pull request ever executed those lines. The docs-reproduction step moves to the branch that owns the script, so each lands with what it needs. The job keeps its two real checks: an install from the published assets, and the documented installer URL serving this tag's installer. F2 — `hu plugin install` could never authenticate. `http_get` appended `-H` after `--`. curl reads everything after `--` as a URL, so the flag and the header became two more URL operands. Measured with curl 8.21.0: exit 3 and `Could not resolve host: -H` with the header after `--`, exit 0 with it before. Over http(s) curl also resolved a name derived from the token. Every URL and registry install failed whenever HU_RELEASE_TOKEN was set, and the error blamed the download. No test set that variable, which is why nothing caught it. `curl_args` now builds the list, and three tests pin the order. --- .github/workflows/release.yml | 47 ++---------------- crates/hiroz-union/src/plugin/install.rs | 63 ++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9afc44cf6..52053fc9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -794,50 +794,9 @@ jobs: || { echo "FAIL: published install-hu.sh differs from the tagged source"; exit 1; } echo "ok — the documented one-liner URL serves this tag's installer" - - name: Install nushell - uses: hustcer/setup-nu@v3 - with: - version: "0.113.1" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - # From the source checkout, not the artifact: `hu` cannot generate its own - # traffic, because no release ships message definitions (#309, G2). With - # only `hu router` the suite measures an empty graph and decays into an - # exit-status check -- which a truncated plugin passes. - - name: Build the traffic fixture from source - run: cargo build --release --example z_pubsub -p hiroz - - - name: Reproduce the documented commands against the published release - run: | - set -eu - unset HU_PLUGIN_PATH || true - HOME="$HUHOME" "$HUHOME/.local/bin/hu" router > router.log 2>&1 & - ROUTER_PID=$! - sleep 5 - # A router that died on startup shows up as a dozen unrelated - # measurement failures, so assert on it directly. - kill -0 "$ROUTER_PID" 2>/dev/null || { - echo "FAIL: router died on startup"; tail -20 router.log; exit 1; } - # Exit status must not pass through a pipe, and the log must be - # printed whichever way this goes. - set +e - nu scripts/test-hu-docs-repro.nu \ - --home "$HUHOME" \ - --publisher "$PWD/target/release/examples/z_pubsub" \ - --require-traffic > repro.log 2>&1 - rc=$? - set -e - cat repro.log - kill "$ROUTER_PID" 2>/dev/null || true - test "$rc" -eq 0 || { - echo "FAIL: the published release does not reproduce its own docs"; exit 1; } - - # If the published release cannot install itself, or cannot reproduce its own - # documentation, put it back in the drawer. A draft is invisible to everyone - # without push access and keeps its assets, so the run can be diagnosed from - # exactly what shipped. The tag survives -- withdrawing a release does not + # If the published release cannot install itself, put it back in the drawer. + # A draft is invisible to everyone without push access and keeps its assets, + # so the run can be diagnosed from exactly what shipped. The tag survives -- withdrawing a release does not # delete it -- so the fix is a new tag rather than a rewritten one. # # This is the half of "draft first" that is actually reachable, given that diff --git a/crates/hiroz-union/src/plugin/install.rs b/crates/hiroz-union/src/plugin/install.rs index 6f5c227f1..a33a40dfe 100644 --- a/crates/hiroz-union/src/plugin/install.rs +++ b/crates/hiroz-union/src/plugin/install.rs @@ -122,6 +122,25 @@ fn is_url(s: &str) -> bool { s.starts_with("http://") || s.starts_with("https://") } +/// Build curl's argument list. +/// +/// Split out from `http_get` so a test can assert the ORDER without a network +/// or an environment variable. Every option must precede `--`: curl reads +/// everything after `--` as a URL, so a header appended afterwards becomes two +/// extra URL operands. curl then fails with `Could not resolve host: -H` and, +/// over http(s), performs a DNS lookup for a name derived from the token. +/// Measured with curl 8.21.0: `-H` after `--` exits 3, before `--` exits 0. +fn curl_args(url: &str, token: Option<&str>) -> Vec { + let mut args = vec!["-fsSL".to_string()]; + if let Some(token) = token { + args.push("-H".to_string()); + args.push(format!("Authorization: token {token}")); + } + args.push("--".to_string()); + args.push(url.to_string()); + args +} + /// Download over `curl`. `hu` deliberately carries no HTTP client — pulling in /// a TLS stack for an occasional convenience command is a poor trade, and /// `curl` is present anywhere a user could have downloaded `hu` in the first @@ -130,10 +149,10 @@ fn http_get(url: &str) -> Result> { let mut cmd = std::process::Command::new("curl"); // `--fail` matters: without it an HTTP error page is written to stdout and // we would cheerfully install a 404 as a plugin. - cmd.args(["-fsSL", "--", url]); - if let Ok(token) = std::env::var("HU_RELEASE_TOKEN") { - cmd.arg("-H").arg(format!("Authorization: token {token}")); - } + cmd.args(curl_args( + url, + std::env::var("HU_RELEASE_TOKEN").ok().as_deref(), + )); let out = cmd .output() .with_context(|| "running curl (is it installed?)")?; @@ -419,3 +438,39 @@ mod tests { assert!(!is_url("/home/u/hu_meter.wasm")); } } + +#[cfg(test)] +mod curl_arg_tests { + use super::curl_args; + + // A header placed after `--` becomes a URL operand, so every authenticated + // download fails and curl resolves a host derived from the token. Nothing + // else catches this: no other test sets `HU_RELEASE_TOKEN`, and without + // one the argument list is correct either way. + #[test] + fn the_auth_header_precedes_the_url_terminator() { + let args = curl_args("https://example.invalid/p.wasm", Some("SECRET")); + let dashdash = args.iter().position(|a| a == "--").expect("no `--`"); + let header = args.iter().position(|a| a == "-H").expect("no `-H`"); + assert!(header < dashdash, "-H must precede `--`, got {args:?}"); + } + + #[test] + fn the_url_is_the_only_operand_after_the_terminator() { + for token in [None, Some("SECRET")] { + let args = curl_args("https://example.invalid/p.wasm", token); + let after: Vec<_> = args.iter().skip_while(|a| *a != "--").skip(1).collect(); + assert_eq!( + after, + vec!["https://example.invalid/p.wasm"], + "token={token:?} left extra operands: {args:?}" + ); + } + } + + #[test] + fn no_token_means_no_header() { + let args = curl_args("https://example.invalid/p.wasm", None); + assert!(!args.iter().any(|a| a == "-H"), "{args:?}"); + } +} From c15e76883aef435346628e147ffc0d2fea5bd67a Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 10:32:42 +0800 Subject: [PATCH 3/7] fix(hu): address the review findings on the install path Metadata was recorded in the wrong fields. `accept` took the path or URL as `source` and the literal "local"/"url" as `version`, so `hu plugin list` printed `VERSION local` and `source_label` classified the entry by sniffing a path. `source` now holds the kind, `version` is an `Option` that only the registry index fills, and `origin` holds where it came from with the userinfo and query string removed -- a signed asset URL carries a token there, and `--json` prints it verbatim. `hu plugin list` matched metadata by name alone. Discovery returns the same name from `$HU_PLUGIN_PATH`, the executable-relative prefix and the managed directory, so a development build was labelled with the installed plugin's version. It now matches the managed file path too. Other findings, each independent: - An empty `HU_RELEASE_TOKEN` was treated as a credential and sent as `Authorization: token `, turning an anonymous public download into a 401. The shell installer already treated empty as absent. - The registry index declared a schema version that nothing checked, so `schema: 2` was read as schema 1 whenever the JSON still deserialised. - `uninstall` discarded the result of persisting the install record. The file was already gone, so a failed write left an entry that the next install of that name would inherit, and the command reported success. - The "installed elsewhere" error named `$HU_PLUGIN_PATH` even when the plugin sat in the executable-relative prefix and that variable was unset. It now names the directory that actually holds the file. - `resolve_latest` sent no credential, so a private host answered 401 before any authorised download was attempted. It also had no timeout. - `HU_RELEASE_BASE` without a version asked this project's API for its newest release and named files after it. The installer now refuses that combination instead of 404ing against a correct directory. - One handler served EXIT, INT and TERM. Returning 0 from a signal trap consumes the signal, so a Ctrl-C between two commands deleted the staging directories and let the install continue. The signal traps now exit. - `withdraw-release` compared the verification result to 'failure'. A cancelled or skipped job reports neither, so an unverified release stayed public with the Latest badge. Any non-success now withdraws it. - RELEASING.md promised an enforcement script that does not exist, and documented a `hu-v*` tag namespace that no workflow accepts. The installer's comments and its pre-release example said `hu-v` too. Not changed: the `find -maxdepth` report. That is a BSD extension macOS provides, and the script already avoids the GNU-only spellings. --- .github/workflows/release.yml | 10 ++- RELEASING.md | 4 +- crates/hiroz-union/src/main.rs | 37 ++++++--- crates/hiroz-union/src/plugin/install.rs | 98 +++++++++++++++++++----- docs/tools/hu-install.md | 2 +- scripts/install-hu.sh | 30 +++++++- 6 files changed, 142 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52053fc9d..c3eec741f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -804,14 +804,20 @@ jobs: withdraw-release: name: Withdraw the release if verification failed needs: [publish-release, smoke-test-release-install] - # NOT `failure()`: that is true when ANY ancestor fails, and this job's + # `!= 'success'`, NOT `== 'failure'`: GitHub reports a cancelled job as + # `cancelled` and a skipped one as `skipped`. Promotion has already + # happened by then, so equality on 'failure' leaves an unverified release + # public and carrying the *Latest* badge -- which is the answer + # install-hu.sh's resolve_latest gives. + # + # NOT `failure()` either: that is true when ANY ancestor fails, and this job's # ancestors reach back to build-binaries. A failed build skips release and # publish-release, then this job would still run and try to withdraw a # release that was never created -- going red with a caption implying a bad # release is live. Withdraw only what publish-release actually published. if: ${{ always() && needs.publish-release.result == 'success' - && needs.smoke-test-release-install.result == 'failure' }} + && needs.smoke-test-release-install.result != 'success' }} runs-on: ubuntu-latest steps: - name: Return the release to draft diff --git a/RELEASING.md b/RELEASING.md index 6e415953d..99ba088f1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,9 +14,9 @@ Before releasing, bump the version in all three places consistently: The `hiroz-py` wheel depends on `hiroz-msgs-py>=` — update that lower bound too when bumping. -**One version governs every Rust crate, and a check enforces it.** `hiroz`, `hiroz-protocol` and `hiroz-union` each used to carry a literal `version`, which meant `cargo publish --workspace` could leave a published crate behind at the old number while the tag said otherwise, and a `v0.2.0` tag could produce `hu` assets named `0.1.0`. They now inherit, and `scripts/test-release-version-semantics.sh` fails if any crate under `crates/` reintroduces a literal. +**One version governs every Rust crate, and a check enforces it.** `hiroz`, `hiroz-protocol` and `hiroz-union` each used to carry a literal `version`, which meant `cargo publish --workspace` could leave a published crate behind at the old number while the tag said otherwise, and a `v0.2.0` tag could produce `hu` assets named `0.1.0`. They now inherit. Nothing yet enforces that automatically, so a reintroduced literal is caught by review, not by CI. -`hu` keeps an independent release *cadence* through its own `hu-v*` tags — you can cut a `hu` release between workspace releases — but not an independent *number*. +`hu` ships from the workspace's own `v*` tags. It has neither an independent number nor, at present, an independent tag namespace. > **Do not bump the WIT world alongside the product version.** `hu:plugin@0.1.0` is the plugin **ABI contract**, not a product version, and the two move on different clocks. It lives in three places that must agree — `HOST_WIT_WORLD` in `crates/hiroz-union/src/plugin/install.rs`, the `WIT_WORLD` constant in `scripts/build-hu-release.nu`, and the `package` line of `crates/hiroz-union/wit/v0.1/hu-plugin.wit` — and `install.rs` compares it to a release index by **exact string equality**. Bump the string and `hu plugin install ` refuses every index still declaring the old world, with a message telling the user to upgrade `hu` — for a change that never happened. Rename the package in `hu-plugin.wit` as well and the breakage is real rather than cosmetic: plugins built against the old package no longer instantiate. Change it only when the interface in `hu-plugin.wit` changes incompatibly, and then change all three sites in the same commit. diff --git a/crates/hiroz-union/src/main.rs b/crates/hiroz-union/src/main.rs index 068e07c57..0e09e7f15 100644 --- a/crates/hiroz-union/src/main.rs +++ b/crates/hiroz-union/src/main.rs @@ -345,18 +345,31 @@ fn run_plugin_list(json: bool) -> Result<(), Box = plugins .iter() .map(|(name, path)| { - let m = meta(name); + let m = meta(name, path); serde_json::json!({ "name": name, "path": path.to_string_lossy(), "kind": "wasm", - "version": m.map(|m| m.version.clone()), + "version": m.and_then(|m| m.version.clone()), + "origin": m.and_then(|m| m.origin.clone()), "source": m.map(|m| m.source.clone()).unwrap_or_else(|| "unmanaged".into()), }) }) @@ -371,11 +384,11 @@ fn run_plugin_list(json: bool) -> Result<(), Box Result<(), Box &'static str { - if source.starts_with("http://") || source.starts_with("https://") { - "download" - } else if source == "local" { - "local" - } else { - "installed" +/// Render the recorded source kind. `InstalledEntry::source` holds one of +/// `local`, `url` or `registry`; anything else comes from a record written by +/// an older hu, and is shown verbatim rather than guessed at. +fn source_label(source: &str) -> &str { + match source { + "url" => "download", + other => other, } } diff --git a/crates/hiroz-union/src/plugin/install.rs b/crates/hiroz-union/src/plugin/install.rs index a33a40dfe..62a3be412 100644 --- a/crates/hiroz-union/src/plugin/install.rs +++ b/crates/hiroz-union/src/plugin/install.rs @@ -29,11 +29,13 @@ use super::wasm::{plugin_search_dirs, sanitize_plugin_stem, validate_plugin_stat /// message instead of letting wasmtime fail later with a link error. pub const HOST_WIT_WORLD: &str = "hu:plugin@0.1.0"; +/// The `schema` value in `hu-plugins-.json` that this hu can read. +const SUPPORTED_INDEX_SCHEMA: u32 = 1; + const DEFAULT_REGISTRY_ENV: &str = "HU_PLUGIN_REGISTRY"; #[derive(Debug, Deserialize)] struct RegistryIndex { - #[allow(dead_code)] schema: u32, wit_world: String, plugins: Vec, @@ -59,8 +61,36 @@ pub struct InstalledDb { pub struct InstalledEntry { pub name: String, pub file: String, - pub version: String, + /// `None` when the source does not state one. A local file and a bare URL + /// carry no version; only the registry index does. Recording the *kind* of + /// source here (the previous behaviour) made `hu plugin list` print + /// `VERSION local`. + #[serde(default)] + pub version: Option, + /// How it was installed: `local`, `url` or `registry`. pub source: String, + /// Where it came from, with any credential removed. A signed asset URL can + /// carry a token in its query string or userinfo, and `hu plugin list + /// --json` prints this verbatim. + #[serde(default)] + pub origin: Option, +} + +/// Strip anything credential-bearing from a URL before it is persisted: the +/// userinfo (`https://user:token@host/...`) and the query string, which is +/// where a signed-URL token lives. A non-URL is returned unchanged. +fn sanitize_origin(source: &str) -> String { + if !is_url(source) { + return source.to_string(); + } + let no_query = source.split(['?', '#']).next().unwrap_or(source); + match no_query.split_once("://") { + Some((scheme, rest)) => match rest.split_once('@') { + Some((_userinfo, host_and_path)) => format!("{scheme}://{host_and_path}"), + None => no_query.to_string(), + }, + None => no_query.to_string(), + } } /// The directory installs write to: always the last search dir, which is the @@ -149,10 +179,13 @@ fn http_get(url: &str) -> Result> { let mut cmd = std::process::Command::new("curl"); // `--fail` matters: without it an HTTP error page is written to stdout and // we would cheerfully install a 404 as a plugin. - cmd.args(curl_args( - url, - std::env::var("HU_RELEASE_TOKEN").ok().as_deref(), - )); + // An empty value is not a credential. Sending `Authorization: token ` + // turns an anonymous public download into a 401, and `install-hu.sh` + // already treats an empty variable as absent. + let token = std::env::var("HU_RELEASE_TOKEN") + .ok() + .filter(|t| !t.trim().is_empty()); + cmd.args(curl_args(url, token.as_deref())); let out = cmd .output() .with_context(|| "running curl (is it installed?)")?; @@ -171,8 +204,9 @@ fn accept( bytes: &[u8], file_name: &str, expected_sha: Option<&str>, - source: &str, - version: &str, + source_kind: &str, + origin: &str, + version: Option<&str>, ) -> Result { if let Some(want) = expected_sha { let got = sha256_hex(bytes); @@ -228,8 +262,9 @@ fn accept( db.plugins.push(InstalledEntry { name: display_name, file: format!("{safe}.wasm"), - version: version.to_string(), - source: source.to_string(), + version: version.map(str::to_string), + source: source_kind.to_string(), + origin: Some(sanitize_origin(origin)), }); save_db(&db)?; @@ -251,7 +286,7 @@ pub fn install(source: &str, registry: Option<&str>) -> Result { let expected = std::fs::read_to_string(&sidecar) .ok() .and_then(|s| s.split_whitespace().next().map(str::to_string)); - return accept(&bytes, name, expected.as_deref(), source, "local"); + return accept(&bytes, name, expected.as_deref(), "local", source, None); } if is_url(source) { @@ -261,7 +296,7 @@ pub fn install(source: &str, registry: Option<&str>) -> Result { .ok() .and_then(|b| String::from_utf8(b).ok()) .and_then(|s| s.split_whitespace().next().map(str::to_string)); - return accept(&bytes, name, expected.as_deref(), source, "url"); + return accept(&bytes, name, expected.as_deref(), "url", source, None); } install_from_registry(source, registry) @@ -298,6 +333,18 @@ fn install_from_registry(name: &str, registry: Option<&str>) -> Result let index: RegistryIndex = serde_json::from_slice(&raw) .with_context(|| format!("parsing plugin index at {index_url}"))?; + // Check the schema before reading any entry. serde accepts `schema: 2` as + // long as the rest of the JSON still deserialises, so an index written to a + // later shape would otherwise be silently read as if it were this one. + if index.schema != SUPPORTED_INDEX_SCHEMA { + bail!( + "plugin index at {index_url} declares schema {} but this hu implements {}.\n\ + Install a release matching this hu, or upgrade hu.", + index.schema, + SUPPORTED_INDEX_SCHEMA + ); + } + if index.wit_world != HOST_WIT_WORLD { bail!( "plugin index targets WIT world {} but this hu hosts {}.\n\ @@ -335,8 +382,9 @@ fn install_from_registry(name: &str, registry: Option<&str>) -> Result &bytes, &entry.file, Some(&entry.sha256), + "registry", &asset_url, - &entry.version, + Some(&entry.version), ) } @@ -354,15 +402,20 @@ pub fn uninstall(name: &str) -> Result { let elsewhere = plugin_search_dirs() .into_iter() .filter(|d| *d != dir) - .any(|d| { + .find(|d| { ["hu_", "hu-", ""] .iter() .any(|p| d.join(format!("{p}{name}.wasm")).exists()) }); - if elsewhere { + if let Some(other) = elsewhere { + // Name the directory that actually holds it. `plugin_search_dirs` + // also returns an executable-relative prefix dir, so this fires + // with `$HU_PLUGIN_PATH` unset -- an earlier message blamed that + // variable and gave advice the user could not act on. anyhow!( - "'{name}' is loaded from a directory on $HU_PLUGIN_PATH, not from {}. \ - Remove it there, or unset $HU_PLUGIN_PATH.", + "'{name}' is loaded from {}, not from the managed directory {}. \ + Remove it there.", + other.display(), dir.display() ) } else { @@ -372,9 +425,18 @@ pub fn uninstall(name: &str) -> Result { std::fs::remove_file(found).with_context(|| format!("removing {}", found.display()))?; + // The file is gone by now, so a discarded write leaves an entry claiming a + // plugin that is not there -- which the next install of the same name would + // inherit. Report it instead, and say what state the caller is in. let mut db = load_db(); db.plugins.retain(|p| p.name != name); - let _ = save_db(&db); + save_db(&db).with_context(|| { + format!( + "removed {} but could not update the install record; \ + `hu plugin list` may still show '{name}'", + found.display() + ) + })?; Ok(found.clone()) } diff --git a/docs/tools/hu-install.md b/docs/tools/hu-install.md index 47311dbb0..f8865e671 100644 --- a/docs/tools/hu-install.md +++ b/docs/tools/hu-install.md @@ -21,7 +21,7 @@ sh install-hu.sh That downloads the binary and the plugins, verifies both against `SHA256SUMS`, installs `hu` to `~/.local/bin/` and the plugins to `~/.local/share/hu/plugins/`. -To install a specific version rather than the newest, pass `--version X.Y.Z`. To install from somewhere other than this project's GitHub releases, set `HU_RELEASE_BASE` to that release's download directory — then the installer looks nowhere else, which matters if you fetched the script from one place and its assets live in another. +To install a specific version rather than the newest, pass `--version X.Y.Z`. To install from somewhere other than this project's GitHub releases, set `HU_RELEASE_BASE` to that release's download directory. **Pass `--version` with it.** The newest-release lookup speaks only for this project's own releases. The installer therefore refuses a redirected base without a version, rather than naming files after an unrelated release. **`HU_RELEASE_BASE` is a release's download directory**, ending `/releases/download/v`. Point it at the release you were given; the filenames below it are the same either way. diff --git a/scripts/install-hu.sh b/scripts/install-hu.sh index ec78de6cc..900470ac3 100755 --- a/scripts/install-hu.sh +++ b/scripts/install-hu.sh @@ -19,7 +19,7 @@ set -eu # Release attachments are served from # ///releases/download// -# and the tag for version X is hu-vX. HU_RELEASE_BASE overrides the whole +# and the tag for version X is vX. HU_RELEASE_BASE overrides the whole # directory, which is what makes it possible to point at a smoke-test tag whose # filenames carry a different version than its tag. DEFAULT_HOST="https://github.com" @@ -123,7 +123,16 @@ resolve_token() { # /releases/latest excludes drafts and pre-releases, which is what we want: a # bare install should never land on a rehearsal tag. resolve_latest() { - curl -fsSL "https://api.github.com/repos/$DEFAULT_REPO_PATH/releases/latest" 2>/dev/null \ + # Send the same credential as every other request. A private repository + # answers /releases/latest with 401, so without this a no-argument install + # fails here rather than at the asset it is authorised to fetch. + if [ -n "${TOKEN:-}" ]; then + set -- -H "Authorization: token $TOKEN" + else + set -- + fi + curl -fsSL --connect-timeout 10 --max-time 60 "$@" \ + "https://api.github.com/repos/$DEFAULT_REPO_PATH/releases/latest" 2>/dev/null \ | grep -m1 '"tag_name"' \ | sed 's/.*"tag_name" *: *"//; s/".*//; s/^v//' } @@ -172,7 +181,14 @@ cleanup() { [ -n "$STAGE" ] && rm -rf "$STAGE" return 0 } -trap cleanup EXIT INT TERM +# EXIT only. A trap that returns 0 CONSUMES the signal, so sharing this handler +# with INT/TERM let a Ctrl-C between two commands delete the staging directories +# and leave the installer running against them. The signal traps therefore clean +# up and then exit, which re-runs cleanup through EXIT -- harmless, both removals +# are idempotent. +trap cleanup EXIT +trap 'cleanup; exit 130' INT +trap 'cleanup; exit 143' TERM if [ -n "$OFFLINE_DIR" ]; then [ -d "$OFFLINE_DIR" ] || die "$OFFLINE_DIR is not a directory" @@ -190,6 +206,12 @@ else TARGET="$(detect_target)" if [ -z "$VERSION" ]; then + # A caller who redirected the base has not told us WHICH release lives + # there, and this project's API cannot answer for someone else's host. + # Asking it anyway builds filenames from an unrelated version number + # and 404s against a directory that was perfectly correct. + [ -z "$BASE" ] || die "HU_RELEASE_BASE needs an explicit --version (or HU_VERSION): + the newest-release lookup only speaks for this project's own releases." VERSION="$(resolve_latest || true)" [ -n "$VERSION" ] || die "could not determine the latest version from $DEFAULT_HOST/$DEFAULT_REPO_PATH @@ -198,7 +220,7 @@ Pass one explicitly: --version X.Y.Z (or set HU_VERSION)." fi # A pre-release tag and its asset filenames do NOT carry the same version. - # The tag is the full `hu-v0.1.0-rc1`, but build-hu-release.nu names every + # The tag is the full `v0.1.0-rc1`, but build-hu-release.nu names every # asset for the CORE version (`hu-0.1.0-...`), because an rc ships the same # crate as the release it rehearses. For a normal release the two strings # are identical, which is exactly why conflating them survived until the From 086e00e109b89ee3a2ef80cf6691b4541373e0b4 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 10:38:21 +0800 Subject: [PATCH 4/7] test(hu): cover the install success paths and stop reaching the network Every test in this file was a refusal. No test had ever completed an install and looked at the result, which is why the metadata defects in the previous commit were found by review instead. The obstacle was real but did not apply. `hiroz-union` cannot build a plugin, because the plugins are a separate, excluded, wasm32-wasip2 workspace. `validate_plugin_static` only compiles the file as a component, so an 8-byte empty component -- the magic, the component version and the layer -- drives install, list and uninstall end to end. It exports nothing and can never be dispatched, and no test claims it can. Adds five: a local install lands the file and records provenance, a URL install verifies its sidecar, a signed URL's query string never reaches installed.json, a registry install records the version the index states, and uninstall removes the file and the record together. `registry_install_without_a_registry_says_how_to_configure_one` removed the registry override, so it fell back to the real GitHub index and made an external request. It also would have started failing, rather than erroring, once a release exists for this CARGO_PKG_VERSION. It now points at a loopback port that serves 404. Also corrects the module doc. It said the success paths lived at the end of scripts/ci/hu-tests.sh. That script contains no `hu plugin install` line, and grepping for one is how the claim was caught. --- crates/hiroz-union/tests/plugin_install.rs | 263 +++++++++++++++++++-- 1 file changed, 244 insertions(+), 19 deletions(-) diff --git a/crates/hiroz-union/tests/plugin_install.rs b/crates/hiroz-union/tests/plugin_install.rs index 37278a801..011d5ba7e 100644 --- a/crates/hiroz-union/tests/plugin_install.rs +++ b/crates/hiroz-union/tests/plugin_install.rs @@ -10,19 +10,19 @@ //! executed — including the WIT world-mismatch check, which is the kind of //! guard that looks correct forever and is never proven to fire. //! -//! The success paths are **not** here, because they need a genuine WASM -//! component and this crate cannot build one: the plugins are a separate, -//! `exclude`d, `wasm32-wasip2` workspace. They live at the end of -//! `scripts/ci/hu-tests.sh`, which has already built real plugins by that -//! point — install by URL with a `.sha256` sidecar, install by registry name -//! through a served index, dispatch, and uninstall. +//! The success paths are here too, and they were not before. The obstacle was +//! real — `hiroz-union` cannot build a plugin, because the plugins are a +//! separate, `exclude`d, `wasm32-wasip2` workspace — but it did not need a +//! plugin. `validate_plugin_static` compiles the file as a component and does +//! not instantiate it, so the 8-byte empty component below is enough to drive +//! install, list and uninstall end to end. It cannot dispatch, and no test here +//! claims it can. //! -//! An earlier version of this comment claimed that script and the -//! docs-reproduction suite already covered them. Neither did: the script -//! touched only `plugin validate` and `plugin list`, and every -//! `hu plugin install` line in the docs is `skip`. The claim is why nobody -//! noticed for so long — a comment asserting coverage is as good at hiding a -//! gap as a doc asserting behaviour. +//! An earlier version of this comment said the success paths lived at the end +//! of `scripts/ci/hu-tests.sh`. They did not: that script contains no +//! `hu plugin install` line at all, and grepping for one is how the claim was +//! caught. A comment asserting coverage hides a gap exactly as well as a doc +//! asserting behaviour, and this file has now made that mistake twice. //! //! The server is a few lines of `std::net` on a loopback ephemeral port: no //! new dependency, and no network access, so these stay runnable offline. @@ -105,13 +105,61 @@ impl Outcome { } } -/// Run `hu plugin install ` with an isolated HOME. -fn install(args: &[&str]) -> Outcome { - let home = std::env::temp_dir().join(format!( +/// A valid, empty WASM component: the 4-byte magic, then the component-model +/// version (0x0d) and layer (0x01). `Component::from_file` compiles it, which +/// is all `validate_plugin_static` asks of a plugin, so it exercises every step +/// of installation without needing the `wasm32-wasip2` toolchain. It exports +/// nothing, so it can never be dispatched — that is a separate concern and no +/// test here pretends otherwise. +const EMPTY_COMPONENT: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00]; + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(bytes); + h.finalize().iter().map(|b| format!("{b:02x}")).collect() +} + +/// This test's private HOME. Stable within a test, distinct between tests, so a +/// test can install and then list or uninstall against the same state. +fn test_home() -> PathBuf { + std::env::temp_dir().join(format!( "hu-install-test-{}-{:?}", std::process::id(), std::thread::current().id() - )); + )) +} + +fn managed_dir(home: &std::path::Path) -> PathBuf { + home.join(".local/share/hu/plugins") +} + +/// Run `hu plugin ` against an existing HOME, without wiping it. +fn hu(home: &std::path::Path, args: &[&str]) -> Outcome { + let out = Command::new(env!("CARGO_BIN_EXE_hu")) + .arg("plugin") + .args(args) + .env("HOME", home) + .env_remove("HU_PLUGIN_PATH") + .env_remove("HU_PLUGIN_REGISTRY") + .env_remove("HU_RELEASE_TOKEN") + .output() + .expect("run hu"); + let output = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + Outcome { + ok: out.status.success(), + output, + home: home.to_path_buf(), + } +} + +/// Run `hu plugin install ` with a fresh, isolated HOME. +fn install(args: &[&str]) -> Outcome { + let home = test_home(); let _ = std::fs::remove_dir_all(&home); std::fs::create_dir_all(&home).unwrap(); @@ -123,6 +171,7 @@ fn install(args: &[&str]) -> Outcome { // Discovery must not reach a build tree during these tests. .env_remove("HU_PLUGIN_PATH") .env_remove("HU_PLUGIN_REGISTRY") + .env_remove("HU_RELEASE_TOKEN") .output() .expect("run hu"); @@ -253,9 +302,16 @@ fn registry_install_refuses_an_unknown_name_and_lists_what_exists() { #[test] fn registry_install_without_a_registry_says_how_to_configure_one() { - // Not a URL and not an existing file, with no registry configured: the - // message has to name the way out, or the user is stuck. - let r = install(&["meter"]); + // Not a URL and not an existing file: the message has to name the way out, + // or the user is stuck. + // + // `--registry` points at a loopback port that serves 404 rather than being + // omitted. Omitting it falls back to the real GitHub index, so this test + // reached the network -- and would have started FAILING, not erroring, the + // day a release exists for this CARGO_PKG_VERSION and the install succeeds. + let base = serve(HashMap::new()); + let registry = format!("{base}/hu-plugins.json"); + let r = install(&["meter", "--registry", ®istry]); assert!(!r.ok); assert!( r.output.contains("HU_PLUGIN_REGISTRY") && r.output.contains("--registry"), @@ -337,3 +393,172 @@ fn uninstall_explains_when_the_plugin_lives_on_hu_plugin_path() { let _ = std::fs::remove_dir_all(&dir); } + +// --------------------------------------------------------------- success +// +// Every test above is a refusal. These are the paths a user actually takes, +// and until now not one of them had ever run: the metadata defects this file +// was extended to cover were found by review, not by a test, because no test +// ever completed an install and looked at the result. + +#[test] +fn local_install_places_the_file_and_records_its_provenance() { + let home = test_home(); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + + let src = home.join("hu_meter.wasm"); + std::fs::write(&src, EMPTY_COMPONENT).unwrap(); + + let r = hu(&home, &["install", src.to_str().unwrap()]); + assert!(r.ok, "local install should succeed, got:\n{}", r.output); + assert!( + managed_dir(&home).join("hu_meter.wasm").exists(), + "the plugin is not in the managed directory:\n{}", + r.output + ); + + let list = hu(&home, &["list"]); + assert!(list.ok, "{}", list.output); + assert!(list.output.contains("meter"), "{}", list.output); + // A local file states no version, so the listing must not invent one. It + // used to print `local` here -- the source KIND in the version column. + assert!( + list.output.contains("local"), + "the source column should say local:\n{}", + list.output + ); + assert!( + !list.output.contains("VERSION local") && !list.output.contains(" local local"), + "the version column must not carry the source kind:\n{}", + list.output + ); +} + +#[test] +fn url_install_verifies_the_sidecar_and_installs() { + let sha = sha256_hex(EMPTY_COMPONENT); + let mut routes = HashMap::new(); + routes.insert( + "/hu_meter.wasm".to_string(), + (200u16, EMPTY_COMPONENT.to_vec()), + ); + routes.insert( + "/hu_meter.wasm.sha256".to_string(), + (200u16, format!("{sha} hu_meter.wasm\n").into_bytes()), + ); + let base = serve(routes); + + let r = install(&[&format!("{base}/hu_meter.wasm")]); + assert!(r.ok, "url install should succeed, got:\n{}", r.output); + assert!( + managed_dir(&r.home).join("hu_meter.wasm").exists(), + "{}", + r.output + ); + + let list = hu(&r.home, &["list", "--json"]); + assert!(list.ok, "{}", list.output); + // The recorded origin must be the URL, and the source kind must be `url` + // (rendered as `download`), not a path sniffed at display time. + assert!( + list.output.contains("\"source\": \"url\""), + "source kind should be recorded as url:\n{}", + list.output + ); + assert!( + list.output.contains("\"version\": null"), + "a bare URL states no version:\n{}", + list.output + ); +} + +#[test] +fn url_install_does_not_persist_a_credential_from_the_source_url() { + let sha = sha256_hex(EMPTY_COMPONENT); + let mut routes = HashMap::new(); + routes.insert( + "/hu_meter.wasm".to_string(), + (200u16, EMPTY_COMPONENT.to_vec()), + ); + routes.insert( + "/hu_meter.wasm.sha256".to_string(), + (200u16, format!("{sha} hu_meter.wasm\n").into_bytes()), + ); + let base = serve(routes); + + // A signed asset URL carries its token in the query string, and + // `hu plugin list --json` prints the recorded origin verbatim. + let r = install(&[&format!("{base}/hu_meter.wasm?token=SUPERSECRET")]); + assert!(r.ok, "install should succeed, got:\n{}", r.output); + + let list = hu(&r.home, &["list", "--json"]); + assert!( + !list.output.contains("SUPERSECRET"), + "the query string must not reach installed.json:\n{}", + list.output + ); + let db = + std::fs::read_to_string(managed_dir(&r.home).join("installed.json")).unwrap_or_default(); + assert!(!db.is_empty(), "no install record was written"); + assert!( + !db.contains("SUPERSECRET"), + "the query string must not be persisted:\n{db}" + ); +} + +#[test] +fn registry_install_records_the_version_the_index_states() { + let sha = sha256_hex(EMPTY_COMPONENT); + let mut routes = HashMap::new(); + routes.insert( + "/index.json".to_string(), + (200u16, index_json("hu:plugin@0.1.0", "hu_meter.wasm", &sha)), + ); + routes.insert( + "/hu_meter.wasm".to_string(), + (200u16, EMPTY_COMPONENT.to_vec()), + ); + let base = serve(routes); + + let r = install(&["meter", "--registry", &format!("{base}/index.json")]); + assert!(r.ok, "registry install should succeed, got:\n{}", r.output); + + let list = hu(&r.home, &["list", "--json"]); + assert!( + list.output.contains("\"version\": \"0.1.0\""), + "the index states 0.1.0 and the record should carry it:\n{}", + list.output + ); + assert!( + list.output.contains("\"source\": \"registry\""), + "source kind should be registry:\n{}", + list.output + ); +} + +#[test] +fn uninstall_removes_both_the_file_and_the_record() { + let home = test_home(); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + let src = home.join("hu_meter.wasm"); + std::fs::write(&src, EMPTY_COMPONENT).unwrap(); + + assert!(hu(&home, &["install", src.to_str().unwrap()]).ok); + let installed = managed_dir(&home).join("hu_meter.wasm"); + assert!(installed.exists()); + + let r = hu(&home, &["uninstall", "meter"]); + assert!(r.ok, "uninstall should succeed, got:\n{}", r.output); + assert!(!installed.exists(), "the file survived:\n{}", r.output); + + // The record must go with it. A surviving entry would later be attached to + // whatever is installed next under the same name. + let list = hu(&home, &["list", "--json"]); + assert!( + !list.output.contains("hu_meter.wasm"), + "the install record survived the uninstall:\n{}", + list.output + ); +} From 3fe0eb6c591a472980b7fa1020a37d88f5920679 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 10:44:01 +0800 Subject: [PATCH 5/7] docs(hu): correct two false claims and apply STE to the new comments The tarball's README told the reader to run `sha256sum -c SHA256SUMS`. That file covers every asset in the release, and a tarball holds only some of them, so the command reports the absent ones as failures. It now gives `--ignore-missing` and a single-file form for older coreutils. Applies the ASD-STE100 sentence rules to the comments this branch adds. Measured over the added comment lines: passive constructions 10 to 4, sentences joining two independent clauses 1 to 0, longest 15 words. --- crates/hiroz-union/src/plugin/install.rs | 21 +++++++++++---------- crates/hiroz-union/tests/plugin_install.rs | 22 +++++++++++----------- scripts/build-hu-release.nu | 10 +++++++++- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/crates/hiroz-union/src/plugin/install.rs b/crates/hiroz-union/src/plugin/install.rs index 62a3be412..0cd024658 100644 --- a/crates/hiroz-union/src/plugin/install.rs +++ b/crates/hiroz-union/src/plugin/install.rs @@ -61,24 +61,25 @@ pub struct InstalledDb { pub struct InstalledEntry { pub name: String, pub file: String, - /// `None` when the source does not state one. A local file and a bare URL - /// carry no version; only the registry index does. Recording the *kind* of - /// source here (the previous behaviour) made `hu plugin list` print + /// `None` when the source states no version. A local file and a bare URL + /// state none. Only the registry index states one. The previous code put + /// the source KIND in this field, so `hu plugin list` printed /// `VERSION local`. #[serde(default)] pub version: Option, - /// How it was installed: `local`, `url` or `registry`. + /// The kind of source this came from: `local`, `url` or `registry`. pub source: String, - /// Where it came from, with any credential removed. A signed asset URL can - /// carry a token in its query string or userinfo, and `hu plugin list - /// --json` prints this verbatim. + /// Where it came from. The host removes any credential first: a signed + /// asset URL carries a token in its query string or its userinfo, and + /// `hu plugin list --json` prints this field verbatim. #[serde(default)] pub origin: Option, } -/// Strip anything credential-bearing from a URL before it is persisted: the -/// userinfo (`https://user:token@host/...`) and the query string, which is -/// where a signed-URL token lives. A non-URL is returned unchanged. +/// Remove the credential-bearing parts of a URL before the host stores it. +/// Those parts are the userinfo (`https://user:token@host/...`) and the query +/// string, where a signed URL carries its token. This returns a non-URL +/// unchanged. fn sanitize_origin(source: &str) -> String { if !is_url(source) { return source.to_string(); diff --git a/crates/hiroz-union/tests/plugin_install.rs b/crates/hiroz-union/tests/plugin_install.rs index 011d5ba7e..35947b648 100644 --- a/crates/hiroz-union/tests/plugin_install.rs +++ b/crates/hiroz-union/tests/plugin_install.rs @@ -10,10 +10,10 @@ //! executed — including the WIT world-mismatch check, which is the kind of //! guard that looks correct forever and is never proven to fire. //! -//! The success paths are here too, and they were not before. The obstacle was -//! real — `hiroz-union` cannot build a plugin, because the plugins are a -//! separate, `exclude`d, `wasm32-wasip2` workspace — but it did not need a -//! plugin. `validate_plugin_static` compiles the file as a component and does +//! The success paths are here too. They were absent for a reason that was real +//! but did not apply. `hiroz-union` cannot build a plugin, because the plugins +//! are a separate, `exclude`d, `wasm32-wasip2` workspace. These tests do not +//! need one. `validate_plugin_static` compiles the file as a component and does //! not instantiate it, so the 8-byte empty component below is enough to drive //! install, list and uninstall end to end. It cannot dispatch, and no test here //! claims it can. @@ -109,8 +109,8 @@ impl Outcome { /// version (0x0d) and layer (0x01). `Component::from_file` compiles it, which /// is all `validate_plugin_static` asks of a plugin, so it exercises every step /// of installation without needing the `wasm32-wasip2` toolchain. It exports -/// nothing, so it can never be dispatched — that is a separate concern and no -/// test here pretends otherwise. +/// nothing, so nothing can dispatch it. That is a separate concern, and no test +/// here claims otherwise. const EMPTY_COMPONENT: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00]; fn sha256_hex(bytes: &[u8]) -> String { @@ -397,9 +397,9 @@ fn uninstall_explains_when_the_plugin_lives_on_hu_plugin_path() { // --------------------------------------------------------------- success // // Every test above is a refusal. These are the paths a user actually takes, -// and until now not one of them had ever run: the metadata defects this file -// was extended to cover were found by review, not by a test, because no test -// ever completed an install and looked at the result. +// and until now not one of them had ever run. Review found the metadata +// defects this file now covers, because no test ever completed an install and +// then looked at the result. #[test] fn local_install_places_the_file_and_records_its_provenance() { @@ -553,8 +553,8 @@ fn uninstall_removes_both_the_file_and_the_record() { assert!(r.ok, "uninstall should succeed, got:\n{}", r.output); assert!(!installed.exists(), "the file survived:\n{}", r.output); - // The record must go with it. A surviving entry would later be attached to - // whatever is installed next under the same name. + // The record must go with it. hu would otherwise attach a surviving entry + // to the next plugin installed under the same name. let list = hu(&home, &["list", "--json"]); assert!( !list.output.contains("hu_meter.wasm"), diff --git a/scripts/build-hu-release.nu b/scripts/build-hu-release.nu index f1a5778e3..566f923f9 100755 --- a/scripts/build-hu-release.nu +++ b/scripts/build-hu-release.nu @@ -325,7 +325,15 @@ If that list is empty, `hu meter` and `hu monitor` will not work. ## Verify this download - sha256sum -c SHA256SUMS +`SHA256SUMS` covers every asset in the release, and this archive holds only +some of them. A bare `sha256sum -c` therefore reports the absent ones as +failures. Check the files you actually downloaded: + + sha256sum --ignore-missing -c SHA256SUMS + +Older coreutils has no `--ignore-missing`. Check one file instead: + + grep hu-($ver)-($target).tar.gz SHA256SUMS | sha256sum -c - ## Documentation From 0306816e44145b9eaa052087c4a529a576b70f92 Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 11:01:14 +0800 Subject: [PATCH 6/7] test(hu): repair the two tests CI caught `url_install_does_not_persist_a_credential_from_the_source_url` sent `?token=SUPERSECRET`, and the fixture server routed on the whole request target. The route table therefore missed and the server answered 404. It now routes on the path, as a real asset host does with a query string it does not recognise. `uninstall_explains_when_the_plugin_lives_on_hu_plugin_path` asserted the old wording. That message named `$HU_PLUGIN_PATH` unconditionally, which is wrong when the plugin sits in the executable-relative prefix and the variable is unset. The message now names the directory that holds the file, so the test asserts on that directory and on the absence of the "not installed" wording. --- crates/hiroz-union/tests/plugin_install.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/hiroz-union/tests/plugin_install.rs b/crates/hiroz-union/tests/plugin_install.rs index 35947b648..01ff6f55b 100644 --- a/crates/hiroz-union/tests/plugin_install.rs +++ b/crates/hiroz-union/tests/plugin_install.rs @@ -66,8 +66,11 @@ fn handle(mut stream: TcpStream, routes: &HashMap) { if reader.read_line(&mut request_line).is_err() { return; } - // "GET /path HTTP/1.1" - let path = request_line.split_whitespace().nth(1).unwrap_or("/"); + // "GET /path?query HTTP/1.1". Route on the path alone: a real asset host + // ignores a query string it does not know, and the credential test sends + // `?token=...` to prove that hu does not persist it. + let target = request_line.split_whitespace().nth(1).unwrap_or("/"); + let path = target.split('?').next().unwrap_or(target); let (status, body) = routes .get(path) @@ -385,9 +388,18 @@ fn uninstall_explains_when_the_plugin_lives_on_hu_plugin_path() { let r = uninstall("meter", Some(&dir)); assert!(!r.ok, "must not claim success:\n{}", r.output); + // The message must NAME the directory that holds the file. It used to say + // `$HU_PLUGIN_PATH` unconditionally, which is wrong when the plugin sits in + // the executable-relative prefix and that variable is unset -- advice the + // user cannot act on. assert!( - r.output.contains("HU_PLUGIN_PATH"), - "should point at the real location, not say 'not installed', got:\n{}", + r.output.contains(&dir.display().to_string()), + "should name the directory that actually holds it, got:\n{}", + r.output + ); + assert!( + !r.output.contains("no installed plugin named"), + "must not claim it is not installed, got:\n{}", r.output ); From 60e1f9772141e3cf16e83c7440497e999e692fcf Mon Sep 17 00:00:00 2001 From: yuanyuyuan Date: Sat, 22 Aug 2026 11:25:13 +0800 Subject: [PATCH 7/7] fix(hu): keep a URL's query string out of the installed filename `hu plugin install ` derived the file name from the whole URL. A signed asset URL carries its token in the query string, so the installed file was named `hu_meter.wasm?token=...` -- the credential written into a path on disk, where nothing redacts it. The previous commit stopped the token reaching `installed.json`; it still reached the filesystem. The sidecar URL was wrong for the same reason. Appending `.sha256` to the full URL asks for `....wasm?token=....sha256`, which a host that ignores unknown query parameters answers with the asset itself. The installer then read the component bytes as a checksum and refused the install. Both now use the URL's path, with the query re-attached for the sidecar request. The credential test asserts the installed filename as well as the record, because a filename is what the record fix did not cover. --- crates/hiroz-union/src/plugin/install.rs | 14 ++++++++++++-- crates/hiroz-union/tests/plugin_install.rs | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/hiroz-union/src/plugin/install.rs b/crates/hiroz-union/src/plugin/install.rs index 0cd024658..cd69e7eaf 100644 --- a/crates/hiroz-union/src/plugin/install.rs +++ b/crates/hiroz-union/src/plugin/install.rs @@ -292,8 +292,18 @@ pub fn install(source: &str, registry: Option<&str>) -> Result { if is_url(source) { let bytes = http_get(source)?; - let name = source.rsplit('/').next().unwrap_or("plugin.wasm"); - let expected = http_get(&format!("{source}.sha256")) + // Split the query and fragment off before deriving anything from the + // URL. A signed asset URL carries its token there, and both uses below + // are wrong if it is left on: the file name becomes + // `hu_meter.wasm?token=...`, which puts the credential in a path on + // disk, and the sidecar URL becomes `....wasm?token=....sha256`, which + // asks the host for the asset rather than its checksum. + let (path_part, query) = match source.find(['?', '#']) { + Some(i) => (&source[..i], &source[i..]), + None => (source, ""), + }; + let name = path_part.rsplit('/').next().unwrap_or("plugin.wasm"); + let expected = http_get(&format!("{path_part}.sha256{query}")) .ok() .and_then(|b| String::from_utf8(b).ok()) .and_then(|s| s.split_whitespace().next().map(str::to_string)); diff --git a/crates/hiroz-union/tests/plugin_install.rs b/crates/hiroz-union/tests/plugin_install.rs index 01ff6f55b..263a2de23 100644 --- a/crates/hiroz-union/tests/plugin_install.rs +++ b/crates/hiroz-union/tests/plugin_install.rs @@ -504,6 +504,25 @@ fn url_install_does_not_persist_a_credential_from_the_source_url() { let r = install(&[&format!("{base}/hu_meter.wasm?token=SUPERSECRET")]); assert!(r.ok, "install should succeed, got:\n{}", r.output); + // The file name is derived from the URL. With the query string left on, the + // credential lands in a path on disk -- which is worse than the record, + // because nothing ever redacts a filename. + let installed: Vec = std::fs::read_dir(managed_dir(&r.home)) + .map(|d| { + d.filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + assert!( + installed.iter().any(|f| f == "hu_meter.wasm"), + "expected hu_meter.wasm, found {installed:?}" + ); + assert!( + !installed.iter().any(|f| f.contains("SUPERSECRET")), + "the credential reached a filename: {installed:?}" + ); + let list = hu(&r.home, &["list", "--json"]); assert!( !list.output.contains("SUPERSECRET"),