Signed release distribution and in-place auto-updates for Rust programs, built on the
BottleFmt stack (bottlers
for identity/signing, purecrypto for hashing,
compcol for compression).
rsupd is the Rust successor to goupd: it keeps the
atomic binary-swap-and-restart mechanics but replaces goupd's plaintext update files with a
cryptographically signed manifest, so a program can trust an update with nothing but a
32-byte fingerprint baked into it.
There are two sides and one rule.
flowchart LR
subgraph P["Producer · rsupd CLI"]
direction TB
K["holds the private<br/>signing key 🔑"] --> B["build + sign<br/>the release"]
end
subgraph H["rsupd host (free)"]
M["signed manifest<br/>+ artifacts<br/><i>bytes only — no keys</i>"]
end
subgraph C["Consumer · rsupd::Updater"]
direction TB
F["carries the project's<br/>32-byte fingerprint"] --> V["verify signature<br/>against fingerprint"]
V --> D["download +<br/>check binary hash"]
D --> S["atomic self-swap"]
end
B -- upload --> M
M -- fetch --> V
K -. "rsupd id export<br/>(embedded once, at build)" .-> F
(Producer and consumer are usually the same project — rsupd itself is both. The fingerprint is the producer's, derived from its signing key and compiled into the consumer at build time; only the private key stays producer-only.)
rsupd (the hosting service) is free for anyone to publish to. It only stores bytes — it never holds your keys and cannot forge or force an update, because the signing key (yours) is the sole source of trust. A compromised or malicious host can withhold updates, but it can never make your users install something you didn't sign.
The rule: the consumer trusts exactly one thing — the fingerprint (a 32-byte SHA-256 of the project's signing public key) that you compile into it. Everything else (the manifest, the host it came from, the binary) is verified against that anchor. Whoever holds the private signing key can publish; nobody else can, no matter what they control.
There are two roles: you publish signed releases (a one-time setup with the rsupd CLI),
and your program consumes them (a few lines of the rsupd library). Here is the whole path.
cargo install rsupd --features _cli # the producer CLI (the library default is the updater)
rsupd id init --project myapp # creates ~/.config/rsupd/myapp/identity.bin
rsupd id export --project myapp # prints the 32-byte trust anchor as hex (paste it into your app)
rsupd publish --setup-ci --full # build.rs + multi-platform CI + a sign/publish jobGuard identity.bin — it is the signing key. --setup-ci --full also uploads it as the
RSUPD_IDENTITY CI secret and adds a tag-gated job that builds every platform and publishes
signed binaries, so cutting a release becomes "push a v* tag". (Use plain --setup-ci to scaffold
the build without the secret upload / auto-publish.)
cargo add rsupd # the default is the consumer updater — no features neededfn updater() -> rsupd::Result<rsupd::Updater> {
rsupd::Updater::builder(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
// The trust anchor from `rsupd id export`. It's a hash of a public key,
// so it's safe to paste right into your source.
.fingerprint_hex("925804220841644e23b6c756b2dc3e611374d08eeb24918fcff0161401da8334")
.build() // fetches from the dist-go host by default
}That is the whole integration — the channel defaults to master (matching rsupd publish), and the
transport defaults to the dist-go HttpTransport. Optional: to also detect a rebuild of the same
version, add a build.rs (rsupd publish --setup-ci writes one) and feed its stamps in:
.git_tag(env!("RSUPD_GIT_TAG"))
.date_tag(rsupd::date_tag_from_unix(env!("RSUPD_BUILD_UNIX")))Command-line tool — update when asked, then exit:
fn main() {
rsupd::honor_startup_delay(); // settle briefly if an update just restarted us
if std::env::args().any(|a| a == "--update") {
match updater().and_then(|u| u.update()) {
Ok(true) => println!("updated"),
Ok(false) => println!("already up to date"),
Err(e) => eprintln!("update failed: {e}"),
}
return;
}
// ... your normal command ...
}Daemon / long-running — check hourly in the background and restart into the new build:
fn main() {
rsupd::honor_startup_delay();
if let Ok(u) = updater() {
u.spawn_auto_update(/* check immediately = */ false); // hourly; installs + restarts
}
// ... run your service ...
}A successful update restarts the process into the new binary by default; pass
.auto_restart(false) to the builder if you'd rather apply the swap and handle the restart yourself.
rsupd check # confirms the fingerprint is embedded and the updater is wired
git tag v1.2.3 && git push --tags # CI builds every platform, signs, and publishesrsupd check exits non-zero when something is missing (so it works as a CI gate) and prints
copy-paste fixes for whatever it finds.
This is the part worth understanding in full.
Each project owns an identity: an Ed25519 signing key (the primary, "self" key) plus an
X25519 encryption key, packaged as a signed IDCard (the public half) alongside an encrypted
keychain (the private half). It lives at ~/.config/rsupd/<project>/identity.bin and is
created once with rsupd id init. The signing key is the only one the release path uses; guard
it like any other code-signing key — anyone who has it can publish updates your users will trust.
rsupd id export prints the 32-byte SHA-256 of the signing public key as hex, which you paste
into your program via .fingerprint_hex(".."). That single constant is the entire basis of trust: no key servers, no
certificate chains, no TLS pinning. It is public (a hash of a public key), so commit it freely.
A release is described by a manifest — an integer-keyed CBOR map (bottlers house style):
| key | field | key | field |
|---|---|---|---|
| 1 | format version | 6 | git short hash |
| 2 | project | 7 | released (unix time) |
| 3 | channel | 8 | the project's IDCard (public) |
| 4 | version (semver) | 9 | artifacts |
| 5 | date tag (YYYYMMDDhhmmss) |
Each artifact is itself an int-map: target, filename, compression, raw_size, size,
and a hash (["sha256", <digest>]) over the uncompressed binary. The whole manifest is
sealed in a bottlers::Bottle and signed by the project's Ed25519 key. Note it embeds the
project's full IDCard — so the consumer can recover the public key with no out-of-band fetch.
When the consumer checks for an update, the signature is verified before anything is downloaded, and the binary is verified before anything is replaced.
- Fetch + verify the manifest (
Manifest::open_and_verify), in order:- the signed CBOR bottle opens;
- the IDCard embedded in it is validly self-signed;
- that IDCard's fingerprint equals the fingerprint compiled into the consumer;
- the manifest bottle is signed by that same key.
- Applicability checks: the manifest's project and channel match, it carries an artifact for the running host, and it is strictly newer than the running build (see below).
- Download the (compressed) artifact for this host.
- Verify before replacing (
decode_and_verify): the stored size matches, it decompresses to exactlyraw_size(capped at 2× as a guard), and the SHA-256 of the decompressed binary matches the hash in the signed manifest. - Atomically install: write
.<name>.new, rename the current binary to.<name>.old, move the new one into place, restore on failure. (A running Windows executable that can't be deleted is hidden instead.)
The integrity chain is therefore: embedded fingerprint → manifest signature → trusted hash → artifact bytes. The binary's hash is only believed because it lives inside a document signed by the key whose fingerprint you pinned. Tampering with the binary or the hash fails.
A release is installed only if it is strictly newer than the running build:
- greater semver version → update;
- equal version → update only if the manifest's
date_tag(git commit time) is strictly greater than the running build's; - same git short hash → it's the same build, skip.
For this to work the running binary must know its own build identity. A build.rs captures the
git short hash and commit time at compile time (RSUPD_GIT_TAG / RSUPD_BUILD_UNIX); the updater
is told about them via .git_tag(...) / .date_tag(...). Built outside a git checkout (e.g. from
a crates.io tarball) these are empty and the comparison falls back to plain semver. The net effect:
rsupd never downgrades, and never reinstalls an identical build.
- A hostile mirror / MITM / DNS hijack cannot forge an update: without the signing key they can't produce a manifest that verifies against your fingerprint, and a tampered binary won't match a validly-signed hash. This is why the distribution host is not the trust boundary — the download URL doesn't need to be authenticated, and pinning the host (below) is about operational consistency, not security.
- Rollback is prevented in the sense that the consumer only installs strictly-newer versions; a replayed older (but validly-signed) manifest is ignored.
- What it does not prevent: an attacker who controls the network can suppress updates
(keep serving the current valid manifest, so you simply never see a new one) — manifests carry a
timestamp but no enforced freshness/expiry. And, as with any signing scheme, compromise of the
private signing key is total — the holder can sign malicious updates. Protect the key; in CI it
lives only as the
RSUPD_IDENTITYsecret (below). - The encryption (X25519) key in the identity is advertised in the IDCard but is not used by the release-signing path; releases are signed, not encrypted.
The producer is the rsupd CLI, which lives behind the crate's non-default _cli feature:
cargo install rsupd --features _cli (or use a prebuilt binary). The library you depend on in
your app needs no feature — it's the consumer updater by default.
# 1. one-time: create the project signing identity, and export its fingerprint
rsupd id init --project myapp
rsupd id export --project myapp # prints the fingerprint hex to embed in the app
# 2. compile your binaries (natively and/or cross-compiled)
cargo build --release # plus any `--target <triple>` builds
# 3a. build a signed package from target/<triple>/release/ (offline artifact)
rsupd build -C . -o myapp.zip
# 3b. …or build + confirm + upload in one step
rsupd publish # binaries from the local target/ tree
rsupd publish --ci # binaries downloaded from a CI run (see below)
# inspect / verify a package, and self-check your wiring
rsupd inspect myapp.zip
rsupd checkThe package is a plain store-mode .zip (unzip-readable) containing manifest.cbor plus one
zstd-compressed archive per target, named flat as <bin>_<target>.zst. --naming chooses the
target label: os_arch (default, e.g. myapp_linux_amd64.zst) or full triple (e.g.
myapp_x86_64-unknown-linux-musl.zst, when one os_arch slot isn't specific enough). A single
macOS universal (fat) binary is published under the darwin_universal label and matches either
Apple arch. The channel defaults to the current git branch (falling back to master).
rsupd check is the doctor: it statically verifies that each [[bin]] constructs the updater and
embeds the right fingerprint, and that a build.rs build identity and a CI build config are
present — printing the exact rsupd … command to fix whatever is missing. It exits non-zero when a
required item is missing, so it doubles as a CI / pre-release gate.
rsupd publish uploads to the Cloud/Rust:upload endpoint of the rsupd hosting service — a free
file host for anyone distributing rsupd-signed releases. It stores bytes only: no account-bound
keys, and (as above) it can't influence what your users accept, since trust comes entirely from
your signature. The layout (where <name> is the base64url of the project fingerprint):
https://dist-go.tristandev.net/rust/<name>/MANIFEST-<channel> ← moving pointer to the latest manifest
https://dist-go.tristandev.net/rust/<name>/<version>/<filename> ← each artifact (immutable)
Versions are immutable server-side: re-publishing an existing <version> is rejected, so a
released artifact set can't be silently overwritten.
The minimal integration is in Getting started above. A few details:
- Builder knobs (
rsupd::Updater::builder(name, version)):.fingerprint_hex("..")(or.fingerprint(bytes)for raw bytes) — required, the trust anchor;.channel(..)(defaults tomaster, must match how you publish);.git_tag(..)/.date_tag(..)(optional build identity for same-version detection);.transport(..)(optional, defaults to the dist-goHttpTransport);.auto_restart(bool)(defaulttrue). - Applying updates:
check()returns anAvailable(.version(),.git_tag()) without touching disk;install(&available)swaps the binary in place;update()does check + install (+ restart unless disabled);spawn_auto_update(immediate)runs that on an hourly background thread. - Target selection:
rsupd::TARGETis the running build's exact triple (captured bybuild.rs); the updater matches the artifact by it (or the compactos_archlabel), falling back todarwin_universalon macOS. - Call
rsupd::honor_startup_delay()early inmain()so a process that was just restarted by an update settles briefly before doing work.
HttpTransport— the real network transport. It fetches manifests/artifacts from the fixeddist-gohost using the fingerprint-derived path. The host is deliberately not configurable; recall it is not the trust boundary.ZipPackageTransport— serves a local package.zip, running the entire check → verify → install path offline. Useful for tests and sideloading.
You can implement the rsupd::Transport trait yourself to fetch from anywhere; all signature and
hash verification happens in the updater regardless of where the bytes come from.
rsupd is itself a consumer: rsupd version prints its version, build identity and target, and
rsupd update downloads and installs the latest matching release in place.
rsupd publish --ci sources the per-platform binaries from a CI run instead of the local tree: it
uses gh (GitHub) or glab (GitLab) — auto-detected, or --provider — to download a run's
artifacts, expecting one artifact named after each Rust target triple containing the compiled
binary.
rsupd publish --setup-ci scaffolds that for you: a build.rs (build identity) plus a build
workflow (.github/workflows/build.yml or a marked block in .gitlab-ci.yml) whose matrix builds
every supported target — Linux amd64/arm64/armv7, Windows amd64/arm64, and a single macOS universal
binary via lipo — and uploads each as a triple-named artifact.
rsupd publish --setup-ci --full goes further: it uploads the signing identity as the
RSUPD_IDENTITY secret (base64 of identity.bin, piped to gh/glab, with a confirmation
prompt) and adds a tag-gated publish job that builds, signs and uploads the release
unattended. In CI, rsupd reads the identity straight from RSUPD_IDENTITY (via
Identity::load_env_or_file) — no filesystem setup, and the on-disk identity is used as a fallback
when the variable is unset. The publish job assumes the identity has no keychain password.
Complete and in use: identity/signing, the signed manifest, packaging, the producer CLI
(id, build, publish, inspect, check, version, update), the consumer updater
(verify → download → hash-check → atomic swap → restart), the HttpTransport over dist-go, and
the GitHub/GitLab CI scaffolding including the full sign-and-publish pipeline.
MIT