feat: close the audit backlog - honest update checks, resumable installs, and a trust chain that reaches the apps - #68
Merged
Conversation
reqwest's ClientBuilder::timeout is a total deadline covering connect, TLS, redirects and the entire body stream. At 300 s that made any asset larger than the link could carry in five minutes impossible to fetch: a 40 MB binary needed a sustained ~1.2 Mbit/s or it failed every single time, and since nothing is resumed, each attempt spent a full asset's worth of quota for no retained progress. The same client backs the launcher self-update, so Colony could not update itself on such a line either. read_timeout bounds the only thing worth bounding - a connection that has stopped delivering bytes. A stalled socket still dies in 60 s; a slow but live one now runs to completion.
check_update_available returned Option<String>, collapsing 'no update' and
'the check could not run' into the same None. updates_checked then REPLACED
available_updates wholesale and wrote the all-clear status line, so going
offline - or simply hitting the anonymous rate limit on a second launch within
the hour - cleared every update badge and affirmatively told the user their
apps were current. No toast, no log, no way to notice.
The correct invariant already exists one file over: check_launcher_update
returns Result and update/launcher.rs refuses to claim 'up to date' on a failed
check, with a regression test. This gives the app-side check the same contract:
- check_update_available returns Result<Option<String>> ('not installed' stays
a legitimate Ok(None), a failed tag resolution is now an Err)
- UpdatesChecked carries one outcome per repo, so a partial failure keeps the
results that did come back
- updates_checked MERGES: a repo whose check failed keeps the badge it had, a
repo that checked clean loses it, and any failure suppresses the all-clear
line in favour of a warning naming the count
Also chains the update queue through download_release's two silent early
returns. A repo that left the catalog mid-run (a refresh replaces it wholesale)
stranded every remaining entry, which a later unrelated install would then
silently drain into an unannounced cascade of downloads.
Colony had no channel that reliably reached the user when something failed. Five parts of the same problem: status_message had exactly one render site, inside the grid header. The detail page returns early and settings and the GitHub panel replace the content pane, so on three of the four pages the status line did not exist - and it was the sole feedback for "no release for your platform", a failed uninstall, a failed release-notes fetch and a rate-limited refresh. It is now a footer in App::view, rendered on every page, which also stops a long raw transport error from laying the Fill search input out at width zero. Diagnostics reached nobody either. EnvFilter::from_default_env().add_directive( INFO) reads as "default to info", but a bare RUST_LOG=debug parses to a directive that compares Equal to the added one, so add_directive REPLACED the user's request. There was no log file at all, and a .desktop launch has no terminal (on Windows, no console). RUST_LOG is now honoured verbatim, and logs go to the cache dir as well as stderr, truncated per run. colony --version opened the GUI, while the bug template makes its output a required field. --version and --help are now answered before the window opens. Toasts never expired with reduce-motion or animations off: the expiry timer was gated on animations, so the retain(!is_expired) branch was unreachable and the stack grew without bound. Since the overlay grows upward from the bottom, the oldest toasts left clicking range and could never be dismissed - the accessibility settings were the ones that silted the UI up. The timer is now always armed and the stack is capped at five. Finally, a failed catalog fetch left an anonymous user with no way out but signing in or restarting. Refresh is now offered in the disconnected and error arms of the GitHub panel and in the grid's empty state - the same anonymous fetch boot already runs. A refresh the user clicked also toasts on failure now; the anti-noise rule stays for the boot path.
The most serious of these is the release URL. `tag` comes from colony.json and was interpolated straight into https://github.com/{org}/{repo}/releases/download/{tag}/{filename}. reqwest parses through the WHATWG URL parser, which collapses `..` segments BEFORE the request is issued, so a tag reading `v1/../../../../../EvilOrg/EvilRepo/releases/download/v1` shortens the path into an account outside the org - and Colony then installs and trusts those bytes. That is reachable from write access to one line of a catalog repo, with no release-publishing rights anywhere, and it survives a JSON diff review. It breaks the single containment claim the trust model rests on. URLs are now built from percent-encoded path segments (build_url), applied to the release URL, the tagged-release API call and the contents API call that carries the manifest's icon path. Encoding rather than rejecting, because a legitimate git tag may contain a slash. Also in this batch: - ensure_safe_component was POSIX-shaped. Rust's Windows path parser treats "payload:stream" as a single Normal component, so it passed the guard and wrote an NTFS alternate data stream; reserved device names (CON, NUL, COM1) resolve to devices from any directory; and a trailing dot or space is stripped by the filesystem, so the name checked differed from the name written. Enforced on every platform, since the catalog is shared. - .meta and .blockmap join NON_INSTALLABLE_SUFFIXES. Without them, the day an ecosystem app adopts the signed sidecar, a documented substring filePattern starts matching two assets and fails - which the spec promises cannot happen. - The staged `<exe>.new` write, the staged .sig/.meta/.meta.sig writes and the OAuth token file now use create_new. fs::write follows a symlink planted at those predictable names; for <exe>.new that meant the rename afterwards moved the SYMLINK over Colony's own binary. - Buffered sidecar fetches are capped at 64 KiB. A release can publish a multi-gigabyte file named foo-linux.sig, which was an OOM on click. - The install path reads the staged file once and checks the signature and the digest against that one buffer, instead of three separate reads of a predictable path between verification and install. - OAuthSession has a hand-written Debug that redacts the token. Message derives Debug and carries one, so a future `tracing::debug!(?message)` would have put it in the log file.
HTTP_CACHE was a process-local LazyLock that nothing ever serialised, so a cold boot and a warm boot cost exactly the same number of GitHub requests. Replaying the org catalog against the live org measures ~55 requests for a full refresh, against an anonymous budget of 60/h - so opening Colony twice within the hour rate-limited the second launch, and the user got a stale catalog. GitHub does not bill a 304, so the map only ever needed to survive the process. It now loads from <data>/cache/http_etags.json at first use and is written back after a successful catalog refresh, where it is most complete. The file is bounded: entries are selected smallest-first so a squeeze drops the handful of giant READMEs rather than the dozens of small manifest responses that make up most of the request count, and any single body over 512 KiB is never persisted. 404s are remembered too, for six hours. The catalog deliberately generates them - several CHANGELOG names, several licence filenames, a second icon path, and colony.json for every repo in the org including those that will never have one - and an ETag structurally cannot help there, because a 404 carries none. That is the difference between comfortably under and comfortably over the anonymous quota. The TTL is the cost: a repo that adds a CHANGELOG is picked up on the next window, or immediately via Settings > Clear caches, which now clears this cache too.
A dropped connection used to throw away every byte received. download_to_file opened the staging file with create_new (which unlinks first) and every failure path deleted the partial, so a drop at 95% of a 40 MB asset cost the user 38 MB and the next click re-paid the full 40. There was no retry either, so 'the network blipped' was indistinguishable from 'this app cannot be installed'. Transfers now resume. A partial file survives a transport failure alongside a small identity sidecar recording the server's ETag and the total length; the next attempt sends Range and appends. download_with_resume wraps that in three bounded attempts with backoff, so an ordinary blip is invisible to the user rather than a failed install. Resuming means stitching two responses into one file, so identity is checked rather than assumed. If-Range is sent but NOT trusted: GitHub redirects release assets to Azure blob storage, which ignores the header and answers 206 to a stale validator just the same - verified against a real release asset. So a 206 is only accepted when the response's own ETag and the total in Content-Range still match what the partial file was recorded against; on any disagreement the partial is discarded and the retry starts clean. Nothing weakens verification: the signature and digest still run over the completed file. Covered by a test that stands up a truncating Range server on std::net (no new dependency) and asserts the stitched file is byte-identical. Also in this batch: - Advertised bodies over 4 GiB are refused up front, and the stream aborts if one exceeds the ceiling anyway. The whole asset is written to disk before any check can run, so a release could otherwise fill the user's partition on its own say-so. - The launcher download stages to <asset>.part and is renamed only once complete. It used to write straight to the path apply_launcher_update consumes. - prune_staging() runs at boot and removes orphaned .part/.new/.old files under apps/ and update-staging/, plus the launcher's own backup. Cancel was the only sweep that existed, so a crash or a closed window leaked the whole partial asset with no UI that showed it. The launcher backup is removed by exact path, never by sweeping the executable's directory - which may well be /usr/bin. - The .colony_asset marker is written BEFORE .colony_version. The two writes are individually non-atomic, and in the old order a kill between them left a filePattern app claiming the new version while the asset marker still named the old binary - so Colony reported the new version, Launch ran the old one, and no update was offered to correct it. This order makes a torn install simply re-offer the update.
…g Windows/macOS The install path could not update an app the user had left open. A plain std::fs::rename over a live executable fails on Windows - the image is held with FILE_SHARE_READ|FILE_SHARE_DELETE, so MoveFileExW cannot delete the destination - and it failed AFTER downloading and verifying the whole asset, with a raw 'Access is denied' and no hint that closing the app would help. Colony already knew the parade and applied it to its own self-update; replace_file() factors it out so both halves work the same way. Uninstall gets the same treatment: a directory whose binary cannot be deleted has its files renamed aside, and the boot sweep collects them once the process holding them has exited. launcher_is_system_managed() was #[cfg(unix)] and matched on /usr and /opt, so a Windows install under Program Files - not writable unelevated - reported false, offered the update button, and died on the same rename. It is now behavioural: probe whether a file can be created next to the executable. One test covers /usr, /opt, Program Files, a read-only mount and /Applications, with no per-platform path list to keep in sync. keyring was built with only the Secret Service backend. keyring 3 has no default feature, so on Windows and macOS its backend resolved to the in-memory mock backend, whose set_password returns Ok while storing nothing - and Colony reads that Ok as success and then deletes its plaintext fallback on purpose. The token was written nowhere and every restart logged the user out, while the log said 'Token saved to OS keychain'. Both native backends are target-gated, so the Linux build is unchanged and no openssl enters the tree (verified with cargo tree --target all). An app whose asset name carries the version - the case filePattern exists to serve - left its previous binary behind on every update, invisible to the user and reclaimed only by an uninstall. SphereCord's AppImage is 166 MB. The superseded file is now removed once the new one is committed. The self-update relaunch no longer exits unconditionally. apply_launcher_update has already committed the swap by then, so discarding the spawn result meant a binary that would not exec made Colony vanish for good, with no window, no toast, and no way to know a .old backup exists. A failed spawn now surfaces as an error naming both paths. Finally, the platform promise is now honest. The README advertised all three as 'Supported'; Windows and macOS are marked best-effort with the specific gaps named. The shipped categories.json had a hardcoded 'Windows' and 'Linux' section, so a Windows user got a permanently empty 'Linux' entry and a macOS user found /Applications filed under one labelled 'Linux'. Sections take an optional "platforms" list, the three system sections are gated to their own OS, and the missing macOS one is added. Its icons are restored too - the shipped config had regressed every glyph to an empty string while the unreachable built-in fallback kept the real ones.
A cluster of handlers that changed state, or refused to, without telling anyone. Logout swallowed the keyring result and reported success unconditionally. On the next launch load_saved_token restored the session from the credential the user believed they had revoked. delete_saved_token now reports what actually happened (treating "nothing stored" as success), and a failure surfaces as an error naming github.com/settings/applications - matching the care save_token already took on the write path. Uninstall tore down in the wrong order: it dropped the update badge, the release notes and the desktop entry BEFORE attempting the removal that can fail, so a directory that could not be deleted left the app on disk with its integration already ripped out. The fallible step runs first now, the failure path is a clean no-op with a toast, and removing something already absent reports success instead of closing the dialog in silence. Uninstall was also the only action in the detail view not gated on a running download - clicking it mid-install remove_dir_all'd the directory the detached blocking task was still renaming into, and the install died with a bare ENOENT. Cancel never refreshed install status. The install runs in a detached blocking task that runs to completion, so a cancel landing after the rename left the app genuinely installed while the grid still showed it as not. Disabled buttons rendered identically to active ones. Every action button was written as a match on Hovered, Pressed and a catch-all, so Status::Disabled fell into the catch-all: during a download, Launch, Update, Download, Update All, Refresh and the sidebar badge all looked clickable and did nothing. They now go through two shared helpers in theme.rs, so the next button cannot regress. The sidebar's update badge kept offering a download on package-managed installs, where it can only produce a warning toast - Settings > About already showed guidance instead. It now does the same. Device-flow login had no way to recover from a browser that would not open: the verification URI was fetched, marked dead_code, and thrown away, while the panel said only "Enter this code on GitHub". The URL is now carried into the state and rendered as a clickable line (through the existing http(s) gate), and a failed open is logged instead of discarded.
fetch_colony_manifest returned a plain Err for two entirely different things: the network refused, or the repo's own colony.json is malformed. The call site treated both as transient, filed the repo in transient_failures, and merged the stale cached entry back in - so a maintainer who typoed their manifest (a missing name, or "platforms": "linux" where a list belongs) got no signal at all, and the store kept serving the last-good manifest indefinitely while their new release never appeared. Content-level failures now carry an InvalidManifest marker in the anyhow chain. Those log at error with the serde message, which names the offending field, and do NOT resurrect the cache. Transport failures keep the existing behaviour, where the cache is exactly what you want. Two more silent swallows in the same path: auto_detect_release's failure was logged at debug. That is why Eidos sits in the catalog as a card with no platform chips and no Download button while nothing anywhere says which check failed - its assets are named eidos-1.12.0-x86_64-linux.tar.gz, which matches no <name>-<platform> convention. It is the app author's actionable signal, so it is a warning now. The README write was unguarded while its three siblings (licence, changelog, icon) were not, so a transient README failure persisted the repo's one-line GitHub description over the good cached README. One rate-limited refresh permanently degraded every affected detail page to a single sentence, and the only way back was another successful fetch the user could not trigger while still rate-limited. The in-memory description still falls back so the card has text; the disk cache no longer does. Finally, the detail page told two different stories with one label. A manifest declaring platforms, none of them yours, genuinely has no build for you. A manifest declaring none at all means Colony could not match the repo's assets to any convention, which reads as "Colony is broken" when it says only "not available for your platform".
The docs had drifted far enough to send bug reporters somewhere real code never
writes. Every cache and config path in architecture.md and faq.md pointed at
~/.cache/colony/ and ~/.config/colony/, while the code has always used
~/.config/Colony/Colony/{cache,repo-docs,repo-icons,preferences,auth}. The
source-tree map still described update.rs, github.rs and i18n.rs as single
files; they became directories in 430f238. The test count said 105; it is 131.
Also scopes the default log filter. A bare "info" let iced_winit and wgpu print
a pretty-printed WindowAttributes and a full adapter dump on every launch, which
drowned Colony's own lines in the very file a bug report attaches - measured at
5.8 KB of which almost none was ours. The default is now "warn,colony=info", so
their warnings still come through; RUST_LOG overrides it verbatim as before.
~/.cache/colony/ is now genuinely where diagnostics live, which is what
CONTRIBUTING and the FAQ were already telling people.
…lease
Three parts of one problem: the trust chain stopped at the launcher.
Rotation was not expressible. src/signing.rs embedded exactly one public key, so
the single <asset>.sig a release carries is either old-key (refused by every
updated client) or new-key (refused by every client in the field) - and
verification is fail-closed, so the refusal is permanent. The procedure in
docs/release-signing.md instructed both at once and could not be carried out in
either direction: a planned rotation stranded the install base, and an emergency
one after a leak left the defender with nothing anyone could verify while the
attacker held a key every client trusts.
RELEASE_PUBLIC_KEYS is now a list and a signature is accepted if any listed key
validates it, which makes rotation a real three-release sequence: ship N
embedding [new, old] but SIGNED with old (everyone accepts it, and afterwards
trusts both), sign N+1 with new, drop old in N+2. The docs now describe that
sequence, including why N must be signed with the outgoing key and what skipping
to N+2 costs. A test asserts any listed key verifies while an unlisted one never
does, and that the shipped list is still length 1 - so starting a rotation means
deliberately updating that assertion.
App assets got the signed metadata sidecar. Their entire trust check was a
raw-bytes ed25519 verification: proof the bytes came from the org key, but not
which artefact or which version they are. For an app with "signed": true - the
configuration users are told is the strong one - a compromised maintainer could
take a genuinely signed binary from an old, known-vulnerable release, re-upload
it under a new tag, and Colony would offer it as an update and Update All would
install it in one click, every indicator green. The .meta machinery already
existed and was reachable only from the launcher's own self-update.
download_release_asset now fetches {url}.meta and {url}.meta.sig alongside the
signature and runs the same bindings: asset name, digest, and the tag it
resolved. The version rule differs by caller, so it is split out - the launcher
keeps "strictly newer than the running build", apps get ">= what is installed",
because an app pinned to a fixed tag must stay reinstallable. Sidecars are
opportunistic and pinned once seen, the same one-way ratchet as .sig, so the org
can roll them out repo by repo without breaking anything and no repo can quietly
stop.
Finally, the release template Colony hands to app authors produced unsigned
releases with floating action refs, while the spec tells those same authors to
set "signed": true - which fail-closes their own installs. It now carries a sign
job modelled on Colony's own (single ASSETS source of truth, fail-if-secret-
missing, verify-before-upload, chained as needs: because a release-please
release does not fire release events), and every action is pinned to a commit
SHA - checked against upstream - since a floating tag on a workflow that holds
the org signing key is a supply-chain hole.
The product page could not answer the three questions every app store answers. The API returns all of them and Colony discarded two: Asset kept only `name`, so `size` was thrown away and the download total was learned from Content-Length once the transfer had already started; Release kept no `published_at`; and the tag being offered was rendered nowhere at all, only the installed one. Size and date now ride along with the release-notes fetch, so they cost no extra request per card, and the tag comes from the manifest for free. Release notes were unreachable unless an update was already pending. The whole panel lived inside `if let Some(new_tag) = self.available_updates.get(...)`, so a user evaluating an app, or checking what changed in the version they run, got nothing - and `fetch_release_notes` already had a fallback to the manifest's pinned tag that nothing could call. The Changelog tab is not a substitute: it reads a cached CHANGELOG.md many repos do not have. The grid advertised "Get" in accent colour for apps with no build for this platform, right next to the chips saying which platforms they DO support. The user clicked in expecting to install and hit the detail page's dead end. It now reads "Not available", dimmed. The catalog kept GitHub's push order, so the store reshuffled between sessions for reasons invisible to the user - a README typo fix jumped a repo to the top - and muscle memory never formed. Local apps were already sorted; the store is now sorted too, by display name. Which is the last one: the spec defines manifest `name` as "Display name in Colony", two of the eight published manifests deliberately set one that differs from the slug, and Colony used it only in the launch button - so a card titled "Lilypad-Vault" carried a button reading "Launch Lilypad", and the desktop menu got the slug. `display_name()` is now used for the card title, the detail header and the .desktop Name=, while `name` stays the identity key for install paths, caches, favorites and the entry filename, none of which may follow a string the repo can change at will.
Two of the eight manifests the org publishes are broken and nothing on either side of the ecosystem detects it: there is no schema, no lint, and no CI step anywhere. `colony validate-manifest [PATH] [ASSET...]` closes that. It checks the shape (unknown platform keys, a platform declared with no releaseFiles entry, neither file nor filePattern, both at once, a pinned sha256 against a moving "latest" tag, a non-PNG or non-relative icon) and, when given the asset names a release publishes, checks that every platform actually RESOLVES. That last part is the one that matters, because the failure that really happens is a manifest which parses perfectly and still leaves the app listed with no Download button. Run against the live org it reports exactly that for Eidos (assets named eidos-1.12.0-x86_64-linux.tar.gz match no convention), while confirming Grape auto-detects four platforms and SphereCord's glob-with- exclusions resolves to SphereCord-3.3.3.AppImage out of 30 real assets. It exits non-zero, so it works as a CI gate, and the release template now runs it before signing. Decentralised: each repo validates itself. The manifest's `signed` field was missing from the spec's field table entirely. Browsing also lied in three places: Search was section-scoped, and the shipped "All" section filters to `origin: colony` - which scanned apps can never be (entries Colony writes are skipped by the X-Colony-Managed bail; everything else is External or Windows). So typing "firefox" in the default view reported zero results on a machine where Firefox sat two clicks away. A search that lies about zero results is worse than no search. It is global now; Favorites stays scoped, being a chosen set rather than a browsing filter. The Windows and Linux sections consulted only the category filter, which is None for them, so both listed the ENTIRE store next to the locally scanned apps - on Linux, clicking "Windows" showed linux-only repos. They now filter the store half by the manifest's own platform data. And every installed app was filed under `Categories=Utility;` in the desktop menu regardless of what its manifest declared, so a music player and two games landed in the same GNOME/KDE section. The manifest category now maps to the freedesktop main categories (Security, which has no main category, is correctly paired as `Utility;Security;`).
Preferences could destroy themselves. Every state writer used a bare fs::write, so a crash or OOM mid-write left truncated JSON; the loaders could not tell that apart from "no file", returned defaults, and the next save - triggered by something as innocuous as clicking a sidebar section - overwrote the corrupt file with them. Writes are atomic now (temp + rename, the same discipline the install path already used), and a file that fails to parse is moved aside to <name>.corrupt with a warning instead of being clobbered. auto_accent had a working toggle, applied at boot, that was simply never written, so it reset on every restart. It is saved now, and save_preferences is an exhaustive struct literal with no `..` on purpose: the compiler is what catches the next forgotten preference. The three keys that were hardwired to None on every save (close_behavior, update_channel, auto_install_updates) were read by nothing and are gone. The declared MSRV was 1.80 and could never have built - 70 transitive dependencies require more, with iced, zip, image and wgpu at 1.88 - and nothing checked, because CI only ran on stable. It is 1.88 now (verified against the resolved graph), and CI has a leg that builds on whatever Cargo.toml declares, so it cannot drift again. CI also gained clippy --all-targets, which had never seen the tests, and an advisory-only RustSec scan. The .desktop reader took the LAST occurrence of Exec, Icon, Categories, NoDisplay and Hidden while glib takes the FIRST - the rule Colony's own writer documents and depends on. Only Name had the guard, so a duplicated key made Colony read a different entry than the desktop environment runs. Fixed, with the first test parse_desktop_file has ever had. 52 locale keys (104 entries across the two locales) were dead: settings never built, a replaced welcome carousel, placeholders for features that never shipped. Deleted, with a test that reads the source and fails on any key nothing looks up. cached_get returned a RateLimitInfo that all six callers discarded, so it read like a supported channel while being dead weight; it returns the body now and the type is private to the HTTP layer. The blanket dead_code allow over the 38-method Palette impl is narrowed to the two accessors that are genuinely unused, and the stale one on Message::DownloadProgress - which is both constructed and matched - is gone. Docs: the theme counts said 24 families where there are 25, and the Stellar Blade family (five variants) was missing from the README's table entirely; both are fixed and a test now fails if the numbers drift. Security was missing from three user-facing category lists. The Linux build-dependency list named libasound2-dev and libglib2.0-dev, which nothing in the crate graph needs and CI does not install. Two audit findings did NOT survive checking and are deliberately not "fixed": the claim that the shipped binary contains OpenSSL (cargo tree --target all -i openssl-sys finds nothing), and the claim that a whole theme family was undocumented AND the counts merely stale - the counts were wrong by one, which is what the test now pins.
…ecrets release-please's own step published the release - and moved /releases/latest - before a single binary existed. For the whole build+sign window every running Colony showed the sidebar update badge and every click on it failed: a missing asset first, then a fail-closed signature refusal once build had uploaded but sign had not. The README's "download from the latest release" pointed at a release with zero assets over the same window, and if any build leg failed that state was permanent until someone noticed. The release is now held as a draft immediately after creation and published by the sign job, after it has verified every asset, signature and sidecar is really there. Done with one gh call rather than a release-please config file: the config-file route means switching the action to manifest mode, which changes the outputs contract that build, sign and aur all depend on and that nine releases have proven. This narrows the window from minutes to the second between two steps, with no risk to version resolution. Nothing ever ran a release artefact. The signature checks prove provenance and say nothing about whether the binary starts, so an asset built for the wrong architecture, or one that aborts under the release profile's LTO, would be signed, verified, published and hashed into the AUR PKGBUILD with every check green. Now that --version exists, each native leg runs it; the Intel macOS asset is cross-compiled on an arm64 runner and cannot be executed there, so its architecture is asserted with `file` instead - that being the leg with the fewest users and therefore the one where a regression survives longest. The AUR job was called with `secrets: inherit`, which handed it COLONY_SIGNING_KEY_PEM along with the one secret it needs. That is the single secret whose compromise is unrecoverable: every install in the field trusts the key it signs with. The reusable workflow now declares AUR_SSH_PRIVATE_KEY explicitly and receives only that. Its ssh-keyscan was also trust-on-first-use on every run; the host key is pinned and cross-checked, and the release stops if it disagrees. (The pinned key was verified against the live host, not written from memory - the value I first reached for was wrong and would have broken the publish. Fingerprint SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4.) Docs: the FAQ told users to cancel a download with Esc, which nothing binds, and blamed startup crashes on GTK and xdo libraries that are not in the dependency graph at all. The tutorial's "Colony retries automatically" was false when written and is now true, so it says what actually happens. release-signing.md was in neither documentation index. The bug template still suggested v0.1.4 as the version placeholder at release 0.9.2. The categories.json / colony.toml override search order was implemented and documented nowhere a user would look, so the FAQ now explains it. The README's macOS install skipped the Gatekeeper quarantine step the tutorial documents correctly. And the keyboard reference described bindings that predate grid navigation.
… copy Colony carried its own copy of the ecosystem release-workflow template, and Project-Colony-Resources carries the canonical one next to the shared signing script and the manifest schema. Two hand-maintained copies of the same file is what produced the bug found today: the Resources template named a secret (COLONY_SIGNING_KEY) that no repository actually has, and skipped signing on Windows entirely - so a program following it shipped an unsigned .exe that Colony then refused, because the manifest said "signed": true. Neither mistake was visible from either side. Colony's copy becomes a pointer explaining where the real one lives, why it is a pointer, and the four things an app author needs. README, CONTRIBUTING, docs.md and colony-spec.md now link to the canonical file. Colony's own release-please.yml is deliberately NOT the template and stays separate: it self-updates, so its signature verification is mandatory rather than opt-in.
All four failures on the first run were introduced by this branch, and none were visible from a Linux-only local check. The MSRV job read the version with `cut -d'\"'`. Inside single quotes the shell passes two characters to `cut -d`, which takes exactly one, so the command errored, the output was empty, and the toolchain action failed to parse an empty toolchain. Read with `tr -d ' "'` instead, and fail loudly if the result is empty rather than handing an empty string downstream. Clippy failed on Windows and macOS: `AppCategory::desktop_categories` is consumed only by `write_desktop_entry`, which is `cfg(target_os = "linux")`, so the method is genuinely dead in a non-Linux binary. Allowed narrowly there rather than moving the whole mapping behind a cfg - it is platform-neutral data and its test runs everywhere. The advisory job gated the PR, which is the opposite of what its own comment promised. rustsec/audit-check fails the check by default; `continue-on-error` makes it report as intended. This matters beyond tidiness: quick-xml (RUSTSEC-2026-0194/0195) is held at 0.38 by wayland-scanner via winit and iced and cannot be updated from here at all, and it is reached only as a build-time proc-macro parsing the system's own Wayland protocol XML - never untrusted input. Gating on it would block every unrelated fix until iced moves. That assessment is now written in the workflow so nobody has to re-derive it. h2 was genuinely affected (RUSTSEC-2026-0258, unbounded empty DATA frames) and reachable through reqwest, so the lockfile moves 0.4.13 -> 0.4.19. quick-xml stays, for the reason above.
…rever continue-on-error stopped the workflow failing, but the job itself still reported failure, so the check sat red on every pull request permanently. A check that is always red is a check nobody reads - which is worse than not having it, because it also teaches people to skim past the ones that matter. Ignore the two advisories that genuinely cannot be acted on from this repository, by id and with the reason recorded, and let everything else fail the build. RUSTSEC-2026-0194 and RUSTSEC-2026-0195 are quick-xml, held at 0.38 by wayland-scanner through winit and iced 0.14; both concern parsing untrusted XML, and wayland-scanner is a build-time proc-macro reading the system's own protocol descriptions, so neither is reachable at runtime. The comment says when to remove them. Now red means something new and worth looking at.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Colony 0.9.2 had no written TODO: no open issues, no
TODO/FIXMEin the code,119 green tests. This is the backlog reconstructed by audit, and the work that
closed it.
An audit across nine dimensions produced 135 findings that survived adversarial
verification (3 were refuted). None were p0 — the core is genuinely well built.
The most damaging one was not a crash:
What is in here
Failures become visible. The status line rendered on one of four pages;
RUST_LOG=debugwas silently overridden and no log file existed anywhere; thebug template requires
colony --version, which did not exist. Toasts neverexpired under reduce-motion and grew past clicking range — the accessibility
settings were the ones that silted the UI up.
The install survives reality. The 300 s timeout was a total deadline, so
below ~1.2 Mbit/s no asset could ever finish. Transfers now resume (Range plus
an identity sidecar, bounded retry), because every failure path used to delete
the partial file — a drop at 95% cost the whole download. Replacing a running
binary works on Windows now; Colony already knew the trick and applied it only
to itself.
Remote strings stop being structural. A
tagfromcolony.jsonwasinterpolated into the release URL, and the WHATWG parser collapses
..beforethe request is sent — one line of a catalog repo could redirect an install to an
account outside the org. URLs are built from percent-encoded segments now, with
a test that replays the attack.
The trust chain reaches the apps. The release key is a list, so rotation is
expressible at all. App assets are bound to a version and an asset name by the
signed
.metasidecar — opportunistic, then pinned, so the org can adopt it onerepo at a time.
colony validate-manifestcatches the manifests that parseperfectly and still leave an app uninstallable.
Platforms stop overpromising.
keyringwas built without its macOS andWindows backends, so on those platforms the token was written to an in-memory
mock and lost on every restart while the log said "saved to OS keychain". The
README now says Linux supported, Windows/macOS best-effort, with the gaps named.
Verification
cargo fmt --checkclean,cargo clippy --all-targets -- -D warningsclean,142 tests (up from 119). Resumable downloads are covered end to end against
a Range server that truncates its first response. Every workflow parses; the
five action SHAs added were checked against upstream.
Two audit findings did not survive checking and are deliberately not
"fixed": that the shipped binary contains OpenSSL (
cargo tree --target all -i openssl-sysfinds nothing), and that the theme counts were merely stale — theywere wrong by exactly one, which a test now pins.
Commits
Squash-merging collapses these; the map is kept here.
fix(download): use a read timeout instead of a 300 s total deadlinefix(update): never report apps as up to date when the check did not runfix(ui): make failures visible - status line everywhere, logs, and retryfix(security): validate remote strings before they reach URLs and pathsfeat(github): persist the conditional-request cache across launchesfeat(download): resume interrupted transfersfix(platform): replace running binaries safely, and stop overpromising Windows/macOSfix(update): report every failure the user triggeredfix(github): distinguish a broken manifest from a transient failuredocs: correct every path, count and claim the code had outgrownfeat(signing): make the key rotatable and bind app assets to their releasefeat(store): say what is on offer before the user commits to itfeat(manifest): validate colony.json, and make browsing tell the truthchore: pay down the debt the audit found, and guard it with testsci(release): close the unsigned window, smoke-test artefacts, scope secretsdocs: point at the one canonical release template instead of a second copyThe ecosystem-side rollout (the
.metasidecar and the signing job in the six app repos) is not in this PR: the canonical template and the migration procedure now live in Project-Colony-Resources.