feat(update): self-update via suprim-server feed#4
Conversation
App polls /suprim/update/latest on startup, shows an "Update available!"
badge in the status bar next to the Premium/Free tier pill, and on
click runs a full self-update pipeline:
1. Download the DMG into ~/Library/Caches/SuprimSQL/updates/.
2. Verify SHA-256 matches the feed.
3. hdiutil attach -> cp -R -> hdiutil detach.
4. `open -n /Applications/SuprimSQL.app` then exit so the new process
takes over the focused window.
Modules:
- update/check.rs: reqwest + semver compare, BaseResponse envelope aware,
accepts endpoint override via SUPRIM_UPDATE_ENDPOINT env var so we can
point at a local mock during dev.
- update/state.rs: Idle / Checking / UpToDate / Available / Installing
(with UpdateProgress sub-state) / Relaunching / Failed. Shared across
UI and worker via Arc<Mutex<...>>.
- update/install.rs: pure-Rust stream download + sha2 verify + shell-outs
to /usr/bin/hdiutil and /bin/cp for mount/install. Always attempts to
detach the DMG before bubbling up an error.
- ui/statusbar.rs: the update badge shares the draw_badge() painter with
the tier badge. Hover brightens the fill colour and sets the cursor
to pointing hand; click on "Available" starts the install, click on
"Failed" dismisses. Tier badge is now clickable and opens the license
dialog. Global tooltip delay tightened to 0.25s.
- ui/update_banner.rs: handler-only module that spawns the install task
or resets state in response to status-bar actions.
Testability refactors:
- download_dmg() -> stream_to_file() pure-network helper.
- cache_path() -> cache_path_in(root, version).
- copy_app() -> copy_app_to(mount, dest).
- mount_dmg() -> parse_mount_point(plist) pure string parser.
Tests: 49 new unit + integration tests (114 total, 0 failed), 0 clippy
warnings with --all-targets -D warnings. Coverage includes semver
comparison, JSON snake_case deserialization, wiremock-mocked HTTP flows
(newer / older / equal / invalid / null / network-failure / envelope-err),
SHA-256 verification (match / mismatch / missing-file / case-insensitive),
hdiutil plist parsing (canonical / empty / missing / first-occurrence),
cp bundle replacement (copy / remove-old / missing-source), streaming
download with monotonic progress, install_inner error propagation, and
every status-bar state rendering without panic.
Env override for local testing:
SUPRIM_UPDATE_ENDPOINT=http://localhost:8888/update/latest
Code ReviewFiles changed: 12 | Lines: +1829 / -54 SummaryWires self-update pipeline: polls FindingsCritical
Important
Minor
Positive Patterns
Verdict: REQUEST CHANGES6 critical, 4 important, 5 minor. Findings 1, 2, 3 are hard blockers for a self-updater shipping to prod — personal-domain endpoint + no signature verify + destructive replace = supply-chain attack surface + data-loss risk. Fix the six criticals before merging. Unresolved questions
|
Critical: #1 Switch DEFAULT_ENDPOINT from api.sant1ago.dev (personal domain) to api.suprim.dev (corp-owned). Keeps the trust anchor under project control if maintainers change hands. #2 Code-signature verification after copy. Bake the expected Developer ID Team ID into the binary at build time via the optional SUPRIM_TEAM_ID env var; after the new bundle lands in /Applications/, shell out to `codesign --verify --deep --strict` and parse `codesign -dvv` output for the TeamIdentifier. Reject mismatches. Skipped gracefully for dev/unsigned builds so the pipeline still exercises in CI. #3 Rename-swap atomic replace. Previously `remove_dir_all` + `cp -R` left the user with no bundle at all if cp died mid-way. Now: - rename(dest, dest.backup) - cp -R new-bundle into dest - on success: remove backup - on failure: rename backup back, leaving the old app intact Also clears stale `.backup` directories from prior failed runs. #4 Size cap (500 MB) on stream_to_file. Reject oversized Content-Length headers up front, and track bytes_done mid-stream so a lying / absent header can not sneak past. Partial files are deleted on cap trip so a retry starts clean. #5 Graceful exit. Replace `std::process::exit(0)` after relaunch with `egui::ViewportCommand::Close`, which fires App::on_exit (saves workspace.json + query history) before the process dies. Previously the user could lose unsaved tabs. #6 try_lock() on the UI render thread. The async worker can hold the update-state mutex during a 10 s network timeout; blocking on it per frame would beachball the entire app at startup. try_lock skips a frame (invisible to the user) instead. Important: #7 tracing::warn on unmount failure (was `let _ = ...`). #8 Install pipeline gated `#[cfg(target_os = "macos")]`. Non-macOS builds compile but the entry point sets Failed with a clear message instead of attempting hdiutil. #9 set() recovers from poisoned mutex via `poisoned.into_inner()` instead of silently dropping the new state. #10 SemverParse error now carries the underlying `semver::Error` message, not just the failing string. Tests: 120 passing, 0 failed, 1 ignored (500 MB size-cap validation, run manually with `--ignored`). 0 clippy warnings with --all-targets. New tests cover rollback on cp failure, stale-backup cleanup, Team ID extraction (present / missing / trailing-whitespace), and the size cap.
Minor cleanups from the bot review:
#11 draw_badge now routes text through WidgetText::into_galley so
per-frame repaints hit egui's text layout cache instead of
re-laying-out via painter().layout_no_wrap.
#12 Document the global tooltip_delay override as intentional — default
0.5 s felt sluggish on trackpads; 0.25 s applies to every tooltip in
the app (sidebar truncations, query history, etc.) so the behaviour
stays consistent.
#13 Replace hardcoded "stable" in the feed query with a
`STABLE_CHANNEL` const at module top. When a beta channel lands
there's one place to grep.
#14 Narrow dev-dep tokio features from `["full", "test-util"]` to
`["rt-multi-thread", "macros", "time", "test-util"]` — dropped
signal, net, process, fs, sync, io-util which no test uses.
#15 parse_mount_point now XML-unescapes the five predefined entities
(& < > " '). SuprimSQL controls its DMG volume
name today, but this is defensive against future metadata changes.
Decoding order preserves `&lt;` → `<` literal instead of
double-decoding to `<`.
Tests: 123 passing (+3 for the new unescape coverage), 0 failed,
1 ignored. 0 clippy warnings.
DEFAULT_ENDPOINT is now resolved in this order at runtime:
1. Runtime env var SUPRIM_UPDATE_ENDPOINT (dev override, unchanged).
2. Build-time SUPRIM_UPDATE_ENDPOINT baked in via option_env! at
compile time. Lets staging / beta / prod release pipelines ship
with different defaults without touching source.
3. Hardcoded corp fallback https://api.suprim.dev/suprim/update/latest.
Usage:
SUPRIM_UPDATE_ENDPOINT=https://staging.api.suprim.dev/suprim/update/latest \
cargo build --release
Same pattern as EXPECTED_TEAM_ID in install.rs. Uses `match option_env!`
in a `const` context (stable since Rust 1.80).
Added a sanity test that DEFAULT_ENDPOINT always starts with https:// —
a plaintext default would regress the TLS + signature threat model
from finding #2.
Tests: 124 passing (+1), 0 failed, 1 ignored. 0 clippy warnings.
SUPRIM_UPDATE_ENDPOINT and SUPRIM_TEAM_ID are now baked in via option_env!, but there was no onboarding story for setting them. Ship two templates devs can copy: - Root-level example env template: shell-compatible file with commented overrides. Pair with `set -a && source <file>; set +a` before cargo build. - .cargo/config.toml.example: Cargo project-level [env] section. Copy to .cargo/config.toml (gitignored) and every cargo build/run in the workspace picks it up automatically. Both templates are whitelisted in .gitignore; the non-example copies are ignored so secrets never get committed by accident. README gets a Build-time configuration subsection spelling out all three setup paths (inline / dotenv / cargo config) plus the option_env! compile-time gotcha.
Re-review — commits
|
| # | Fix | Verified at |
|---|---|---|
| 1 | Endpoint → api.suprim.dev + option_env! build-time override |
update/mod.rs:35-39 |
| 2 | verify_code_signature — codesign --verify --deep --strict + TeamIdentifier match |
update/install.rs (see N1 below) |
| 3 | Rename-swap atomic replace with rollback | copy_app_to |
| 4 | 500MB cap, double-check (header + streaming accumulator), partial file cleanup | stream_to_file |
| 5 | ViewportCommand::Close instead of exit(0) — fires App::on_exit |
install_update |
| 6 | try_lock() on UI render thread |
statusbar.rs:48 |
| 7 | tracing::warn! on unmount failure |
install_inner:130-136 |
| 8 | #[cfg(target_os = "macos")] gate on install pipeline |
install.rs |
| 9 | set() recovers via poisoned.into_inner() |
state.rs |
| 10 | SemverParse carries underlying error |
check.rs:111-115 |
| 11 | WidgetText::into_galley hits text layout cache |
draw_badge |
| 12 | Tooltip delay comment marks it "GLOBAL ... intentional" | app.rs:101-107 |
| 13 | STABLE_CHANNEL const |
check.rs:12 |
| 14 | Tokio features narrowed | Cargo.toml:77 |
| 15 | unescape_xml + decode order preserves &lt; |
parse_mount_point |
New findings from the fix commits
Critical
| # | File | Category | Issue | Suggestion |
|---|---|---|---|---|
| N1 | crates/suprim-app/src/update/install.rs (EXPECTED_TEAM_ID + verify_code_signature) |
Security | verify_code_signature silently skips when SUPRIM_TEAM_ID is unset (only tracing::warn). A release build that forgets the env var ships with signature verification disabled — regressing finding #2 with nothing screaming in the build log. |
Add a build.rs guard or compile_error! when target is macOS + release profile + SUPRIM_TEAM_ID unset. Fail the build instead of warning at runtime. |
Minor
| # | File | Category | Issue | Suggestion |
|---|---|---|---|---|
| N2 | crates/suprim-app/src/update/install.rs (install_update non-macOS branch) |
UX | On Linux/Windows the "Update available!" badge still renders and clicking spawns a tokio task that immediately sets Failed. User sees a clickable update that can't install. |
Short-circuit in handle_status_action or update_badge_style on non-macOS so the badge either hides or renders as "Download manually" with a URL. |
| N3 | crates/suprim-app/src/update/install.rs (stream_to_file_enforces_cap_when_content_length_is_missing) |
Tests | Test name says "missing Content-Length" but wiremock always sets it — test doesn't exercise what the name claims. Comment even admits the combined behaviour isn't covered. | Rename to reflect what it actually tests, or add a test that genuinely streams past the cap without a Content-Length header. |
| N4 | crates/suprim-app/src/update/install.rs (copy_app_to_rolls_back_when_cp_fails) |
Tests | Test name says "rolls back when cp fails" but the test only covers the success path and backup cleanup. Doesn't trigger a cp failure. | Either rename to copy_app_to_removes_backup_on_success or force cp failure (read-only dest parent) and verify rollback restores the old bundle. |
| N5 | crates/suprim-app/src/update/install.rs (verify_code_signature) |
Docs | Reading metadata from stderr (display.stderr) is correct — codesign -dvv writes to stderr. Non-obvious; future reader might "fix" it to stdout. |
Add a one-line comment: // codesign -dvv writes metadata to stderr, not stdout — do not "fix" this. |
Verdict: Nearly ready — fix N1 before merge
N1 is a footgun: release pipeline forgets one env var → signature verification silently off → finding #2 regresses without a compile error or test catching it. Make the build fail loudly instead.
N2-N5 are non-blocking cleanups; fine as follow-up.
N1 (critical): build.rs refuses to produce a macOS release binary when SUPRIM_TEAM_ID is unset. A shipped binary with an empty Team ID would silently skip codesign verification and regress finding #2 from the previous review. Escape hatch for CI preview builds: set SUPRIM_ALLOW_UNSIGNED_RELEASE=1 to acknowledge the risk and bypass. - cargo:rerun-if-env-changed for both SUPRIM_UPDATE_ENDPOINT and SUPRIM_TEAM_ID so option_env! sees new values without cargo clean. - CARGO_CFG_TARGET_OS + PROFILE == release gate so cross-compiling macOS from another host still enforces the check. Verified: - cargo build (debug) → pass - cargo build --release (no env) → panics with the diagnostic - SUPRIM_TEAM_ID=X cargo build --release → pass - SUPRIM_ALLOW_UNSIGNED_RELEASE=1 cargo build --release → pass N2: hide the update badge entirely on non-macOS instead of letting the user click it and see a Failed(platform-not-supported) toast. Introduce self_update_supported() as a single cfg-gated source of truth so the statusbar, future settings UI, and menu items all agree. N3: rename stream_to_file_enforces_cap_when_content_length_is_missing → stream_to_file_accepts_body_at_the_cap_exactly. The old name oversold what the test actually checked (wiremock always sets Content-Length; the fallback path is covered by the ignored 500 MB stress test). N4: copy_app_to rollback test now genuinely forces cp to fail. Old test was named "rolls_back_when_cp_fails" but only covered happy-path backup cleanup. Split into two tests: - copy_app_to_removes_backup_on_success (happy path) - copy_app_to_rolls_back_when_cp_fails (force cp failure by chmod 0 on a child in the source bundle, then assert the original bundle is restored and the .backup directory is gone) N5: comment verify_code_signature explaining codesign -dvv writes metadata to stderr, not stdout. Future readers were liable to "fix" display.stderr to display.stdout and silently break the check. Tests: 125 passing (+1 for split), 0 failed, 1 ignored. 0 clippy warnings with --all-targets.
dtolnay/rust-toolchain@stable does not include clippy by default, causing all CI runs to fail at the clippy step. Adding components: clippy ensures it gets installed.
Cargo.lock was gitignored, so CI generated a fresh lock file on every run. The transitive dependency pkcs5 resolved to 0.8.0 (stable) instead of 0.8.0-rc.13, which removed the recommended() API that pkcs8 0.11.0-rc.11 depends on. Committing Cargo.lock is standard practice for binary applications to ensure deterministic, reproducible dependency resolution across all environments.
actions/checkout: v4 -> v6 (Node 24) actions/cache: v4 -> v5 (Node 24)
- event_handler.rs: remove unused eframe::egui import - sidebar: allow dead_code on ConnListEntry and connection_list (macOS-only callers) - install.rs: remove unneeded return after cfg-gated block - state.rs: allow dead_code on UpdateProgress variants (macOS-only construction)
Summary
Wires SuprimSQL into the
/suprim/update/latestfeed served bysuprim-server. On startup the app polls the feed; when the server reports
a newer release, a compact "Update available!" badge appears next to
the existing Premium/Free pill in the status bar. Click → download →
verify SHA-256 → mount DMG → replace
/Applications/SuprimSQL.app→relaunch.
Stack
reqwest 0.13(rustls, json, stream, query)semver 1sha2 0.10Modules
Endpoint override
`SUPRIM_UPDATE_ENDPOINT=http://localhost:8888/update/latest\` redirects
the client at a local mock server (FastAPI or similar) for dev testing
without touching the live feed.
Tests
on main — 49 new tests for this PR)
Coverage highlights:
null-payload / network-failure
missing-file
first-occurrence-on-duplicate
Testability refactors
Four extract-and-inject refactors so the platform shell-outs aren't in
the way of unit tests:
Intentional gaps
Documented in code comments:
These are end-to-end UI flows covered manually against the mock server;
wiring them into the test harness would require a macOS simulator fake
that outweighs the value.
Screenshot
Status bar with the new badge and tier pill side-by-side:
`[ ⬇ Update available! ] [ 👑 Premium ]`
Hover brightens the pill; click triggers the install pipeline.