Compare commits
No commits in common. "master" and "worktree-add-claude-code-package" have entirely different histories.
master
...
worktree-a
29 changed files with 342 additions and 3149 deletions
|
|
@ -110,10 +110,9 @@ jobs:
|
||||||
command -v cargo-llvm-cov >/dev/null 2>&1 || cargo install cargo-llvm-cov --locked
|
command -v cargo-llvm-cov >/dev/null 2>&1 || cargo install cargo-llvm-cov --locked
|
||||||
|
|
||||||
# Reports coverage only — no --fail-under-lines yet. main.rs is
|
# Reports coverage only — no --fail-under-lines yet. main.rs is
|
||||||
# excluded: thin argv dispatch exercised by the real end-to-end
|
# excluded: thin orchestration glue exercised by the real end-to-end
|
||||||
# `cargo run`, not unit tests, so it's not a meaningful signal here.
|
# `cargo run` against live GitHub, not unit tests, so it's not a
|
||||||
# See Makefile.toml > coverage-report for why pipeline.rs, despite
|
# meaningful signal here. See Makefile.toml > coverage-report.
|
||||||
# being mostly untestable I/O orchestration too, stays included.
|
|
||||||
- name: Coverage
|
- name: Coverage
|
||||||
run: cargo llvm-cov --ignore-filename-regex 'main\.rs' --summary-only
|
run: cargo llvm-cov --ignore-filename-regex 'main\.rs' --summary-only
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,9 @@ args = ["test"]
|
||||||
# same-named custom one.
|
# same-named custom one.
|
||||||
#
|
#
|
||||||
# Reports coverage only; not gated on a threshold yet — main.rs is thin
|
# Reports coverage only; not gated on a threshold yet — main.rs is thin
|
||||||
# argv dispatch exercised by the real end-to-end `cargo run`, not unit
|
# orchestration glue exercised by the real end-to-end `cargo run`, not unit
|
||||||
# tests, so it's excluded here rather than dragging the number down for
|
# tests, so it's excluded here rather than dragging the number down for
|
||||||
# reasons unrelated to test quality. pipeline.rs is deliberately NOT
|
# reasons unrelated to test quality.
|
||||||
# excluded even though it's mostly network/subprocess/filesystem
|
|
||||||
# orchestration too (hence its own low number) — its one pure decision
|
|
||||||
# function (decide_tier_action) is unit tested and should stay visible in
|
|
||||||
# this report; excluding the whole file would hide that signal along with
|
|
||||||
# the untested parts. See docs/ARCHITECTURE.md > "separate pure decision logic
|
|
||||||
# from I/O."
|
|
||||||
[tasks.coverage-report]
|
[tasks.coverage-report]
|
||||||
command = "cargo"
|
command = "cargo"
|
||||||
args = ["llvm-cov", "--ignore-filename-regex", "main\\.rs", "--summary-only"]
|
args = ["llvm-cov", "--ignore-filename-regex", "main\\.rs", "--summary-only"]
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
|
|
||||||
Status: design draft, pre-PoC. Captures the design discussion as of 2026-09-11.
|
Status: design draft, pre-PoC. Captures the design discussion as of 2026-09-11.
|
||||||
|
|
||||||
This is the *product* design — what pkgwatch does and why. For how the
|
|
||||||
code implementing it is organized (module boundaries, testability
|
|
||||||
conventions, what CI does and doesn't enforce), see `ARCHITECTURE.md`.
|
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Software not packaged by the distro (Arch/Manjaro here) usually gets installed
|
Software not packaged by the distro (Arch/Manjaro here) usually gets installed
|
||||||
|
|
@ -246,12 +242,6 @@ asset_pattern = "otherpkg-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
method = "same-origin-sha256"
|
method = "same-origin-sha256"
|
||||||
checksum_asset_pattern = "otherpkg-x86_64-unknown-linux-gnu.tar.gz.sha256"
|
checksum_asset_pattern = "otherpkg-x86_64-unknown-linux-gnu.tar.gz.sha256"
|
||||||
|
|
||||||
# binary_name: only needed when the installed binary's name differs from
|
|
||||||
# the pacman package name — e.g. real-world case, scaleway-cli's package
|
|
||||||
# is named scaleway-cli but its actual binary is `scw` (see
|
|
||||||
# packages.d/scaleway-cli.toml). Defaults to the package name.
|
|
||||||
binary_name = "otherbin"
|
|
||||||
|
|
||||||
# Tier 1 example — not yet implemented in the PoC (only
|
# Tier 1 example — not yet implemented in the PoC (only
|
||||||
# same-origin-sha256 and github-attestation exist so far):
|
# same-origin-sha256 and github-attestation exist so far):
|
||||||
[package.somepkg]
|
[package.somepkg]
|
||||||
|
|
@ -287,40 +277,17 @@ implements `repo`, `asset_pattern`, and `verification.method`
|
||||||
matches the line by filename instead of assuming a single-hash file.
|
matches the line by filename instead of assuming a single-hash file.
|
||||||
- Separately, scaleway-cli's Atom feed lists a `vX.Y.Z-dbg1` tag newest,
|
- Separately, scaleway-cli's Atom feed lists a `vX.Y.Z-dbg1` tag newest,
|
||||||
with no real Release object behind it (`releases/tags/<tag>` 404s) —
|
with no real Release object behind it (`releases/tags/<tag>` 404s) —
|
||||||
the GitHub source's `latest_release` now confirms each feed candidate
|
`checker::latest_github_release` now confirms each feed candidate
|
||||||
against the releases API in feed order rather than trusting the first
|
against the releases API in feed order rather than trusting the first
|
||||||
entry outright.
|
entry outright.
|
||||||
|
|
||||||
`sanity_check` and `binary_name` are now real, implemented fields (see
|
Tier 4-6 packages (scaleway-cli included) are not auto-published — see
|
||||||
Builder/Sanity checker above) — added `packages.d/uv.toml`'s and
|
Build/publish/review-queue below. `source`, `check_method`,
|
||||||
`packages.d/scaleway-cli.toml`'s own `sanity_check` blocks, and
|
`check_interval`, and `sanity_check` are still schema sketch, not yet read
|
||||||
scaleway-cli's `binary_name = "scw"`. `source` is implemented too, with
|
by the code — the PoC only knows how to check GitHub-release sources.
|
||||||
two values: `github-release` (the default when omitted, so existing
|
|
||||||
configs are unchanged) and `forgejo-release`, which also requires a
|
|
||||||
`base_url` (see Checker below). `check_method` and `check_interval` are
|
|
||||||
still schema sketch, not yet read by the code — checks are a fixed hourly
|
|
||||||
tick, not per-package.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# A package released from a Forgejo instance instead of GitHub. Only
|
|
||||||
# `same-origin-sha256` is valid here: `github-attestation` needs GitHub.
|
|
||||||
[package.mytool]
|
|
||||||
source = "forgejo-release"
|
|
||||||
base_url = "https://code.austinschaefer.com"
|
|
||||||
repo = "schaefera/mytool"
|
|
||||||
asset_pattern = "mytool-linux-x86_64.tar.gz"
|
|
||||||
|
|
||||||
[package.mytool.verification]
|
|
||||||
method = "same-origin-sha256"
|
|
||||||
checksum_asset_pattern = "SHA256SUMS"
|
|
||||||
```
|
|
||||||
|
|
||||||
Build/publish/review-queue (`makepkg`, `repo-add`, tier 4–6 human review)
|
Build/publish/review-queue (`makepkg`, `repo-add`, tier 4–6 human review)
|
||||||
are now implemented too — see Builder/Sanity checker/Publisher/Reviewer
|
are not implemented yet; a tier 4–6 pass currently just logs "flagging for
|
||||||
queue above and the Status entry below for the first full end-to-end run.
|
review" and stops.
|
||||||
Tier 4-6 packages still don't auto-publish (by design, see Verification
|
|
||||||
trust tiers > Automation posture per tier); they queue for
|
|
||||||
`pkgwatch review <name> --approve`.
|
|
||||||
|
|
||||||
Open questions on the schema:
|
Open questions on the schema:
|
||||||
|
|
||||||
|
|
@ -346,62 +313,19 @@ Open questions on the schema:
|
||||||
|
|
||||||
- **Config loader**: parses `packages.d/*.toml` into an in-memory package
|
- **Config loader**: parses `packages.d/*.toml` into an in-memory package
|
||||||
list. *(Implemented — `src/config.rs`.)*
|
list. *(Implemented — `src/config.rs`.)*
|
||||||
- **Paths**: where config, state and work files live, resolved by
|
|
||||||
`src/paths.rs` per the XDG base-directory spec rather than the current
|
|
||||||
working directory, so an installed binary behaves the same wherever it's
|
|
||||||
launched from. *(Implemented.)*
|
|
||||||
|
|
||||||
| What | Default | XDG variable | Override |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Package declarations (`packages.d/*.toml`) | `~/.config/pkgwatch/packages.d` | `XDG_CONFIG_HOME` | `PKGWATCH_CONFIG_DIR` (the dir *containing* `packages.d`) |
|
|
||||||
| Last-published / pending versions | `~/.local/state/pkgwatch` | `XDG_STATE_HOME` | `PKGWATCH_STATE_DIR` |
|
|
||||||
| Downloads and build trees (safe to delete) | `~/.cache/pkgwatch` | `XDG_CACHE_HOME` | `PKGWATCH_WORK_DIR` |
|
|
||||||
|
|
||||||
Precedence per directory: override, then the XDG variable, then the
|
|
||||||
default under `$HOME`; an empty variable counts as unset. The overrides
|
|
||||||
are used verbatim (no `pkgwatch/` suffix) and exist for dry runs against
|
|
||||||
scratch directories, like `PKGWATCH_REPO_DIR` does for the pacman repo.
|
|
||||||
The XDG variables and `$HOME` must be absolute paths: a relative XDG
|
|
||||||
value is ignored, as the XDG spec requires, and a relative `$HOME` is an
|
|
||||||
error.
|
|
||||||
|
|
||||||
The checkout's `packages.d/` is no longer read on its own; it's just the
|
|
||||||
source to link from. Migrating from the old cwd-relative layout: move
|
|
||||||
`state/` to the state dir and copy or symlink `packages.d/` into the
|
|
||||||
config dir; `work/` is cache and can simply be dropped.
|
|
||||||
- **Checker**: per source type, resolves "what's the latest version" —
|
- **Checker**: per source type, resolves "what's the latest version" —
|
||||||
likely reuses `nvchecker`'s logic/sources conceptually for non-GitHub
|
likely reuses `nvchecker`'s logic/sources conceptually for non-GitHub
|
||||||
sources eventually. For GitHub sources, prefers the `github-atom` feed
|
sources eventually. For GitHub sources, prefers the `github-atom` feed
|
||||||
(see Scaling > Check method) over unconditional REST polling.
|
(see Scaling > Check method) over unconditional REST polling.
|
||||||
*(Implemented for GitHub and Forgejo. `src/release_source/` defines the
|
*(Implemented for GitHub only — `src/checker.rs` regex-matches the first
|
||||||
`ReleaseSource` trait (latest release + API root); each host implements
|
`releases/tag/<tag>` link in the feed rather than doing a full XML parse;
|
||||||
it in its own file (`github.rs`, `forgejo.rs`), and the module's
|
fine while the feed's newest-entry-first shape holds, revisit if that
|
||||||
`for_package` picks one per package, so adding a host doesn't touch
|
ever changes. `check_interval`/per-package cadence not wired up yet —
|
||||||
existing ones. The rest of the crate imports the trait and hosts from
|
the PoC is a single one-shot run, not a scheduled loop.)*
|
||||||
`crate::release_source`, which re-exports them. A trait
|
|
||||||
rather than an enum match because there are now two real hosts with
|
|
||||||
genuinely different logic. GitHub regex-matches the first
|
|
||||||
`releases/tag/<tag>` link in the feed rather than doing a full XML
|
|
||||||
parse; fine while the feed's newest-entry-first shape holds, revisit if
|
|
||||||
that ever changes. Forgejo is one call to
|
|
||||||
`<base_url>/api/v1/repos/<repo>/releases/latest`, which already returns
|
|
||||||
only the newest non-draft, non-prerelease release, so it needs none of
|
|
||||||
GitHub's confirm-each-tag step. `check_interval`/per-package cadence not
|
|
||||||
wired up yet — checks are a fixed hourly tick.)*
|
|
||||||
|
|
||||||
The HTTP is hand-rolled on the `reqwest` already in the tree, not an
|
|
||||||
API-client crate: pkgwatch needs two `GET`s, GitHub's check deliberately
|
|
||||||
uses an Atom feed no API crate covers (to stay off the rate-limited REST
|
|
||||||
API), and the release-by-tag call is shared verbatim by both hosts.
|
|
||||||
`octocrab` is async against our blocking `reqwest` with a default tree
|
|
||||||
larger than pkgwatch's whole current one; `forgejo-api` is a generated
|
|
||||||
binding of the entire API for one endpoint. Revisit if pkgwatch needs
|
|
||||||
authenticated or write API calls.
|
|
||||||
- **Fetcher**: downloads the artifact (and any checksum/signature/
|
- **Fetcher**: downloads the artifact (and any checksum/signature/
|
||||||
attestation companion) for a resolved version. *(Implemented —
|
attestation companion) for a resolved version. *(Implemented —
|
||||||
`src/fetcher.rs`, via the GitHub or Forgejo releases API — same
|
`src/fetcher.rs`, via the GitHub releases API; exact asset-name match,
|
||||||
`releases/tags/<tag>` endpoint and JSON shape on both; exact asset-name
|
not a glob.)*
|
||||||
match, not a glob.)*
|
|
||||||
- **Verifier**: tier-specific verification implementations, dispatched via
|
- **Verifier**: tier-specific verification implementations, dispatched via
|
||||||
a `Verification` enum matched on `method` (an internally-tagged serde
|
a `Verification` enum matched on `method` (an internally-tagged serde
|
||||||
enum) rather than a trait — simpler while there are only two methods;
|
enum) rather than a trait — simpler while there are only two methods;
|
||||||
|
|
@ -410,85 +334,25 @@ Open questions on the schema:
|
||||||
separately. *(Implemented for `same-origin-sha256` and
|
separately. *(Implemented for `same-origin-sha256` and
|
||||||
`github-attestation` — `src/verifier.rs`. The latter shells out to `gh
|
`github-attestation` — `src/verifier.rs`. The latter shells out to `gh
|
||||||
attestation verify` rather than reimplementing sigstore verification.)*
|
attestation verify` rather than reimplementing sigstore verification.)*
|
||||||
- **Builder**: for tiers 1–3 on pass, generates a PKGBUILD (strict
|
- **Builder**: for tiers 1–3 on pass, generates/updates the PKGBUILD
|
||||||
validation on every upstream-controlled string — version, asset name,
|
(strict validation on any upstream-controlled string — version, filename
|
||||||
download URL — before it touches generated shell content; most fields
|
— before it touches generated shell content; never unescaped
|
||||||
are single-quoted, but the `install()` line necessarily uses double
|
interpolation) and runs `makepkg`.
|
||||||
quotes so `${srcdir}`/`${pkgdir}` expand, so the validation rejects `'`,
|
|
||||||
newline, `$`, backtick, *and* backslash — safe for either quoting style
|
|
||||||
rather than assuming a value only ever lands in one of them — never
|
|
||||||
unescaped interpolation) and runs `makepkg`. *(Implemented —
|
|
||||||
`src/builder.rs`. One fixed "prebuilt binary" PKGBUILD shape covers both
|
|
||||||
tracked packages so far: a bare-binary download (scaleway-cli) and a
|
|
||||||
tarball extracting to a same-named directory (uv) — see Scaling >
|
|
||||||
Template reuse. `Package.binary_name` (config.rs) covers the case where
|
|
||||||
the installed binary's name differs from the pacman package name, which
|
|
||||||
turned out to matter immediately: scaleway-cli's real binary is `scw`,
|
|
||||||
not `scaleway-cli` — confirmed by inspecting the currently-installed
|
|
||||||
`extra` package with `pacman -Ql`, not guessable from the repo name.
|
|
||||||
Without it the build would install alongside `extra`'s package instead
|
|
||||||
of shadowing it.)*
|
|
||||||
- **Sanity checker**: after a successful build, runs the package's
|
- **Sanity checker**: after a successful build, runs the package's
|
||||||
declared `sanity_check.command` — with the freshly built package's
|
declared `sanity_check.command` against the built artifact and confirms
|
||||||
`usr/bin` prepended to `PATH`, so it exercises what was just built
|
the reported version matches what pkgwatch believes it just built.
|
||||||
rather than whatever's already installed system-wide — and confirms the
|
Mismatch = fail loud, do not publish. This is a correctness check, not a
|
||||||
reported version matches what pkgwatch believes it just built. Mismatch
|
security control — it catches checker bugs and mangled/wrong-artifact
|
||||||
= fail loud, do not publish. This is a correctness check, not a security
|
downloads, not malicious releases.
|
||||||
control — it catches checker bugs and mangled/wrong-artifact downloads,
|
- **Publisher**: runs `repo-add` against the local repo, only after the
|
||||||
not malicious releases. *(Implemented — `src/sanity.rs`.)*
|
sanity check passes.
|
||||||
- **Publisher**: copies the built package into the local repo directory
|
|
||||||
and runs `repo-add`, only after the sanity check passes. *(Implemented —
|
|
||||||
`src/publisher.rs`. Targets an existing, already-registered local pacman
|
|
||||||
repo rather than one pkgwatch creates — this box already has one at
|
|
||||||
`~/.local/share/pacman/custom`, registered as `[custom]` in
|
|
||||||
`/etc/pacman.conf` (`SigLevel = Optional TrustAll`) and already in use
|
|
||||||
for a hand-packaged AppImage. But that repo directory/registration isn't
|
|
||||||
guaranteed to exist on every box this ever runs on, so it isn't just
|
|
||||||
assumed: `publisher::ensure_registered` checks `/etc/pacman.conf` for an
|
|
||||||
active `[<repo_name>]` section before a build even starts, failing fast
|
|
||||||
with the exact snippet to add if it's missing, rather than wasting a
|
|
||||||
`makepkg` build on a repo pacman will never sync from. The repo
|
|
||||||
*directory* and its database file, by contrast, are fully self-healing —
|
|
||||||
`publish` creates the directory if missing and `repo-add` creates the
|
|
||||||
database on its first run. What's deliberately not automatic, and can't
|
|
||||||
safely be: writing the `[section]` into `/etc/pacman.conf` itself — that
|
|
||||||
needs root, which this process doesn't have and shouldn't grab for
|
|
||||||
itself. Similarly, publish deliberately stops at `repo-add`: getting the
|
|
||||||
new version onto the running system is a separate, deliberate
|
|
||||||
`pacman -Syu`/`pacman -S <pkg>` step left to the operator, not run
|
|
||||||
automatically.)*
|
|
||||||
- **Reviewer queue**: for tiers 4–6, records the detected change instead of
|
- **Reviewer queue**: for tiers 4–6, records the detected change instead of
|
||||||
auto-building; a separate `pkgwatch review` command lets a human
|
auto-building; a separate `pkgwatch review` command lets a human
|
||||||
approve/reject, which then triggers the build → sanity-check → publish
|
approve/reject, which then triggers the build → sanity-check → publish
|
||||||
steps above. *(Implemented — `state::{load,save,clear}_pending_version`
|
steps above.
|
||||||
plus the `review`/`review <name> --approve` subcommands in `src/main.rs`.
|
|
||||||
Tracked separately from the last-published-version state: approving one
|
|
||||||
release doesn't mean future ones auto-publish. `--approve` re-verifies
|
|
||||||
before building rather than trusting a possibly-stale flag from an
|
|
||||||
earlier run. No reject/dismiss command yet — see Status below.)*
|
|
||||||
- **Scheduling**: systemd `.service` (oneshot) + `.timer` running it
|
- **Scheduling**: systemd `.service` (oneshot) + `.timer` running it
|
||||||
periodically, matching the pattern already used for other periodic tasks
|
periodically, matching the pattern already used for other periodic tasks
|
||||||
on this box. *(Implemented — user-level units under `systemd/`, run 10s
|
on this box.
|
||||||
after login and then hourly, non-persistent. Install from the main
|
|
||||||
checkout:*
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo build --release && mkdir -p ~/.config/systemd/user &&
|
|
||||||
cp systemd/* ~/.config/systemd/user/ && systemctl --user daemon-reload &&
|
|
||||||
systemctl --user enable --now pkgwatch.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
*`pkgwatch.service` runs the release binary from the checkout and sets
|
|
||||||
no `WorkingDirectory`: config, state and work dirs come from the XDG
|
|
||||||
paths above, so the service needs `~/.config/pkgwatch/packages.d` set
|
|
||||||
up first — see the migration note under Paths.)*
|
|
||||||
- **Notifications**: `notifier.rs` sends a desktop notification
|
|
||||||
(`notify-send`) when a tier 4-6 release is newly queued for review or a
|
|
||||||
tier 1-3 release is published; both are best-effort and never fail a
|
|
||||||
run. A non-zero exit (verification/build/network failure) triggers
|
|
||||||
`pkgwatch-failure.service` via `OnFailure=`. Approving via
|
|
||||||
`pkgwatch review --approve` doesn't notify — the operator is already at
|
|
||||||
the terminal.
|
|
||||||
|
|
||||||
## Prior art / reference points
|
## Prior art / reference points
|
||||||
|
|
||||||
|
|
@ -526,39 +390,23 @@ Open questions on the schema:
|
||||||
confirmed correct — no build-provenance attestations upstream.
|
confirmed correct — no build-provenance attestations upstream.
|
||||||
Required adding `{version}`-placeholder support to `asset_pattern`/
|
Required adding `{version}`-placeholder support to `asset_pattern`/
|
||||||
`checksum_asset_pattern`, filename-matched parsing of combined
|
`checksum_asset_pattern`, filename-matched parsing of combined
|
||||||
multi-asset checksum files, and having the GitHub source's
|
multi-asset checksum files, and having `latest_github_release`
|
||||||
`latest_release` confirm each Atom-feed candidate against the releases API (this
|
confirm each Atom-feed candidate against the releases API (this
|
||||||
repo's newest feed entry, a `-dbg1` tag, has no real Release behind
|
repo's newest feed entry, a `-dbg1` tag, has no real Release behind
|
||||||
it). Still just flags for human review, same as any tier 4-6 pass —
|
it). Still just flags for human review, same as any tier 4-6 pass —
|
||||||
not auto-installed; see the unchecked build/publish item below.
|
not auto-installed; see the unchecked build/publish item below.
|
||||||
- [x] Full pipeline closed end to end for the first time: check → fetch →
|
- [ ] Not yet implemented: build (PKGBUILD generation + `makepkg`),
|
||||||
verify → build → sanity-check → publish, against two real packages.
|
publish (`repo-add`), reviewer queue for tier 4–6, scheduling/
|
||||||
`uv` (tier 2) auto-built and published on the first run with no
|
`check_interval`, non-GitHub sources, `minisign`/tier-1 method.
|
||||||
human step. `scaleway-cli` (tier 4) queued for review, then
|
- [ ] Refine config schema further (see open questions above), including
|
||||||
`pkgwatch review scaleway-cli --approve` re-verified, built, and
|
the `sanity_check` block per package.
|
||||||
published it — confirmed the built package installs as
|
|
||||||
`/usr/bin/scw`, actually shadowing `extra`'s package rather than
|
|
||||||
installing alongside it under the wrong name. Both landed in the
|
|
||||||
real `~/.local/share/pacman/custom` repo's database
|
|
||||||
(`custom.db.tar.gz`), ready for `sudo pacman -Syu`/`sudo pacman -S`
|
|
||||||
— not run automatically. See Builder/Sanity checker/Publisher/
|
|
||||||
Reviewer queue above for what each piece does.
|
|
||||||
One cosmetic wrinkle, not a correctness issue: `makepkg` printed
|
|
||||||
`libfakeroot internal error: payload not recognized!` while
|
|
||||||
packaging scaleway-cli's large Go binary, but still produced a
|
|
||||||
correct package (verified: exactly `usr/bin/scw` plus standard
|
|
||||||
metadata) — looks like an environment quirk in this sandbox's
|
|
||||||
fakeroot, not something pkgwatch caused; revisit if a real build
|
|
||||||
ever actually fails on it.
|
|
||||||
- [ ] Not yet implemented: `pkgwatch review <name> --reject` (a pending
|
|
||||||
review can only be approved or left pending, not dismissed),
|
|
||||||
per-package `check_interval` (the timer is a fixed hourly tick),
|
|
||||||
sources other than GitHub and Forgejo releases, `minisign`/tier-1
|
|
||||||
method, retention/pruning of old versions in the local repo (see
|
|
||||||
Scaling > Local repo retention), staggering/auth for GitHub API
|
|
||||||
rate limits at higher package counts.
|
|
||||||
- [ ] Refine config schema further (see open questions above).
|
|
||||||
- [ ] Decide version-check strategy for non-GitHub sources: shell out to
|
- [ ] Decide version-check strategy for non-GitHub sources: shell out to
|
||||||
`nvchecker` vs. own implementation.
|
`nvchecker` vs. own implementation.
|
||||||
|
- [ ] Implement PKGBUILD generation with strict upstream-string validation
|
||||||
|
from day one (see Builder, above) — cheap to do right up front,
|
||||||
|
expensive to retrofit.
|
||||||
|
- [ ] Next PoC iteration: carry the verified `uv` artifact through
|
||||||
|
build → sanity-check → `repo-add` publish, closing the loop to an
|
||||||
|
actual local pacman repo `pacman -Syu` can pick up.
|
||||||
- [ ] Decide on project home: local-only for now, or push to
|
- [ ] Decide on project home: local-only for now, or push to
|
||||||
code.austinschaefer.com (Forgejo) once the spec settles.
|
code.austinschaefer.com (Forgejo) once the spec settles.
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
# pkgwatch — code organization
|
|
||||||
|
|
||||||
Status: written 2026-09-17, once the build/publish pipeline PR gave this
|
|
||||||
project enough real code to have actual conventions worth writing down,
|
|
||||||
instead of guessing at them in advance.
|
|
||||||
|
|
||||||
This is distinct from `SPEC.md`, which is the product design (what
|
|
||||||
pkgwatch does and why). This file is about how the *code* implementing
|
|
||||||
that design is organized, so it stays readable as it grows past PoC size
|
|
||||||
instead of quietly accumulating debt. Researched against current industry
|
|
||||||
practice rather than asserted from habit — see Further reading.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
1. **One module, one job — and say what it is, up front.**
|
|
||||||
Ousterhout's "deep modules": the best modules expose a lot of
|
|
||||||
functionality through a simple interface, hiding the complexity behind
|
|
||||||
it. The two failure modes he names — *change amplification* (one
|
|
||||||
conceptual change forces edits in many places) and *obscurity* (a
|
|
||||||
reader can't tell where responsibility lives) — are both symptoms of
|
|
||||||
modules that don't have one clear job.
|
|
||||||
**Rule**: every `src/*.rs` file opens with a `//!` doc comment stating
|
|
||||||
its one responsibility in a sentence. If it can't be one sentence, the
|
|
||||||
module is doing too much.
|
|
||||||
**Example already here**: `builder.rs`'s job is "turn an
|
|
||||||
already-downloaded, already-verified artifact into a built package." It
|
|
||||||
hides PKGBUILD templating, upstream-string validation, and the
|
|
||||||
`makepkg` invocation behind one `build()` call — none of that leaks to
|
|
||||||
callers.
|
|
||||||
|
|
||||||
2. **Organize by pipeline stage (feature), not by technical layer.**
|
|
||||||
The package-by-feature vs. package-by-layer research is consistent:
|
|
||||||
feature-based grouping gives high cohesion within a module and low
|
|
||||||
coupling between modules; layer-based grouping (`models/`, `utils/`,
|
|
||||||
`helpers/`) tends toward the opposite, and a single feature change ends
|
|
||||||
up touching files scattered across every layer.
|
|
||||||
**Rule**: modules are named after what they do in the pipeline
|
|
||||||
(`release_source`, `fetcher`, `verifier`, `builder`, `sanity`,
|
|
||||||
`publisher`, `state`), not generic buckets. (`release_source` is a directory module: the
|
|
||||||
`ReleaseSource` trait and one file per host, re-exported from its
|
|
||||||
`mod.rs` so the rest of the crate never names a host's file.) A new pipeline stage gets a new module
|
|
||||||
named after the stage, not a method bolted onto an existing one.
|
|
||||||
**Anti-example to keep watching for**: a `utils.rs` grab-bag. `hash.rs`
|
|
||||||
could look like one but isn't — it exists for exactly one piece of
|
|
||||||
shared logic (`sha256_hex`) that two real stages (`verifier`,
|
|
||||||
`builder`) both need, not as a place to dump unrelated helpers.
|
|
||||||
|
|
||||||
3. **Separate pure decision logic from I/O ("functional core, imperative
|
|
||||||
shell").**
|
|
||||||
A function that decides *and* does in the same body can't be tested
|
|
||||||
without standing up everything the "does" half touches — often a
|
|
||||||
network call, a subprocess, or the filesystem. Pulling the decision out
|
|
||||||
into its own pure function makes it trivially unit-testable and makes
|
|
||||||
the I/O half thin enough that it obviously matches the decision.
|
|
||||||
**Applied this PR**: `pipeline::process_package`'s tier dispatch
|
|
||||||
(publish now / still pending / newly pending / verification failed) was
|
|
||||||
originally inline in a function that also made the real network and
|
|
||||||
build calls. Pulled out into `decide_tier_action`, a pure function with
|
|
||||||
its own unit tests covering all four outcomes, no I/O involved.
|
|
||||||
|
|
||||||
4. **Every network, subprocess, filesystem-root, or environment boundary
|
|
||||||
is injectable.**
|
|
||||||
Same testability goal as #3, applied to the specific ways this program
|
|
||||||
reaches outside itself. A consistent shape beats ad hoc mocking invented
|
|
||||||
per call site.
|
|
||||||
**Already in force**: `GithubEndpoints`/`ForgejoEndpoints` (the
|
|
||||||
`ReleaseSource` implementations, whose API root is what fetcher/verifier
|
|
||||||
take), `repo_add_bin` and the pacman.conf path (publisher), `PKGWATCH_REPO_DIR`
|
|
||||||
(main, for manual dry runs against a scratch repo instead of the real
|
|
||||||
one). A new external call follows the same shape: production code calls
|
|
||||||
a thin wrapper with the real default; tests call the parameterized
|
|
||||||
version with a fake.
|
|
||||||
|
|
||||||
5. **`main.rs` is a dispatcher, not the program.**
|
|
||||||
Found by looking at this project's own `main.rs`: it grew to 278 lines
|
|
||||||
and zero tests over the course of one PR, because "it's just the entry
|
|
||||||
point" is an easy excuse to skip separating logic from wiring — even
|
|
||||||
though Rust doesn't actually stop you from unit-testing a binary
|
|
||||||
crate's `main.rs`. The Rust community convention of splitting
|
|
||||||
entry-point parsing from application logic exists precisely so the
|
|
||||||
logic ends up somewhere it's normal to test.
|
|
||||||
**Rule**: `main.rs` may parse `argv`, build shared clients, and print
|
|
||||||
output. It must not contain a pipeline decision, a network/subprocess
|
|
||||||
call, or anything with a test worth writing — that belongs in
|
|
||||||
`pipeline.rs`.
|
|
||||||
**Applied this PR**: moved `process_package`, `fetch_and_verify`,
|
|
||||||
`build_and_publish`, `run_review`, and `approve` out of `main.rs` into a
|
|
||||||
new `pipeline.rs`, leaving `main.rs` as argument dispatch only.
|
|
||||||
|
|
||||||
6. **Validate at the boundary, once — don't scatter checks.**
|
|
||||||
Already stated project-wide (see the user's global instructions: don't
|
|
||||||
validate scenarios that can't happen, validate at system boundaries).
|
|
||||||
**Example already here**: `builder.rs`'s `validate_pkgname`/
|
|
||||||
`validate_pkgver`/`validate_shell_safe` run once, at PKGBUILD-generation
|
|
||||||
time, against every upstream-controlled string — not sprinkled through
|
|
||||||
whatever code happens to produce those strings.
|
|
||||||
|
|
||||||
7. **Don't build generality the currently-tracked packages don't need.**
|
|
||||||
Already the load-bearing design principle in `SPEC.md` ("a small fixed
|
|
||||||
set of PKGBUILD shapes," "extend when a third real shape shows up").
|
|
||||||
Restated here because it's also a tech-debt principle in its own right:
|
|
||||||
speculative abstraction is debt too — every future reader has to
|
|
||||||
understand it whether or not it's ever exercised.
|
|
||||||
|
|
||||||
8. **Every non-obvious structural decision gets one sentence of "why,"
|
|
||||||
inline.**
|
|
||||||
Standard tech-debt-prevention advice is to keep Architecture Decision
|
|
||||||
Records; a single-crate personal tool doesn't need a `docs/adr/`
|
|
||||||
directory, but the same information — why this way and not the obvious
|
|
||||||
alternative — needs to live somewhere a future reader will actually see
|
|
||||||
it: the doc comment on the thing itself.
|
|
||||||
**Example already here**: `release_source/github.rs`'s doc comment on
|
|
||||||
`GithubEndpoints::latest_release` explains why the newest Atom-feed entry isn't
|
|
||||||
trusted outright (scaleway-cli's `-dbg1` tag has no real Release behind
|
|
||||||
it) — the reasoning lives right next to the code it justifies, not in a
|
|
||||||
commit message or a separate design doc no one will find later.
|
|
||||||
|
|
||||||
## What's machine-enforced vs. what isn't
|
|
||||||
|
|
||||||
`cargo make ci` (format, clippy, cognitive-complexity threshold, coverage,
|
|
||||||
audit) mechanically enforces what's checkable: style, a handful of lint
|
|
||||||
categories, a complexity ceiling, and that coverage doesn't quietly
|
|
||||||
regress. It does **not** enforce module cohesion, naming, or "is this
|
|
||||||
logic in the right module" — those stay code-review questions. Worth
|
|
||||||
being honest about that boundary rather than implying CI catches
|
|
||||||
everything above.
|
|
||||||
|
|
||||||
## Further reading
|
|
||||||
|
|
||||||
- [A Philosophy of Software Design — deep modules & information hiding, summary](https://medium.com/swlh/a-philosophy-of-software-design-by-john-ousterhout-4a00d0ff9f1c)
|
|
||||||
- [Package by feature vs. package by layer](https://medium.com/@felixnjunge78/package-by-feature-vs-package-by-layer-which-one-wins-11ee03921fed)
|
|
||||||
- [Coupling and cohesion as the foundations of a maintainable codebase](https://medium.com/@iamprovidence/coupling-and-cohesion-foundations-that-affect-your-entire-codebase-77d06d44af0d)
|
|
||||||
- [Rust module and crate organization best practices](https://softwarepatternslexicon.com/rust/idiomatic-rust-patterns/module-and-crate-organization-best-practices/)
|
|
||||||
- [Reducing technical debt in 2026 — IBM](https://www.ibm.com/think/insights/reduce-technical-debt)
|
|
||||||
|
|
@ -16,35 +16,11 @@
|
||||||
# no `{version}` placeholder is needed, same as uv's config. Tracking the
|
# no `{version}` placeholder is needed, same as uv's config. Tracking the
|
||||||
# glibc x86_64 Linux build (`claude-linux-x64.tar.gz`), not the musl
|
# glibc x86_64 Linux build (`claude-linux-x64.tar.gz`), not the musl
|
||||||
# variant, to match this machine.
|
# variant, to match this machine.
|
||||||
#
|
|
||||||
# Archive shape doesn't match uv's or scaleway-cli's: the tarball extracts a
|
|
||||||
# bare `claude` file with no wrapping directory (confirmed via `tar tzvf`
|
|
||||||
# against the real v2.1.276 asset) — hence archive_binary_path below (see
|
|
||||||
# builder.rs's third shape). binary_name is also set explicitly to `claude`
|
|
||||||
# (the real upstream command name, not the `claude-code` package name) so
|
|
||||||
# the installed binary matches what this box already invokes as `claude`
|
|
||||||
# (see /opt/claude-code/bin/claude).
|
|
||||||
|
|
||||||
[package.claude-code]
|
[package.claude-code]
|
||||||
repo = "anthropics/claude-code"
|
repo = "anthropics/claude-code"
|
||||||
asset_pattern = "claude-linux-x64.tar.gz"
|
asset_pattern = "claude-linux-x64.tar.gz"
|
||||||
binary_name = "claude"
|
|
||||||
archive_binary_path = "claude"
|
|
||||||
|
|
||||||
[package.claude-code.verification]
|
[package.claude-code.verification]
|
||||||
method = "same-origin-sha256"
|
method = "same-origin-sha256"
|
||||||
checksum_asset_pattern = "SHASUMS256.txt"
|
checksum_asset_pattern = "SHASUMS256.txt"
|
||||||
|
|
||||||
[package.claude-code.sanity_check]
|
|
||||||
command = "claude --version"
|
|
||||||
version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)'
|
|
||||||
|
|
||||||
# The previously-installed AUR package (claude-code 2.1.273-1) shipped these
|
|
||||||
# via a /usr/bin/claude wrapper around the real /opt/claude-code/bin/claude
|
|
||||||
# binary — almost certainly to stop Claude Code's own self-updater from
|
|
||||||
# fighting with a package manager already managing it, which applies just
|
|
||||||
# as much here. builder.rs replicates that wrapper when `env` is set: real
|
|
||||||
# binary under /usr/lib/claude-code/, generated /usr/bin/claude wrapper.
|
|
||||||
[package.claude-code.env]
|
|
||||||
DISABLE_UPDATES = "1"
|
|
||||||
DISABLE_INSTALLATION_CHECKS = "1"
|
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,11 @@
|
||||||
# Releases ship one combined `SHA256SUMS` file (one line per platform
|
# Releases ship one combined `SHA256SUMS` file (one line per platform
|
||||||
# asset) rather than a per-asset checksum file like uv's — verifier
|
# asset) rather than a per-asset checksum file like uv's — verifier
|
||||||
# matches the line by filename.
|
# matches the line by filename.
|
||||||
#
|
|
||||||
# binary_name = "scw": confirmed by checking the currently-installed
|
|
||||||
# `extra` package (`pacman -Ql scaleway-cli`) — the pacman package is
|
|
||||||
# named scaleway-cli but the actual binary it installs is `scw`. Without
|
|
||||||
# this, pkgwatch's build would install as /usr/bin/scaleway-cli, which
|
|
||||||
# would NOT shadow extra's /usr/bin/scw at all.
|
|
||||||
|
|
||||||
[package.scaleway-cli]
|
[package.scaleway-cli]
|
||||||
repo = "scaleway/scaleway-cli"
|
repo = "scaleway/scaleway-cli"
|
||||||
asset_pattern = "scaleway-cli_{version}_linux_amd64"
|
asset_pattern = "scaleway-cli_{version}_linux_amd64"
|
||||||
binary_name = "scw"
|
|
||||||
|
|
||||||
[package.scaleway-cli.verification]
|
[package.scaleway-cli.verification]
|
||||||
method = "same-origin-sha256"
|
method = "same-origin-sha256"
|
||||||
checksum_asset_pattern = "SHA256SUMS"
|
checksum_asset_pattern = "SHA256SUMS"
|
||||||
|
|
||||||
[package.scaleway-cli.sanity_check]
|
|
||||||
command = "scw version"
|
|
||||||
version_regex = 'Version\s+(\d+\.\d+\.\d+)'
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,3 @@ asset_pattern = "uv-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
|
|
||||||
[package.uv.verification]
|
[package.uv.verification]
|
||||||
method = "github-attestation"
|
method = "github-attestation"
|
||||||
|
|
||||||
[package.uv.sanity_check]
|
|
||||||
command = "uv --version"
|
|
||||||
version_regex = 'uv (\d+\.\d+\.\d+)'
|
|
||||||
|
|
|
||||||
739
src/builder.rs
739
src/builder.rs
|
|
@ -1,739 +0,0 @@
|
||||||
//! Turns an already-downloaded, already-verified artifact into a built
|
|
||||||
//! pacman package: generates a PKGBUILD, then runs `makepkg`. Hides all
|
|
||||||
//! PKGBUILD templating and upstream-string validation behind `build()`.
|
|
||||||
|
|
||||||
use crate::config::Package;
|
|
||||||
use crate::hash;
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// Archive extensions `makepkg` auto-extracts before `package()` runs.
|
|
||||||
/// Longest-first so `.tar.gz` isn't shadowed by a hypothetical `.gz` entry.
|
|
||||||
const ARCHIVE_EXTENSIONS: &[&str] = &[".tar.gz", ".tar.xz", ".tar.zst", ".tar.bz2", ".tgz", ".zip"];
|
|
||||||
|
|
||||||
/// Everything needed to generate and build a PKGBUILD for one release.
|
|
||||||
pub struct BuildRequest<'a> {
|
|
||||||
pub pkg_name: &'a str,
|
|
||||||
pub pkg: &'a Package,
|
|
||||||
pub version: &'a str,
|
|
||||||
pub repo: &'a str,
|
|
||||||
pub asset_name: &'a str,
|
|
||||||
pub download_url: &'a str,
|
|
||||||
pub artifact_path: &'a Path,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct BuildResult {
|
|
||||||
/// The built `.pkg.tar.zst`, ready for `publisher::publish`.
|
|
||||||
pub package_path: PathBuf,
|
|
||||||
/// `makepkg`'s package staging directory (`$pkgdir`), still present
|
|
||||||
/// after a successful build — lets `sanity` exercise the freshly built
|
|
||||||
/// binary without installing it system-wide first.
|
|
||||||
pub pkgdir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generates a PKGBUILD around an already-downloaded, already-verified
|
|
||||||
/// artifact, then runs `makepkg` in `build_dir`.
|
|
||||||
///
|
|
||||||
/// Deliberately one fixed "prebuilt binary" shape, not a templating engine
|
|
||||||
/// — see docs/SPEC.md > Scaling > Template reuse. Covers the shapes
|
|
||||||
/// currently-tracked packages actually need: a bare-binary download
|
|
||||||
/// (scaleway-cli), a tarball containing a same-named directory (uv), and a
|
|
||||||
/// tarball with no wrapping directory at all whose inner filename doesn't
|
|
||||||
/// match the package name (claude-code — see `Package::archive_binary_path`).
|
|
||||||
/// Extend when a fourth real shape shows up rather than guessing at
|
|
||||||
/// generality now.
|
|
||||||
pub fn build(req: &BuildRequest, build_dir: &Path) -> Result<BuildResult> {
|
|
||||||
let pkgbuild = generate_pkgbuild(req)?;
|
|
||||||
|
|
||||||
std::fs::create_dir_all(build_dir)
|
|
||||||
.with_context(|| format!("creating build dir {}", build_dir.display()))?;
|
|
||||||
std::fs::write(build_dir.join("PKGBUILD"), pkgbuild)?;
|
|
||||||
// makepkg looks for the source file by its declared name next to
|
|
||||||
// PKGBUILD; pre-seed it with the copy pkgwatch already downloaded and
|
|
||||||
// verified so makepkg's own sha256 check passes without re-fetching
|
|
||||||
// from the network (and without trusting the network a second time).
|
|
||||||
std::fs::copy(req.artifact_path, build_dir.join(req.asset_name))?;
|
|
||||||
|
|
||||||
let status = Command::new("makepkg")
|
|
||||||
.args(["--noconfirm", "--force"])
|
|
||||||
.current_dir(build_dir)
|
|
||||||
.status()
|
|
||||||
.context("running makepkg (is base-devel installed?)")?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("makepkg failed for {} {}", req.pkg_name, req.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
let package_path = find_built_package(build_dir, req.pkg_name, req.version)?;
|
|
||||||
let pkgdir = build_dir.join("pkg").join(req.pkg_name);
|
|
||||||
Ok(BuildResult {
|
|
||||||
package_path,
|
|
||||||
pkgdir,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the PKGBUILD text for `req`, validating every upstream-controlled
|
|
||||||
/// string first (see docs/SPEC.md > Architecture > Builder: "strict validation
|
|
||||||
/// on any upstream-controlled string ... never unescaped interpolation").
|
|
||||||
/// Pure and side-effect-free so it's testable without invoking `makepkg`.
|
|
||||||
fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
|
|
||||||
validate_pkgname(req.pkg_name)?;
|
|
||||||
validate_pkgver(req.version)?;
|
|
||||||
validate_shell_safe("asset name", req.asset_name)?;
|
|
||||||
validate_shell_safe("download url", req.download_url)?;
|
|
||||||
validate_shell_safe("repo", req.repo)?;
|
|
||||||
|
|
||||||
let binary_name = req.pkg.binary_name(req.pkg_name);
|
|
||||||
validate_pkgname(binary_name)?;
|
|
||||||
|
|
||||||
let sha256 = hash::sha256_hex_file(req.artifact_path)?;
|
|
||||||
|
|
||||||
let install_source = if let Some(path) = &req.pkg.archive_binary_path {
|
|
||||||
validate_shell_safe("archive binary path", path)?;
|
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
match archive_stem(req.asset_name) {
|
|
||||||
Some(stem) => format!("{stem}/{binary_name}"),
|
|
||||||
None => req.asset_name.to_string(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let package_body = package_body(
|
|
||||||
req.pkg_name,
|
|
||||||
binary_name,
|
|
||||||
&install_source,
|
|
||||||
req.pkg.env.as_ref(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(format!(
|
|
||||||
"# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\
|
|
||||||
# edits are overwritten on the next update)\n\
|
|
||||||
pkgname='{name}'\n\
|
|
||||||
pkgver='{version}'\n\
|
|
||||||
pkgrel=1\n\
|
|
||||||
pkgdesc='{repo} release {version}, packaged by pkgwatch'\n\
|
|
||||||
arch=('x86_64')\n\
|
|
||||||
url='https://github.com/{repo}'\n\
|
|
||||||
license=('unknown')\n\
|
|
||||||
options=('!strip')\n\
|
|
||||||
source=('{asset}::{url}')\n\
|
|
||||||
sha256sums=('{sha256}')\n\
|
|
||||||
\n\
|
|
||||||
package() {{\n\
|
|
||||||
{package_body}\
|
|
||||||
}}\n",
|
|
||||||
name = req.pkg_name,
|
|
||||||
version = req.version,
|
|
||||||
repo = req.repo,
|
|
||||||
asset = req.asset_name,
|
|
||||||
url = req.download_url,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the `package()` function body: a plain single-file install, or —
|
|
||||||
/// when `env` declares variables to export — the real binary installed
|
|
||||||
/// under `/usr/lib/<pkgname>/` plus a generated `/usr/bin/<binary_name>`
|
|
||||||
/// wrapper that exports them before `exec`-ing it (see
|
|
||||||
/// `Package::env`'s doc comment for why this exists).
|
|
||||||
///
|
|
||||||
/// The wrapper locates its sibling binary via `$(dirname "$0")` rather
|
|
||||||
/// than a hardcoded absolute path: on this box `/bin/sh` is `bash`, which
|
|
||||||
/// sets `$0` to the full resolved path when a script is found via `PATH`
|
|
||||||
/// (confirmed empirically) — so the same relative lookup resolves
|
|
||||||
/// correctly both under `sanity.rs`'s staging-directory `pkgdir` check and
|
|
||||||
/// after a real `pacman` install, with no need to special-case either.
|
|
||||||
fn package_body(
|
|
||||||
pkg_name: &str,
|
|
||||||
binary_name: &str,
|
|
||||||
install_source: &str,
|
|
||||||
env: Option<&std::collections::BTreeMap<String, String>>,
|
|
||||||
) -> Result<String> {
|
|
||||||
match env.filter(|e| !e.is_empty()) {
|
|
||||||
None => Ok(format!(
|
|
||||||
"\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n"
|
|
||||||
)),
|
|
||||||
Some(env) => {
|
|
||||||
let mut exports = String::new();
|
|
||||||
for (key, value) in env {
|
|
||||||
validate_env_key(key)?;
|
|
||||||
validate_single_quoted_safe("env value", value)?;
|
|
||||||
exports.push_str(&format!("export {key}='{value}'\n"));
|
|
||||||
}
|
|
||||||
Ok(format!(
|
|
||||||
"\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/lib/{pkg_name}/{binary_name}\"\n\
|
|
||||||
\x20 install -Dm755 /dev/stdin \"${{pkgdir}}/usr/bin/{binary_name}\" <<'PKGWATCH_WRAPPER'\n\
|
|
||||||
#!/bin/sh\n\
|
|
||||||
{exports}\
|
|
||||||
exec \"$(dirname \"$0\")/../lib/{pkg_name}/{binary_name}\" \"$@\"\n\
|
|
||||||
PKGWATCH_WRAPPER\n"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matches `<prefix><anything>.pkg.tar.<compression>` — not hardcoded to
|
|
||||||
/// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any
|
|
||||||
/// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This
|
|
||||||
/// box's default happens to be `.zst`, but guessing wrong would otherwise
|
|
||||||
/// report a false "makepkg failed" for a build that actually succeeded.
|
|
||||||
fn find_built_package(build_dir: &Path, pkg_name: &str, version: &str) -> Result<PathBuf> {
|
|
||||||
let prefix = format!("{pkg_name}-{version}-");
|
|
||||||
for entry in std::fs::read_dir(build_dir)? {
|
|
||||||
let path = entry?.path();
|
|
||||||
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if file_name.starts_with(&prefix) && file_name.contains(".pkg.tar.") {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bail!(
|
|
||||||
"makepkg reported success but no {prefix}*.pkg.tar.* found in {}",
|
|
||||||
build_dir.display()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strips a recognized archive extension, returning the resulting stem —
|
|
||||||
/// the directory name `makepkg` extracts a same-named tarball into, by
|
|
||||||
/// the convention every currently-tracked tarball-shaped package follows.
|
|
||||||
/// `None` means the asset is a bare binary download (no extraction).
|
|
||||||
fn archive_stem(asset_name: &str) -> Option<&str> {
|
|
||||||
ARCHIVE_EXTENSIONS
|
|
||||||
.iter()
|
|
||||||
.find_map(|ext| asset_name.strip_suffix(ext))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rejects characters that are dangerous in *either* quoting style the
|
|
||||||
/// PKGBUILD template uses: a single quote breaks out of the single-quoted
|
|
||||||
/// fields (`pkgname`, `sha256sums`, ...); `$`, a backtick, or a backslash
|
|
||||||
/// are still live inside the double-quoted `install()` line, where
|
|
||||||
/// `asset_name` (via `install_source`) and `binary_name` end up embedded
|
|
||||||
/// so `${srcdir}`/`${pkgdir}` can expand. A single check covering both
|
|
||||||
/// contexts is safer than trying to remember which fields land in which
|
|
||||||
/// quoting style. See docs/SPEC.md > Architecture > Builder ("never unescaped
|
|
||||||
/// interpolation").
|
|
||||||
fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
|
|
||||||
if value.contains(['\'', '\n', '$', '`', '\\']) {
|
|
||||||
bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A wrapper-script env var name must be a valid POSIX shell identifier —
|
|
||||||
/// this alone also rules out every shell metacharacter, so `export
|
|
||||||
/// {key}=...` in the generated wrapper can never be anything but a plain
|
|
||||||
/// assignment.
|
|
||||||
fn validate_env_key(key: &str) -> Result<()> {
|
|
||||||
let valid = !key.is_empty()
|
|
||||||
&& key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
|
|
||||||
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
|
|
||||||
if !valid {
|
|
||||||
bail!("'{key}' is not a valid environment variable name");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rejects only what can break out of a *single*-quoted shell string: a
|
|
||||||
/// literal `'` (ends the quoting early) or a newline (injects an extra
|
|
||||||
/// statement into the wrapper). Unlike `validate_shell_safe`, `$`/backtick/
|
|
||||||
/// backslash are fine here — single quotes make them inert, and the
|
|
||||||
/// generated wrapper only ever embeds `value` inside `export key='value'`.
|
|
||||||
fn validate_single_quoted_safe(field: &str, value: &str) -> Result<()> {
|
|
||||||
if value.contains(['\'', '\n']) {
|
|
||||||
bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A pacman `pkgver` may only contain alphanumerics, `.`, `_`, `+` — no
|
|
||||||
/// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the
|
|
||||||
/// final package filename) and no shell metacharacters.
|
|
||||||
fn validate_pkgver(version: &str) -> Result<()> {
|
|
||||||
let valid = !version.is_empty()
|
|
||||||
&& version
|
|
||||||
.chars()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+'));
|
|
||||||
if !valid {
|
|
||||||
bail!("'{version}' is not a valid pacman pkgver (only [A-Za-z0-9._+] allowed)");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A pacman package/binary name may only contain lowercase alphanumerics
|
|
||||||
/// plus `@ . _ + -`.
|
|
||||||
fn validate_pkgname(name: &str) -> Result<()> {
|
|
||||||
let valid = !name.is_empty()
|
|
||||||
&& name.chars().all(|c| {
|
|
||||||
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '@' | '.' | '_' | '+' | '-')
|
|
||||||
});
|
|
||||||
if !valid {
|
|
||||||
bail!("'{name}' is not a valid pacman package name");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn archive_stem_strips_known_extensions() {
|
|
||||||
assert_eq!(
|
|
||||||
archive_stem("uv-x86_64-unknown-linux-gnu.tar.gz"),
|
|
||||||
Some("uv-x86_64-unknown-linux-gnu")
|
|
||||||
);
|
|
||||||
assert_eq!(archive_stem("thing.zip"), Some("thing"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn archive_stem_none_for_bare_binary() {
|
|
||||||
assert_eq!(archive_stem("scaleway-cli_2.62.0_linux_amd64"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_built_package_matches_default_zst_extension() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.zst"), b"").unwrap();
|
|
||||||
let found = find_built_package(dir.path(), "uv", "0.12.15").unwrap();
|
|
||||||
assert_eq!(found, dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.zst"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_built_package_matches_non_default_pkgext() {
|
|
||||||
// A box with PKGEXT='.pkg.tar.xz' in makepkg.conf shouldn't report
|
|
||||||
// a false failure just because this crate's default assumption
|
|
||||||
// (.zst) doesn't match.
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.xz"), b"").unwrap();
|
|
||||||
let found = find_built_package(dir.path(), "uv", "0.12.15").unwrap();
|
|
||||||
assert_eq!(found, dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.xz"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_built_package_ignores_non_matching_prefix() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
std::fs::write(dir.path().join("other-0.12.15-1-x86_64.pkg.tar.zst"), b"").unwrap();
|
|
||||||
assert!(find_built_package(dir.path(), "uv", "0.12.15").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn find_built_package_errors_with_clear_message_when_nothing_matches() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let err = find_built_package(dir.path(), "uv", "0.12.15").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("uv-0.12.15-"));
|
|
||||||
assert!(err.to_string().contains(".pkg.tar.*"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_pkgver_accepts_dotted_version() {
|
|
||||||
assert!(validate_pkgver("2.62.0").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_pkgver_rejects_hyphen() {
|
|
||||||
assert!(validate_pkgver("2.62.0-dbg1").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_pkgver_rejects_shell_metacharacters() {
|
|
||||||
assert!(validate_pkgver("2.62.0; rm -rf /").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_pkgver_rejects_empty() {
|
|
||||||
assert!(validate_pkgver("").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_pkgname_accepts_hyphenated_name() {
|
|
||||||
assert!(validate_pkgname("scaleway-cli").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_pkgname_rejects_uppercase() {
|
|
||||||
assert!(validate_pkgname("Scaleway-CLI").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_shell_safe_rejects_single_quote() {
|
|
||||||
assert!(validate_shell_safe("asset name", "thing'; touch pwned #.tar.gz").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_shell_safe_rejects_newline() {
|
|
||||||
assert!(validate_shell_safe("download url", "https://example.com/a\nb").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_shell_safe_rejects_dollar_sign() {
|
|
||||||
// asset_name lands inside a double-quoted string via
|
|
||||||
// install_source — $() command substitution is still live there
|
|
||||||
// even though single-quote breakout isn't.
|
|
||||||
assert!(validate_shell_safe("asset name", "thing$(touch pwned).tar.gz").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_shell_safe_rejects_backtick() {
|
|
||||||
assert!(validate_shell_safe("asset name", "thing`touch pwned`.tar.gz").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_shell_safe_rejects_backslash() {
|
|
||||||
assert!(validate_shell_safe("asset name", "thing\\$(touch pwned).tar.gz").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_shell_safe_accepts_normal_url() {
|
|
||||||
assert!(validate_shell_safe("download url", "https://example.com/a/b.tar.gz").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_package(binary_name: Option<&str>) -> Package {
|
|
||||||
let toml_text = match binary_name {
|
|
||||||
Some(bin) => format!(
|
|
||||||
r#"
|
|
||||||
repo = "o/r"
|
|
||||||
asset_pattern = "x"
|
|
||||||
binary_name = "{bin}"
|
|
||||||
[verification]
|
|
||||||
method = "github-attestation"
|
|
||||||
"#
|
|
||||||
),
|
|
||||||
None => r#"
|
|
||||||
repo = "o/r"
|
|
||||||
asset_pattern = "x"
|
|
||||||
[verification]
|
|
||||||
method = "github-attestation"
|
|
||||||
"#
|
|
||||||
.to_string(),
|
|
||||||
};
|
|
||||||
toml::from_str(&toml_text).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_package_with_archive_binary_path(
|
|
||||||
binary_name: &str,
|
|
||||||
archive_binary_path: &str,
|
|
||||||
) -> Package {
|
|
||||||
let toml_text = format!(
|
|
||||||
r#"
|
|
||||||
repo = "o/r"
|
|
||||||
asset_pattern = "x"
|
|
||||||
binary_name = "{binary_name}"
|
|
||||||
archive_binary_path = "{archive_binary_path}"
|
|
||||||
[verification]
|
|
||||||
method = "github-attestation"
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
toml::from_str(&toml_text).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_package_with_env(entries: &[(&str, &str)]) -> Package {
|
|
||||||
let env_lines: String = entries
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{k} = \"{v}\"\n"))
|
|
||||||
.collect();
|
|
||||||
let toml_text = format!(
|
|
||||||
r#"
|
|
||||||
repo = "o/r"
|
|
||||||
asset_pattern = "x"
|
|
||||||
[verification]
|
|
||||||
method = "github-attestation"
|
|
||||||
[env]
|
|
||||||
{env_lines}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
toml::from_str(&toml_text).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_rejects_unsafe_version() {
|
|
||||||
let pkg = make_package(None);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("thing.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "thing",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "1.0.0-dbg1",
|
|
||||||
repo: "o/r",
|
|
||||||
asset_name: "thing.tar.gz",
|
|
||||||
download_url: "https://example.com/thing.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
let build_dir = dir.path().join("build");
|
|
||||||
assert!(build(&req, &build_dir).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_bare_binary_installs_under_binary_name_override() {
|
|
||||||
let pkg = make_package(Some("scw"));
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("scaleway-cli_2.62.0_linux_amd64");
|
|
||||||
std::fs::write(&artifact_path, b"binary-bytes").unwrap();
|
|
||||||
let expected_sha = hash::sha256_hex(b"binary-bytes");
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "scaleway-cli",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "2.62.0",
|
|
||||||
repo: "scaleway/scaleway-cli",
|
|
||||||
asset_name: "scaleway-cli_2.62.0_linux_amd64",
|
|
||||||
download_url: "https://github.com/scaleway/scaleway-cli/releases/download/v2.62.0/scaleway-cli_2.62.0_linux_amd64",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
let pkgbuild = generate_pkgbuild(&req).unwrap();
|
|
||||||
|
|
||||||
assert!(pkgbuild.contains("pkgname='scaleway-cli'"));
|
|
||||||
assert!(pkgbuild.contains("pkgver='2.62.0'"));
|
|
||||||
assert!(pkgbuild.contains(&format!("sha256sums=('{expected_sha}')")));
|
|
||||||
// Bare binary (no archive extension) — installed straight from
|
|
||||||
// srcdir under the overridden binary name, not the pkgname.
|
|
||||||
assert!(pkgbuild.contains(
|
|
||||||
"install -Dm755 \"${srcdir}/scaleway-cli_2.62.0_linux_amd64\" \"${pkgdir}/usr/bin/scw\""
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_tarball_installs_from_extracted_stem_dir() {
|
|
||||||
let pkg = make_package(None);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("uv-x86_64-unknown-linux-gnu.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"tarball-bytes").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "uv",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "0.12.15",
|
|
||||||
repo: "astral-sh/uv",
|
|
||||||
asset_name: "uv-x86_64-unknown-linux-gnu.tar.gz",
|
|
||||||
download_url: "https://github.com/astral-sh/uv/releases/download/0.12.15/uv-x86_64-unknown-linux-gnu.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
let pkgbuild = generate_pkgbuild(&req).unwrap();
|
|
||||||
|
|
||||||
// No binary_name override — pkgname doubles as the binary name,
|
|
||||||
// and makepkg extracts the tarball into a same-named directory.
|
|
||||||
assert!(pkgbuild.contains(
|
|
||||||
"install -Dm755 \"${srcdir}/uv-x86_64-unknown-linux-gnu/uv\" \"${pkgdir}/usr/bin/uv\""
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_flat_archive_installs_from_archive_binary_path_override() {
|
|
||||||
// claude-code's shape: a tarball with no wrapping directory, whose
|
|
||||||
// inner filename ("claude") doesn't match the package name
|
|
||||||
// ("claude-code") — neither existing shape (stem/binary_name, or
|
|
||||||
// bare-binary-no-archive) fits, hence the explicit override.
|
|
||||||
let pkg = make_package_with_archive_binary_path("claude-code", "claude");
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("claude-linux-x64.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"tarball-bytes").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "claude-code",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "2.1.276",
|
|
||||||
repo: "anthropics/claude-code",
|
|
||||||
asset_name: "claude-linux-x64.tar.gz",
|
|
||||||
download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
let pkgbuild = generate_pkgbuild(&req).unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
pkgbuild
|
|
||||||
.contains("install -Dm755 \"${srcdir}/claude\" \"${pkgdir}/usr/bin/claude-code\"")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_rejects_archive_binary_path_with_command_substitution() {
|
|
||||||
let pkg = make_package_with_archive_binary_path("claude-code", "claude$(touch pwned)");
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("claude-linux-x64.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "claude-code",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "2.1.276",
|
|
||||||
repo: "anthropics/claude-code",
|
|
||||||
asset_name: "claude-linux-x64.tar.gz",
|
|
||||||
download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
assert!(generate_pkgbuild(&req).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_with_env_installs_via_lib_and_wrapper() {
|
|
||||||
let pkg = make_package_with_env(&[
|
|
||||||
("DISABLE_UPDATES", "1"),
|
|
||||||
("DISABLE_INSTALLATION_CHECKS", "1"),
|
|
||||||
]);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("thing.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "thing",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "1.0.0",
|
|
||||||
repo: "o/r",
|
|
||||||
asset_name: "thing.tar.gz",
|
|
||||||
download_url: "https://example.com/thing.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
let pkgbuild = generate_pkgbuild(&req).unwrap();
|
|
||||||
|
|
||||||
// Real binary goes under /usr/lib/<pkgname>/, not /usr/bin directly.
|
|
||||||
assert!(pkgbuild.contains(
|
|
||||||
"install -Dm755 \"${srcdir}/thing/thing\" \"${pkgdir}/usr/lib/thing/thing\""
|
|
||||||
));
|
|
||||||
// Wrapper written inline via a quoted heredoc (no bash expansion at
|
|
||||||
// PKGBUILD-build time) to /usr/bin/<binary_name>.
|
|
||||||
assert!(pkgbuild.contains(
|
|
||||||
"install -Dm755 /dev/stdin \"${pkgdir}/usr/bin/thing\" <<'PKGWATCH_WRAPPER'"
|
|
||||||
));
|
|
||||||
assert!(pkgbuild.contains("export DISABLE_INSTALLATION_CHECKS='1'"));
|
|
||||||
assert!(pkgbuild.contains("export DISABLE_UPDATES='1'"));
|
|
||||||
// Sorted (BTreeMap) — deterministic regardless of TOML source order.
|
|
||||||
let checks_pos = pkgbuild.find("DISABLE_INSTALLATION_CHECKS").unwrap();
|
|
||||||
let updates_pos = pkgbuild.find("DISABLE_UPDATES").unwrap();
|
|
||||||
assert!(checks_pos < updates_pos);
|
|
||||||
// Relative $0-based lookup, not a hardcoded absolute path — see
|
|
||||||
// package_body's doc comment for why.
|
|
||||||
assert!(pkgbuild.contains(r#"exec "$(dirname "$0")/../lib/thing/thing" "$@""#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_without_env_keeps_single_file_install() {
|
|
||||||
// Regression guard: packages with no `env` table must keep the
|
|
||||||
// original one-line install, not gain a wrapper/lib split.
|
|
||||||
let pkg = make_package(None);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("thing.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "thing",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "1.0.0",
|
|
||||||
repo: "o/r",
|
|
||||||
asset_name: "thing.tar.gz",
|
|
||||||
download_url: "https://example.com/thing.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
let pkgbuild = generate_pkgbuild(&req).unwrap();
|
|
||||||
|
|
||||||
assert!(!pkgbuild.contains("PKGWATCH_WRAPPER"));
|
|
||||||
assert!(!pkgbuild.contains("/usr/lib/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_rejects_env_value_with_single_quote() {
|
|
||||||
let pkg = make_package_with_env(&[("FOO", "bar'; touch pwned #")]);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("thing.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "thing",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "1.0.0",
|
|
||||||
repo: "o/r",
|
|
||||||
asset_name: "thing.tar.gz",
|
|
||||||
download_url: "https://example.com/thing.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
assert!(generate_pkgbuild(&req).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_rejects_invalid_env_key() {
|
|
||||||
let pkg = make_package_with_env(&[("1BAD-KEY", "value")]);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("thing.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "thing",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "1.0.0",
|
|
||||||
repo: "o/r",
|
|
||||||
asset_name: "thing.tar.gz",
|
|
||||||
download_url: "https://example.com/thing.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
assert!(generate_pkgbuild(&req).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_env_key_accepts_underscore_and_digits_after_first_char() {
|
|
||||||
assert!(validate_env_key("DISABLE_UPDATES_2").is_ok());
|
|
||||||
assert!(validate_env_key("_private").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_env_key_rejects_leading_digit() {
|
|
||||||
assert!(validate_env_key("1KEY").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_env_key_rejects_hyphen() {
|
|
||||||
assert!(validate_env_key("MY-KEY").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_single_quoted_safe_accepts_dollar_and_backtick() {
|
|
||||||
// Inert inside single quotes, unlike validate_shell_safe's context.
|
|
||||||
assert!(validate_single_quoted_safe("env value", "$(touch pwned)").is_ok());
|
|
||||||
assert!(validate_single_quoted_safe("env value", "`touch pwned`").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_single_quoted_safe_rejects_single_quote() {
|
|
||||||
assert!(validate_single_quoted_safe("env value", "it's").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_rejects_download_url_with_single_quote() {
|
|
||||||
let pkg = make_package(None);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("thing.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "thing",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "1.0.0",
|
|
||||||
repo: "o/r",
|
|
||||||
asset_name: "thing.tar.gz",
|
|
||||||
download_url: "https://example.com/x'; touch pwned #.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
assert!(generate_pkgbuild(&req).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_pkgbuild_rejects_asset_name_with_command_substitution() {
|
|
||||||
// Regression test: asset_name feeds install_source, which is
|
|
||||||
// embedded in the double-quoted install() line, not a
|
|
||||||
// single-quoted field — a single-quote-only check would miss this.
|
|
||||||
let pkg = make_package(None);
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let artifact_path = dir.path().join("thing.tar.gz");
|
|
||||||
std::fs::write(&artifact_path, b"data").unwrap();
|
|
||||||
|
|
||||||
let req = BuildRequest {
|
|
||||||
pkg_name: "thing",
|
|
||||||
pkg: &pkg,
|
|
||||||
version: "1.0.0",
|
|
||||||
repo: "o/r",
|
|
||||||
asset_name: "thing$(touch pwned).tar.gz",
|
|
||||||
download_url: "https://example.com/thing.tar.gz",
|
|
||||||
artifact_path: &artifact_path,
|
|
||||||
};
|
|
||||||
assert!(generate_pkgbuild(&req).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
125
src/checker.rs
125
src/checker.rs
|
|
@ -1,6 +1,45 @@
|
||||||
//! Turns a release tag into a version string. What the latest tag *is*
|
use crate::github::GithubEndpoints;
|
||||||
//! comes from a `ReleaseSource` (see `release_source`); this is the one piece of
|
use anyhow::{Result, bail};
|
||||||
//! the check stage that isn't host-specific.
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// Resolves the latest release tag for `repo` via its public Atom feed.
|
||||||
|
///
|
||||||
|
/// Deliberately not a full XML parse: the feed lists entries newest-first,
|
||||||
|
/// and each `<link rel="alternate" .../releases/tag/<tag>"/>` is matched
|
||||||
|
/// in document order. Revisit with a real XML parser if GitHub's feed
|
||||||
|
/// shape ever changes.
|
||||||
|
///
|
||||||
|
/// The feed can list a tag newer than any tag with a real Release object
|
||||||
|
/// behind it — observed on scaleway/scaleway-cli, which pushes a
|
||||||
|
/// `vX.Y.Z-dbg1` tag (no corresponding Release; `releases/tags/<tag>`
|
||||||
|
/// 404s) right after each real release, and that tag sorts newest in the
|
||||||
|
/// feed. So each candidate is confirmed against the releases API in feed
|
||||||
|
/// order, returning the first that actually resolves.
|
||||||
|
pub fn latest_github_release(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
endpoints: &GithubEndpoints,
|
||||||
|
repo: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let url = format!("{}/{repo}/releases.atom", endpoints.web);
|
||||||
|
let body = client.get(&url).send()?.error_for_status()?.text()?;
|
||||||
|
|
||||||
|
let re = Regex::new(r#"releases/tag/([^"]+)""#)?;
|
||||||
|
let mut candidates = re
|
||||||
|
.captures_iter(&body)
|
||||||
|
.map(|caps| caps[1].to_string())
|
||||||
|
.peekable();
|
||||||
|
if candidates.peek().is_none() {
|
||||||
|
bail!("no release tag found in {url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag in candidates {
|
||||||
|
let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api);
|
||||||
|
if client.get(&release_url).send()?.status().is_success() {
|
||||||
|
return Ok(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("no release tag in {url} resolved to a real release via the API")
|
||||||
|
}
|
||||||
|
|
||||||
/// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`.
|
/// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`.
|
||||||
///
|
///
|
||||||
|
|
@ -26,4 +65,84 @@ mod tests {
|
||||||
fn version_from_tag_leaves_bare_version_unchanged() {
|
fn version_from_tag_leaves_bare_version_unchanged() {
|
||||||
assert_eq!(version_from_tag("0.12.15"), "0.12.15");
|
assert_eq!(version_from_tag("0.12.15"), "0.12.15");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn atom_feed(tags: &[&str]) -> String {
|
||||||
|
let entries: String = tags
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
format!(r#"<link rel="alternate" href="https://github.com/o/r/releases/tag/{t}"/>"#)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("<feed>{entries}</feed>")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_github_release_skips_tags_with_no_real_release() {
|
||||||
|
let mut server = mockito::Server::new();
|
||||||
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors the real scaleway-cli case: newest feed entry (a -dbg1
|
||||||
|
// tag) has no Release object behind it and 404s.
|
||||||
|
let _feed = server
|
||||||
|
.mock("GET", "/o/r/releases.atom")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"]))
|
||||||
|
.create();
|
||||||
|
let _missing = server
|
||||||
|
.mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1")
|
||||||
|
.with_status(404)
|
||||||
|
.create();
|
||||||
|
let _real = server
|
||||||
|
.mock("GET", "/repos/o/r/releases/tags/v2.62.0")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body("{}")
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let tag = latest_github_release(&client, &endpoints, "o/r").unwrap();
|
||||||
|
assert_eq!(tag, "v2.62.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_github_release_errors_when_feed_has_no_tags() {
|
||||||
|
let mut server = mockito::Server::new();
|
||||||
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
|
let _feed = server
|
||||||
|
.mock("GET", "/o/r/releases.atom")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body("<feed></feed>")
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("no release tag found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_github_release_errors_when_no_candidate_resolves() {
|
||||||
|
let mut server = mockito::Server::new();
|
||||||
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
|
let _feed = server
|
||||||
|
.mock("GET", "/o/r/releases.atom")
|
||||||
|
.with_status(200)
|
||||||
|
.with_body(atom_feed(&["v1.0.0-dbg1"]))
|
||||||
|
.create();
|
||||||
|
let _missing = server
|
||||||
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1")
|
||||||
|
.with_status(404)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("resolved to a real release"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
221
src/config.rs
221
src/config.rs
|
|
@ -1,10 +1,6 @@
|
||||||
//! Parses `packages.d/*.toml` into typed, in-memory `Package` records.
|
use anyhow::{Context, Result};
|
||||||
//! The only module that knows the TOML shape — everything downstream
|
|
||||||
//! works with `Package`/`Verification`/`SanityCheck`, never raw TOML.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -12,117 +8,23 @@ struct PackageFile {
|
||||||
package: HashMap<String, Package>,
|
package: HashMap<String, Package>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a package's releases are published: the `source` key from
|
|
||||||
/// docs/SPEC.md > Config schema. Omitted means GitHub, so every existing
|
|
||||||
/// `packages.d/*.toml` keeps working.
|
|
||||||
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "kebab-case")]
|
|
||||||
pub enum Source {
|
|
||||||
#[default]
|
|
||||||
GithubRelease,
|
|
||||||
/// A Forgejo (or Gitea) instance; needs `base_url` too.
|
|
||||||
ForgejoRelease,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct Package {
|
pub struct Package {
|
||||||
/// `owner/name` on whichever `source` hosts it.
|
|
||||||
pub repo: String,
|
pub repo: String,
|
||||||
#[serde(default)]
|
/// Exact GitHub release asset name (still not a glob — see
|
||||||
pub source: Source,
|
/// SPEC.md > Architecture > Fetcher), optionally containing a
|
||||||
/// Web root of the Forgejo instance, e.g. `https://code.austinschaefer.com`
|
|
||||||
/// (the API lives under `/api/v1`). Required for, and only meaningful
|
|
||||||
/// with, `source = "forgejo-release"`.
|
|
||||||
pub base_url: Option<String>,
|
|
||||||
/// Exact release asset name (still not a glob — see
|
|
||||||
/// docs/SPEC.md > Architecture > Fetcher), optionally containing a
|
|
||||||
/// `{version}` placeholder for projects whose asset names embed the
|
/// `{version}` placeholder for projects whose asset names embed the
|
||||||
/// version (e.g. `scaleway-cli_{version}_linux_amd64`). Substituted via
|
/// version (e.g. `scaleway-cli_{version}_linux_amd64`). Substituted via
|
||||||
/// `checker::version_from_tag` before matching.
|
/// `checker::version_from_tag` before matching.
|
||||||
pub asset_pattern: String,
|
pub asset_pattern: String,
|
||||||
pub verification: Verification,
|
pub verification: Verification,
|
||||||
/// Name of the executable inside the built package, if it differs from
|
|
||||||
/// the package name itself — e.g. scaleway-cli's pacman package is
|
|
||||||
/// named `scaleway-cli` but its real binary is `scw` (discovered by
|
|
||||||
/// checking the currently-installed extra package, not guessable from
|
|
||||||
/// the repo name). Defaults to the package name when omitted.
|
|
||||||
pub binary_name: Option<String>,
|
|
||||||
/// Explicit path to the binary inside the extracted archive, relative
|
|
||||||
/// to `srcdir`, for archive layouts that don't match the "extracts into
|
|
||||||
/// a directory named after the archive stem" convention `builder.rs`
|
|
||||||
/// otherwise assumes (e.g. claude-code's tarball extracts a bare
|
|
||||||
/// `claude` file with no wrapping directory, and that inner filename
|
|
||||||
/// doesn't match the package name either). Only meaningful when
|
|
||||||
/// `asset_pattern` names an archive; ignored for bare-binary downloads,
|
|
||||||
/// where the downloaded file *is* the source path already. Defaults to
|
|
||||||
/// the stem/`binary_name` convention when omitted.
|
|
||||||
pub archive_binary_path: Option<String>,
|
|
||||||
/// Environment variables to export before the real binary runs, when
|
|
||||||
/// the vendor's own install ships them via a wrapper script that
|
|
||||||
/// `builder.rs` would otherwise not replicate — e.g. claude-code's
|
|
||||||
/// prior AUR package sets `DISABLE_UPDATES=1`/
|
|
||||||
/// `DISABLE_INSTALLATION_CHECKS=1` specifically so its self-updater
|
|
||||||
/// doesn't fight with a package manager already managing it, which
|
|
||||||
/// applies just as much to pkgwatch-managed installs. `BTreeMap` for
|
|
||||||
/// deterministic (sorted) ordering in the generated PKGBUILD. When
|
|
||||||
/// present and non-empty, the real binary installs to
|
|
||||||
/// `/usr/lib/<pkgname>/<binary_name>` instead of `/usr/bin` directly,
|
|
||||||
/// and a generated `/usr/bin/<binary_name>` wrapper sets these vars
|
|
||||||
/// before `exec`-ing it. Omitted or empty: no wrapper, same single-file
|
|
||||||
/// install as before.
|
|
||||||
pub env: Option<BTreeMap<String, String>>,
|
|
||||||
/// Post-build correctness check (not a security control — see
|
|
||||||
/// docs/SPEC.md > Verification trust tiers). Runs `command` against the
|
|
||||||
/// freshly built binary and confirms `version_regex`'s capture group
|
|
||||||
/// matches the version pkgwatch believes it just built.
|
|
||||||
pub sanity_check: Option<SanityCheck>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Package {
|
|
||||||
/// Rejects combinations that can't work, once at load time rather than
|
|
||||||
/// as a confusing failure deep in a run (see docs/ARCHITECTURE.md >
|
|
||||||
/// "validate at the boundary, once").
|
|
||||||
fn validate(&self, name: &str) -> Result<()> {
|
|
||||||
match (self.source, &self.base_url) {
|
|
||||||
(Source::GithubRelease, None) => {}
|
|
||||||
(Source::GithubRelease, Some(_)) => {
|
|
||||||
// Silently ignoring it would hide a mistyped `source`.
|
|
||||||
bail!("{name}: base_url only applies to source = \"forgejo-release\"");
|
|
||||||
}
|
|
||||||
(Source::ForgejoRelease, None) => {
|
|
||||||
bail!("{name}: source = \"forgejo-release\" needs a base_url");
|
|
||||||
}
|
|
||||||
(Source::ForgejoRelease, Some(url)) => {
|
|
||||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
|
||||||
bail!("{name}: base_url '{url}' must start with http:// or https://");
|
|
||||||
}
|
|
||||||
// `gh attestation verify` only speaks GitHub's attestation API.
|
|
||||||
if matches!(self.verification, Verification::GithubAttestation) {
|
|
||||||
bail!("{name}: github-attestation verification needs a GitHub source");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The name of the executable inside the built package: `binary_name`
|
|
||||||
/// if the package declares one, else `pkg_name` itself.
|
|
||||||
pub fn binary_name<'a>(&'a self, pkg_name: &'a str) -> &'a str {
|
|
||||||
self.binary_name.as_deref().unwrap_or(pkg_name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
|
||||||
pub struct SanityCheck {
|
|
||||||
pub command: String,
|
|
||||||
pub version_regex: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[serde(tag = "method", rename_all = "kebab-case")]
|
#[serde(tag = "method", rename_all = "kebab-case")]
|
||||||
pub enum Verification {
|
pub enum Verification {
|
||||||
/// Tier 4: proves transport integrity only, not authorship. See
|
/// Tier 4: proves transport integrity only, not authorship. See
|
||||||
/// docs/SPEC.md > Verification trust tiers. `checksum_asset_pattern` may
|
/// SPEC.md > Verification trust tiers. `checksum_asset_pattern` may
|
||||||
/// also contain a `{version}` placeholder, same as `asset_pattern`.
|
/// also contain a `{version}` placeholder, same as `asset_pattern`.
|
||||||
SameOriginSha256 { checksum_asset_pattern: String },
|
SameOriginSha256 { checksum_asset_pattern: String },
|
||||||
/// Tier 2: GitHub build-provenance attestation, verified via `gh
|
/// Tier 2: GitHub build-provenance attestation, verified via `gh
|
||||||
|
|
@ -140,7 +42,7 @@ impl Verification {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads every `*.toml` file in `dir` (the `packages.d/` layout from
|
/// Loads every `*.toml` file in `dir` (the `packages.d/` layout from
|
||||||
/// docs/SPEC.md > Scaling to many packages), keyed by package name.
|
/// SPEC.md > Scaling to many packages), keyed by package name.
|
||||||
pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
|
pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
|
for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
|
||||||
|
|
@ -152,10 +54,6 @@ pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
|
||||||
.with_context(|| format!("reading {}", path.display()))?;
|
.with_context(|| format!("reading {}", path.display()))?;
|
||||||
let file: PackageFile =
|
let file: PackageFile =
|
||||||
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
|
||||||
for (name, pkg) in &file.package {
|
|
||||||
pkg.validate(name)
|
|
||||||
.with_context(|| format!("in {}", path.display()))?;
|
|
||||||
}
|
|
||||||
out.extend(file.package);
|
out.extend(file.package);
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|
@ -227,38 +125,6 @@ mod tests {
|
||||||
assert_eq!(packages[0].1.verification.tier(), 2);
|
assert_eq!(packages[0].1.verification.tier(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loads_env_table_as_sorted_map() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
write(
|
|
||||||
dir.path(),
|
|
||||||
"pkg.toml",
|
|
||||||
r#"
|
|
||||||
[package.pkg]
|
|
||||||
repo = "o/r"
|
|
||||||
asset_pattern = "pkg.tar.gz"
|
|
||||||
|
|
||||||
[package.pkg.verification]
|
|
||||||
method = "github-attestation"
|
|
||||||
|
|
||||||
[package.pkg.env]
|
|
||||||
DISABLE_UPDATES = "1"
|
|
||||||
DISABLE_INSTALLATION_CHECKS = "1"
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let packages = load_packages_dir(dir.path()).unwrap();
|
|
||||||
let env = packages[0].1.env.as_ref().unwrap();
|
|
||||||
let entries: Vec<(&String, &String)> = env.iter().collect();
|
|
||||||
assert_eq!(
|
|
||||||
entries,
|
|
||||||
vec![
|
|
||||||
(&"DISABLE_INSTALLATION_CHECKS".to_string(), &"1".to_string()),
|
|
||||||
(&"DISABLE_UPDATES".to_string(), &"1".to_string()),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loads_multiple_files_and_ignores_non_toml() {
|
fn loads_multiple_files_and_ignores_non_toml() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -307,79 +173,4 @@ mod tests {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
assert!(load_packages_dir(dir.path()).unwrap().is_empty());
|
assert!(load_packages_dir(dir.path()).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One `[package.p]` with the given extra top-level lines and
|
|
||||||
/// verification table, loaded through the real loader so validation
|
|
||||||
/// runs too.
|
|
||||||
fn load_one(extra: &str, verification: &str) -> Result<Package> {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
write(
|
|
||||||
dir.path(),
|
|
||||||
"p.toml",
|
|
||||||
&format!(
|
|
||||||
"[package.p]\nrepo = \"o/r\"\nasset_pattern = \"x\"\n{extra}\n\
|
|
||||||
[package.p.verification]\n{verification}\n"
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let mut loaded = load_packages_dir(dir.path())?;
|
|
||||||
Ok(loaded.remove(0).1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAME_ORIGIN: &str = "method = \"same-origin-sha256\"\nchecksum_asset_pattern = \"SUMS\"";
|
|
||||||
const ATTESTATION: &str = "method = \"github-attestation\"";
|
|
||||||
|
|
||||||
const FORGEJO: &str = "source = \"forgejo-release\"\nbase_url = \"https://forge.example.com\"";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn source_defaults_to_github_release() {
|
|
||||||
let pkg = load_one("", ATTESTATION).unwrap();
|
|
||||||
assert_eq!(pkg.source, Source::GithubRelease);
|
|
||||||
assert_eq!(pkg.base_url, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loads_explicit_github_release_source() {
|
|
||||||
let pkg = load_one("source = \"github-release\"", ATTESTATION).unwrap();
|
|
||||||
assert_eq!(pkg.source, Source::GithubRelease);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loads_forgejo_release_source() {
|
|
||||||
let pkg = load_one(FORGEJO, SAME_ORIGIN).unwrap();
|
|
||||||
assert_eq!(pkg.source, Source::ForgejoRelease);
|
|
||||||
assert_eq!(pkg.base_url.as_deref(), Some("https://forge.example.com"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_forgejo_release_without_base_url() {
|
|
||||||
let err = load_one("source = \"forgejo-release\"", SAME_ORIGIN).unwrap_err();
|
|
||||||
assert!(format!("{err:#}").contains("needs a base_url"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_base_url_on_a_github_source() {
|
|
||||||
let err = load_one("base_url = \"https://forge.example.com\"", SAME_ORIGIN).unwrap_err();
|
|
||||||
assert!(format!("{err:#}").contains("only applies to source"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_forgejo_base_url_without_scheme() {
|
|
||||||
let err = load_one(
|
|
||||||
"source = \"forgejo-release\"\nbase_url = \"forge.example.com\"",
|
|
||||||
SAME_ORIGIN,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(format!("{err:#}").contains("must start with http"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_github_attestation_on_a_forgejo_source() {
|
|
||||||
let err = load_one(FORGEJO, ATTESTATION).unwrap_err();
|
|
||||||
assert!(format!("{err:#}").contains("needs a GitHub source"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_unknown_source() {
|
|
||||||
assert!(load_one("source = \"gitlab-release\"", SAME_ORIGIN).is_err());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
//! Downloads a named release asset (from GitHub or Forgejo) to a local
|
use crate::github::GithubEndpoints;
|
||||||
//! path. The only module that talks to the releases API for asset bytes —
|
|
||||||
//! `release_source` only resolves version tags, never downloads.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -17,29 +14,17 @@ struct Asset {
|
||||||
browser_download_url: String,
|
browser_download_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A downloaded release asset: its local path plus the URL it came from,
|
|
||||||
/// the latter needed for the `source=` line of a generated PKGBUILD (see
|
|
||||||
/// `builder`) — `makepkg` uses it only as a fallback if the pre-seeded
|
|
||||||
/// local copy ever goes missing.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct DownloadedAsset {
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub download_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Downloads the release asset named exactly `asset_name` for `repo`@`tag`
|
/// Downloads the release asset named exactly `asset_name` for `repo`@`tag`
|
||||||
/// into `dest_dir`, returning the local path and its origin URL. `api` is
|
/// into `dest_dir`, returning the local path.
|
||||||
/// the releases API root (see `ReleaseSource::api`); GitHub and Forgejo
|
|
||||||
/// serve the same endpoint and JSON shape under it.
|
|
||||||
pub fn download_asset(
|
pub fn download_asset(
|
||||||
client: &reqwest::blocking::Client,
|
client: &reqwest::blocking::Client,
|
||||||
api: &str,
|
endpoints: &GithubEndpoints,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
tag: &str,
|
tag: &str,
|
||||||
asset_name: &str,
|
asset_name: &str,
|
||||||
dest_dir: &Path,
|
dest_dir: &Path,
|
||||||
) -> Result<DownloadedAsset> {
|
) -> Result<PathBuf> {
|
||||||
let api_url = format!("{api}/repos/{repo}/releases/tags/{tag}");
|
let api_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api);
|
||||||
let release: Release = client
|
let release: Release = client
|
||||||
.get(&api_url)
|
.get(&api_url)
|
||||||
.send()?
|
.send()?
|
||||||
|
|
@ -61,10 +46,7 @@ pub fn download_asset(
|
||||||
.error_for_status()?
|
.error_for_status()?
|
||||||
.bytes()?;
|
.bytes()?;
|
||||||
std::fs::write(&dest_path, &bytes)?;
|
std::fs::write(&dest_path, &bytes)?;
|
||||||
Ok(DownloadedAsset {
|
Ok(dest_path)
|
||||||
path: dest_path,
|
|
||||||
download_url: asset.browser_download_url.clone(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -74,7 +56,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn download_asset_writes_matching_asset_to_dest_dir() {
|
fn download_asset_writes_matching_asset_to_dest_dir() {
|
||||||
let mut server = mockito::Server::new();
|
let mut server = mockito::Server::new();
|
||||||
let api = server.url();
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
let asset_url = format!("{}/download/thing.tar.gz", server.url());
|
let asset_url = format!("{}/download/thing.tar.gz", server.url());
|
||||||
let release_body = format!(
|
let release_body = format!(
|
||||||
r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"#
|
r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"#
|
||||||
|
|
@ -92,9 +77,9 @@ mod tests {
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let dest_dir = tempfile::tempdir().unwrap();
|
let dest_dir = tempfile::tempdir().unwrap();
|
||||||
let asset = download_asset(
|
let path = download_asset(
|
||||||
&client,
|
&client,
|
||||||
&api,
|
&endpoints,
|
||||||
"o/r",
|
"o/r",
|
||||||
"v1.0.0",
|
"v1.0.0",
|
||||||
"thing.tar.gz",
|
"thing.tar.gz",
|
||||||
|
|
@ -102,15 +87,17 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(asset.path, dest_dir.path().join("thing.tar.gz"));
|
assert_eq!(path, dest_dir.path().join("thing.tar.gz"));
|
||||||
assert_eq!(asset.download_url, asset_url);
|
assert_eq!(std::fs::read(&path).unwrap(), b"artifact-bytes");
|
||||||
assert_eq!(std::fs::read(&asset.path).unwrap(), b"artifact-bytes");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn download_asset_errors_when_no_asset_matches() {
|
fn download_asset_errors_when_no_asset_matches() {
|
||||||
let mut server = mockito::Server::new();
|
let mut server = mockito::Server::new();
|
||||||
let api = server.url();
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
let _release = server
|
let _release = server
|
||||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||||
.with_status(200)
|
.with_status(200)
|
||||||
|
|
@ -121,7 +108,7 @@ mod tests {
|
||||||
let dest_dir = tempfile::tempdir().unwrap();
|
let dest_dir = tempfile::tempdir().unwrap();
|
||||||
let err = download_asset(
|
let err = download_asset(
|
||||||
&client,
|
&client,
|
||||||
&api,
|
&endpoints,
|
||||||
"o/r",
|
"o/r",
|
||||||
"v1.0.0",
|
"v1.0.0",
|
||||||
"thing.tar.gz",
|
"thing.tar.gz",
|
||||||
|
|
@ -134,7 +121,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn download_asset_errors_when_release_not_found() {
|
fn download_asset_errors_when_release_not_found() {
|
||||||
let mut server = mockito::Server::new();
|
let mut server = mockito::Server::new();
|
||||||
let api = server.url();
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
let _release = server
|
let _release = server
|
||||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||||
.with_status(404)
|
.with_status(404)
|
||||||
|
|
@ -144,7 +134,7 @@ mod tests {
|
||||||
let dest_dir = tempfile::tempdir().unwrap();
|
let dest_dir = tempfile::tempdir().unwrap();
|
||||||
let err = download_asset(
|
let err = download_asset(
|
||||||
&client,
|
&client,
|
||||||
&api,
|
&endpoints,
|
||||||
"o/r",
|
"o/r",
|
||||||
"v1.0.0",
|
"v1.0.0",
|
||||||
"thing.tar.gz",
|
"thing.tar.gz",
|
||||||
|
|
|
||||||
29
src/github.rs
Normal file
29
src/github.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/// Base URLs for GitHub's public web host (Atom feeds, release pages) and
|
||||||
|
/// its REST API, factored out so tests can point both at a local mock
|
||||||
|
/// server instead of the real github.com/api.github.com.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GithubEndpoints {
|
||||||
|
pub web: String,
|
||||||
|
pub api: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GithubEndpoints {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
web: "https://github.com".to_string(),
|
||||||
|
api: "https://api.github.com".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_points_at_real_github() {
|
||||||
|
let endpoints = GithubEndpoints::default();
|
||||||
|
assert_eq!(endpoints.web, "https://github.com");
|
||||||
|
assert_eq!(endpoints.api, "https://api.github.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/hash.rs
81
src/hash.rs
|
|
@ -1,81 +0,0 @@
|
||||||
//! Two functions, shared by two real callers (`verifier`, `builder`) —
|
|
||||||
//! not a general-purpose utils dump. See docs/ARCHITECTURE.md > "organize by
|
|
||||||
//! pipeline stage, not by layer" for why that distinction matters.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::io::Read;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// In-memory digest — test-only now that both real callers (`verifier`,
|
|
||||||
/// `builder`) hash a file already on disk via `sha256_hex_file` instead.
|
|
||||||
/// Kept for building expected hashes from in-memory test fixtures.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(data);
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same digest as `sha256_hex(&std::fs::read(path)?)`, but streamed in
|
|
||||||
/// fixed-size chunks instead of reading the whole file into memory first —
|
|
||||||
/// downloaded release assets are tens of MB, and the file is already on
|
|
||||||
/// disk, so there's no reason to hold a second full copy in memory just to
|
|
||||||
/// hash it.
|
|
||||||
pub fn sha256_hex_file(path: &Path) -> Result<String> {
|
|
||||||
let mut file =
|
|
||||||
std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
let mut buf = [0u8; 64 * 1024];
|
|
||||||
loop {
|
|
||||||
let n = file
|
|
||||||
.read(&mut buf)
|
|
||||||
.with_context(|| format!("reading {}", path.display()))?;
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hasher.update(&buf[..n]);
|
|
||||||
}
|
|
||||||
Ok(hex::encode(hasher.finalize()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn matches_known_sha256() {
|
|
||||||
// printf 'hello world' | sha256sum
|
|
||||||
assert_eq!(
|
|
||||||
sha256_hex(b"hello world"),
|
|
||||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sha256_hex_file_matches_in_memory_digest() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("data.bin");
|
|
||||||
// Bigger than one read chunk, to actually exercise the loop.
|
|
||||||
let data = vec![0x5au8; 200 * 1024];
|
|
||||||
std::fs::write(&path, &data).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(sha256_hex_file(&path).unwrap(), sha256_hex(&data));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sha256_hex_file_matches_for_empty_file() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("empty.bin");
|
|
||||||
std::fs::write(&path, b"").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(sha256_hex_file(&path).unwrap(), sha256_hex(b""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sha256_hex_file_errors_on_missing_file() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let missing = dir.path().join("does-not-exist.bin");
|
|
||||||
assert!(sha256_hex_file(&missing).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
107
src/main.rs
107
src/main.rs
|
|
@ -1,35 +1,92 @@
|
||||||
//! Entry point: parses `argv` and dispatches to `pipeline`. Nothing here
|
|
||||||
//! makes a network/subprocess call or contains a decision worth a test —
|
|
||||||
//! see docs/ARCHITECTURE.md > "main is a dispatcher, not the program."
|
|
||||||
|
|
||||||
mod builder;
|
|
||||||
mod checker;
|
mod checker;
|
||||||
mod config;
|
mod config;
|
||||||
mod fetcher;
|
mod fetcher;
|
||||||
mod hash;
|
mod github;
|
||||||
mod notifier;
|
|
||||||
mod paths;
|
|
||||||
mod pipeline;
|
|
||||||
mod publisher;
|
|
||||||
mod release_source;
|
|
||||||
mod sanity;
|
|
||||||
mod state;
|
mod state;
|
||||||
#[cfg(test)]
|
|
||||||
mod test_support;
|
|
||||||
mod verifier;
|
mod verifier;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::Result;
|
||||||
|
use github::GithubEndpoints;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// check -> fetch -> verify -> build -> sanity-check -> publish, for
|
/// First iteration: check -> fetch -> verify -> report, for whatever is
|
||||||
/// whatever is in packages.d/. Tier 1-3 passes auto-publish; tier 4-6
|
/// in packages.d/. No build/publish step yet (see SPEC.md > Status).
|
||||||
/// passes queue for `pkgwatch review`. See docs/SPEC.md > Architecture for what
|
|
||||||
/// each stage does, and docs/ARCHITECTURE.md for how the code implementing it
|
|
||||||
/// is organized.
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let client = reqwest::blocking::Client::builder()
|
||||||
match args.first().map(String::as_str) {
|
.user_agent("pkgwatch/0.1 (PoC; https://code.austinschaefer.com)")
|
||||||
None => pipeline::run_check(),
|
.build()?;
|
||||||
Some("review") => pipeline::run_review(&args[1..]),
|
let endpoints = GithubEndpoints::default();
|
||||||
Some(other) => bail!("unknown subcommand '{other}' (expected: review)"),
|
|
||||||
|
let packages_dir = Path::new("packages.d");
|
||||||
|
let state_dir = Path::new("state");
|
||||||
|
let work_dir = Path::new("work");
|
||||||
|
|
||||||
|
let packages = config::load_packages_dir(packages_dir)?;
|
||||||
|
if packages.is_empty() {
|
||||||
|
println!("no packages configured under {}/", packages_dir.display());
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (name, pkg) in packages {
|
||||||
|
println!("== {name} ({}) ==", pkg.repo);
|
||||||
|
|
||||||
|
let latest = checker::latest_github_release(&client, &endpoints, &pkg.repo)?;
|
||||||
|
let last_seen = state::load_last_version(state_dir, &name);
|
||||||
|
|
||||||
|
if last_seen.as_deref() == Some(latest.as_str()) {
|
||||||
|
println!(" up to date at {latest}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" new version detected: {latest} (previously: {last_seen:?})");
|
||||||
|
|
||||||
|
let dest_dir = work_dir.join(&name).join(&latest);
|
||||||
|
let asset_name = pkg
|
||||||
|
.asset_pattern
|
||||||
|
.replace("{version}", checker::version_from_tag(&latest));
|
||||||
|
let artifact_path = fetcher::download_asset(
|
||||||
|
&client,
|
||||||
|
&endpoints,
|
||||||
|
&pkg.repo,
|
||||||
|
&latest,
|
||||||
|
&asset_name,
|
||||||
|
&dest_dir,
|
||||||
|
)?;
|
||||||
|
println!(" fetched {}", artifact_path.display());
|
||||||
|
|
||||||
|
let result = verifier::verify(
|
||||||
|
&client,
|
||||||
|
&endpoints,
|
||||||
|
&pkg.verification,
|
||||||
|
&pkg.repo,
|
||||||
|
&latest,
|
||||||
|
&artifact_path,
|
||||||
|
&dest_dir,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" verification (tier {}): {} — {}",
|
||||||
|
result.tier,
|
||||||
|
if result.passed { "PASS" } else { "FAIL" },
|
||||||
|
result.justification
|
||||||
|
);
|
||||||
|
|
||||||
|
match (result.tier, result.passed) {
|
||||||
|
(1..=3, true) => {
|
||||||
|
println!(" tier 1-3 pass: would auto-build + publish (not yet implemented)");
|
||||||
|
state::save_last_version(state_dir, &name, &latest)?;
|
||||||
|
}
|
||||||
|
(_, true) => {
|
||||||
|
println!(" tier 4-6 pass: flagging for human review, not auto-publishing");
|
||||||
|
println!(
|
||||||
|
" (review-queue persistence not yet implemented — this is where it plugs in)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(_, false) => {
|
||||||
|
println!(" verification failed — not publishing, not updating state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
src/notifier.rs
111
src/notifier.rs
|
|
@ -1,111 +0,0 @@
|
||||||
//! Tells the operator, via a desktop notification, that a package needs
|
|
||||||
//! attention (a tier 4-6 release awaiting review) or just landed in the
|
|
||||||
//! local repo. Best-effort: a missing `notify-send` or session bus must
|
|
||||||
//! never fail a run, since the pipeline's real outcome is already in
|
|
||||||
//! state and stdout.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// What happened to a package that the operator should hear about.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Event<'a> {
|
|
||||||
/// Tier 4-6: verified, waiting on `pkgwatch review <name> --approve`.
|
|
||||||
NeedsReview { name: &'a str, version: &'a str },
|
|
||||||
/// Tier 1-3: built and added to the local repo, waiting on `pacman -Syu`.
|
|
||||||
Published { name: &'a str, version: &'a str },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (summary, body) for `event` — pure, so the wording is unit-testable
|
|
||||||
/// without a notification daemon.
|
|
||||||
fn message(event: Event<'_>) -> (String, String) {
|
|
||||||
match event {
|
|
||||||
Event::NeedsReview { name, version } => (
|
|
||||||
format!("{name} {version} needs review"),
|
|
||||||
format!("Run `pkgwatch review {name} --approve`, then `sudo pacman -Syu`."),
|
|
||||||
),
|
|
||||||
Event::Published { name, version } => (
|
|
||||||
format!("{name} {version} published"),
|
|
||||||
"Run `sudo pacman -Syu` to install it.".to_string(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Best-effort desktop notification via the real `notify-send`; never
|
|
||||||
/// fails the caller.
|
|
||||||
pub fn notify(event: Event<'_>) {
|
|
||||||
notify_with(Path::new("notify-send"), event);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `notify_send_bin` is injectable for the same reason as
|
|
||||||
/// `publisher::publish_with`'s `repo_add_bin`.
|
|
||||||
fn notify_with(notify_send_bin: &Path, event: Event<'_>) {
|
|
||||||
let (summary, body) = message(event);
|
|
||||||
let result = Command::new(notify_send_bin)
|
|
||||||
.args(["--app-name=pkgwatch", "--", &summary, &body])
|
|
||||||
.status();
|
|
||||||
match result {
|
|
||||||
Ok(status) if status.success() => {}
|
|
||||||
Ok(status) => eprintln!(" warning: notify-send exited with {status}"),
|
|
||||||
Err(err) => eprintln!(" warning: could not run notify-send: {err}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::test_support::write_executable_script;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn message_for_review_names_the_approve_command() {
|
|
||||||
let (summary, body) = message(Event::NeedsReview {
|
|
||||||
name: "claude-code",
|
|
||||||
version: "v2.1.278",
|
|
||||||
});
|
|
||||||
assert_eq!(summary, "claude-code v2.1.278 needs review");
|
|
||||||
assert!(body.contains("pkgwatch review claude-code --approve"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn message_for_publish_points_at_pacman() {
|
|
||||||
let (summary, body) = message(Event::Published {
|
|
||||||
name: "uv",
|
|
||||||
version: "0.12.17",
|
|
||||||
});
|
|
||||||
assert_eq!(summary, "uv 0.12.17 published");
|
|
||||||
assert!(body.contains("pacman -Syu"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn notify_invokes_binary_with_summary_and_body() {
|
|
||||||
let stub_dir = tempfile::tempdir().unwrap();
|
|
||||||
let log_path = stub_dir.path().join("invoked_with.txt");
|
|
||||||
let stub = write_executable_script(
|
|
||||||
stub_dir.path(),
|
|
||||||
"fake-notify-send",
|
|
||||||
&format!("printf '%s\\n' \"$@\" > {}", log_path.display()),
|
|
||||||
);
|
|
||||||
|
|
||||||
notify_with(
|
|
||||||
&stub,
|
|
||||||
Event::Published {
|
|
||||||
name: "uv",
|
|
||||||
version: "0.12.17",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let invoked_with = std::fs::read_to_string(&log_path).unwrap();
|
|
||||||
assert!(invoked_with.contains("uv 0.12.17 published"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn notify_survives_missing_binary() {
|
|
||||||
notify_with(
|
|
||||||
Path::new("/nonexistent/notify-send"),
|
|
||||||
Event::NeedsReview {
|
|
||||||
name: "x",
|
|
||||||
version: "1",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
184
src/paths.rs
184
src/paths.rs
|
|
@ -1,184 +0,0 @@
|
||||||
//! Decides where pkgwatch's config, state, and work directories live on
|
|
||||||
//! disk — the only module that reads the environment to answer that; every
|
|
||||||
//! other module takes the directories it needs as parameters. (The pacman
|
|
||||||
//! repo dir, `pipeline::custom_repo_dir`, is resolved separately.)
|
|
||||||
//!
|
|
||||||
//! These follow the XDG base-directory spec instead of the current working
|
|
||||||
//! directory, so an installed `/usr/bin/pkgwatch` behaves the same
|
|
||||||
//! wherever it's launched from (a systemd unit, a shell, another checkout)
|
|
||||||
//! instead of only working from inside the repo.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
const APP_DIR: &str = "pkgwatch";
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
pub struct Paths {
|
|
||||||
/// `*.toml` package declarations. Config: hand-edited, worth backing up.
|
|
||||||
pub packages_dir: PathBuf,
|
|
||||||
/// Last-published/pending versions. State: small, but losing it makes
|
|
||||||
/// every package look new, so it isn't cache.
|
|
||||||
pub state_dir: PathBuf,
|
|
||||||
/// Downloaded artifacts and build trees. Cache: safe to delete.
|
|
||||||
pub work_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Paths {
|
|
||||||
pub fn from_env() -> Result<Self> {
|
|
||||||
Self::resolve(|key| std::env::var(key).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `getenv` is injectable so tests don't mutate the process-global
|
|
||||||
/// environment, which would race with `cargo test`'s parallel threads.
|
|
||||||
///
|
|
||||||
/// Each directory has a pkgwatch-specific override, then the matching
|
|
||||||
/// XDG variable, then the XDG default under `$HOME`; empty counts as
|
|
||||||
/// unset. The overrides exist for dry runs against scratch
|
|
||||||
/// directories, like `PKGWATCH_REPO_DIR` does for the pacman repo, so
|
|
||||||
/// they're used verbatim. The XDG variables and `$HOME` must be
|
|
||||||
/// absolute: the spec says to ignore a relative XDG value, and honoring
|
|
||||||
/// one would bring back the cwd dependence this module exists to remove.
|
|
||||||
fn resolve(getenv: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
|
||||||
let base = |override_var: &str, xdg_var: &str, home_subpath: &str| -> Result<PathBuf> {
|
|
||||||
if let Some(dir) = non_empty(&getenv, override_var) {
|
|
||||||
return Ok(PathBuf::from(dir));
|
|
||||||
}
|
|
||||||
if let Some(dir) = absolute(&getenv, xdg_var) {
|
|
||||||
return Ok(Path::new(&dir).join(APP_DIR));
|
|
||||||
}
|
|
||||||
let home = absolute(&getenv, "HOME").context("HOME is not set to an absolute path")?;
|
|
||||||
Ok(Path::new(&home).join(home_subpath).join(APP_DIR))
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
packages_dir: base("PKGWATCH_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")?
|
|
||||||
.join("packages.d"),
|
|
||||||
state_dir: base("PKGWATCH_STATE_DIR", "XDG_STATE_HOME", ".local/state")?,
|
|
||||||
work_dir: base("PKGWATCH_WORK_DIR", "XDG_CACHE_HOME", ".cache")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The XDG spec says an empty variable must be treated as unset.
|
|
||||||
fn non_empty(getenv: &impl Fn(&str) -> Option<String>, key: &str) -> Option<String> {
|
|
||||||
getenv(key).filter(|v| !v.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like `non_empty`, but also drops relative values (an empty string isn't
|
|
||||||
/// absolute either, so this subsumes the empty check).
|
|
||||||
fn absolute(getenv: &impl Fn(&str) -> Option<String>, key: &str) -> Option<String> {
|
|
||||||
getenv(key).filter(|v| Path::new(v).is_absolute())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
|
||||||
let map: HashMap<String, String> = pairs
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
||||||
.collect();
|
|
||||||
move |key| map.get(key).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn defaults_live_under_home() {
|
|
||||||
let paths = Paths::resolve(env(&[("HOME", "/home/u")])).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
paths,
|
|
||||||
Paths {
|
|
||||||
packages_dir: "/home/u/.config/pkgwatch/packages.d".into(),
|
|
||||||
state_dir: "/home/u/.local/state/pkgwatch".into(),
|
|
||||||
work_dir: "/home/u/.cache/pkgwatch".into(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn xdg_variables_override_home_defaults() {
|
|
||||||
let paths = Paths::resolve(env(&[
|
|
||||||
("HOME", "/home/u"),
|
|
||||||
("XDG_CONFIG_HOME", "/xdg/config"),
|
|
||||||
("XDG_STATE_HOME", "/xdg/state"),
|
|
||||||
("XDG_CACHE_HOME", "/xdg/cache"),
|
|
||||||
]))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
paths.packages_dir,
|
|
||||||
Path::new("/xdg/config/pkgwatch/packages.d")
|
|
||||||
);
|
|
||||||
assert_eq!(paths.state_dir, Path::new("/xdg/state/pkgwatch"));
|
|
||||||
assert_eq!(paths.work_dir, Path::new("/xdg/cache/pkgwatch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pkgwatch_overrides_win_and_are_used_verbatim() {
|
|
||||||
let paths = Paths::resolve(env(&[
|
|
||||||
("HOME", "/home/u"),
|
|
||||||
("XDG_STATE_HOME", "/xdg/state"),
|
|
||||||
("PKGWATCH_CONFIG_DIR", "/scratch/cfg"),
|
|
||||||
("PKGWATCH_STATE_DIR", "/scratch/state"),
|
|
||||||
("PKGWATCH_WORK_DIR", "/scratch/work"),
|
|
||||||
]))
|
|
||||||
.unwrap();
|
|
||||||
// No `pkgwatch/` suffix appended to an explicit override.
|
|
||||||
assert_eq!(paths.packages_dir, Path::new("/scratch/cfg/packages.d"));
|
|
||||||
assert_eq!(paths.state_dir, Path::new("/scratch/state"));
|
|
||||||
assert_eq!(paths.work_dir, Path::new("/scratch/work"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_variables_are_treated_as_unset() {
|
|
||||||
let paths = Paths::resolve(env(&[
|
|
||||||
("HOME", "/home/u"),
|
|
||||||
("XDG_STATE_HOME", ""),
|
|
||||||
("PKGWATCH_WORK_DIR", ""),
|
|
||||||
]))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(paths.state_dir, Path::new("/home/u/.local/state/pkgwatch"));
|
|
||||||
assert_eq!(paths.work_dir, Path::new("/home/u/.cache/pkgwatch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_xdg_variables_are_ignored() {
|
|
||||||
let paths = Paths::resolve(env(&[
|
|
||||||
("HOME", "/home/u"),
|
|
||||||
("XDG_CONFIG_HOME", "rel/config"),
|
|
||||||
("XDG_STATE_HOME", "./state"),
|
|
||||||
("XDG_CACHE_HOME", "cache"),
|
|
||||||
]))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
paths.packages_dir,
|
|
||||||
Path::new("/home/u/.config/pkgwatch/packages.d")
|
|
||||||
);
|
|
||||||
assert_eq!(paths.state_dir, Path::new("/home/u/.local/state/pkgwatch"));
|
|
||||||
assert_eq!(paths.work_dir, Path::new("/home/u/.cache/pkgwatch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn relative_home_is_an_error() {
|
|
||||||
let err = Paths::resolve(env(&[("HOME", "relative/home")])).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("HOME"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn errors_when_nothing_locates_home() {
|
|
||||||
let err = Paths::resolve(env(&[])).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("HOME"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_home_needed_when_every_dir_is_overridden() {
|
|
||||||
let paths = Paths::resolve(env(&[
|
|
||||||
("PKGWATCH_CONFIG_DIR", "/c"),
|
|
||||||
("PKGWATCH_STATE_DIR", "/s"),
|
|
||||||
("PKGWATCH_WORK_DIR", "/w"),
|
|
||||||
]))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(paths.state_dir, Path::new("/s"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
489
src/pipeline.rs
489
src/pipeline.rs
|
|
@ -1,489 +0,0 @@
|
||||||
//! Orchestrates one run of check -> fetch -> verify -> build ->
|
|
||||||
//! sanity-check -> publish across every configured package, plus the
|
|
||||||
//! `review` subcommand for tier 4-6 approvals. The only module that calls
|
|
||||||
//! more than one other pipeline-stage module — see docs/ARCHITECTURE.md > "main
|
|
||||||
//! is a dispatcher, not the program" for why this lives here and not in
|
|
||||||
//! `main.rs`.
|
|
||||||
|
|
||||||
use crate::builder;
|
|
||||||
use crate::checker;
|
|
||||||
use crate::config::{self, Package};
|
|
||||||
use crate::fetcher::{self, DownloadedAsset};
|
|
||||||
use crate::notifier::{self, Event};
|
|
||||||
use crate::paths::Paths;
|
|
||||||
use crate::publisher;
|
|
||||||
use crate::release_source::{self, ReleaseSource};
|
|
||||||
use crate::sanity;
|
|
||||||
use crate::state;
|
|
||||||
use crate::verifier::{self, VerificationResult};
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// Not a repo pkgwatch invents: this is the existing, already-registered
|
|
||||||
/// local pacman repo on this box (see `[custom]` in /etc/pacman.conf and
|
|
||||||
/// its `Server = file://...` line). pkgwatch adds packages to it; it does
|
|
||||||
/// not create the repo or touch pacman.conf.
|
|
||||||
const CUSTOM_REPO_NAME: &str = "custom";
|
|
||||||
const CUSTOM_REPO_SUBPATH: &str = ".local/share/pacman/custom";
|
|
||||||
|
|
||||||
fn build_client() -> Result<reqwest::blocking::Client> {
|
|
||||||
Ok(reqwest::blocking::Client::builder()
|
|
||||||
.user_agent("pkgwatch/0.1 (PoC; https://code.austinschaefer.com)")
|
|
||||||
.build()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn custom_repo_dir() -> Result<PathBuf> {
|
|
||||||
// Override for testing against a scratch repo instead of the real one
|
|
||||||
// at $HOME/.local/share/pacman/custom.
|
|
||||||
if let Ok(dir) = std::env::var("PKGWATCH_REPO_DIR") {
|
|
||||||
return Ok(PathBuf::from(dir));
|
|
||||||
}
|
|
||||||
let home = std::env::var("HOME").context("HOME is not set")?;
|
|
||||||
Ok(Path::new(&home).join(CUSTOM_REPO_SUBPATH))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds a hint to the bare "No such file" a missing config dir would give:
|
|
||||||
/// the dir is no longer relative to the cwd, so a checkout's `packages.d/`
|
|
||||||
/// isn't picked up on its own (see docs/SPEC.md > Paths).
|
|
||||||
fn load_packages(packages_dir: &Path) -> Result<Vec<(String, Package)>> {
|
|
||||||
config::load_packages_dir(packages_dir).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"no package config at {} (set PKGWATCH_CONFIG_DIR to the directory containing \
|
|
||||||
packages.d, or see docs/SPEC.md > Paths for moving a checkout's packages.d/ there)",
|
|
||||||
packages_dir.display()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_check() -> Result<()> {
|
|
||||||
let client = build_client()?;
|
|
||||||
let Paths {
|
|
||||||
packages_dir,
|
|
||||||
state_dir,
|
|
||||||
work_dir,
|
|
||||||
} = Paths::from_env()?;
|
|
||||||
|
|
||||||
let packages = load_packages(&packages_dir)?;
|
|
||||||
if packages.is_empty() {
|
|
||||||
println!("no packages configured under {}/", packages_dir.display());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut any_failed = false;
|
|
||||||
for (name, pkg) in &packages {
|
|
||||||
println!("== {name} ({}) ==", pkg.repo);
|
|
||||||
let result = release_source::for_package(pkg).and_then(|host| {
|
|
||||||
process_package(&client, host.as_ref(), &state_dir, &work_dir, name, pkg)
|
|
||||||
});
|
|
||||||
if let Err(err) = result {
|
|
||||||
eprintln!(" error: {err:#}");
|
|
||||||
any_failed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if any_failed {
|
|
||||||
bail!("one or more packages failed — see errors above");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What to do about a package after verification, derived purely from the
|
|
||||||
/// verification outcome and whether this exact version is already queued
|
|
||||||
/// for review — no I/O. See docs/ARCHITECTURE.md > "separate pure decision
|
|
||||||
/// logic from I/O."
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum TierAction {
|
|
||||||
/// Verification failed outright — don't build, don't touch state.
|
|
||||||
VerificationFailed,
|
|
||||||
/// Tier 1-3: safe to auto-build and publish immediately.
|
|
||||||
Publish,
|
|
||||||
/// Tier 4-6, and this exact version was already flagged on an earlier
|
|
||||||
/// run — nothing new to report.
|
|
||||||
StillPending,
|
|
||||||
/// Tier 4-6, and this version hasn't been flagged yet.
|
|
||||||
NewlyPending,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decide_tier_action(tier: u8, passed: bool, already_pending_this_version: bool) -> TierAction {
|
|
||||||
if !passed {
|
|
||||||
return TierAction::VerificationFailed;
|
|
||||||
}
|
|
||||||
if tier <= 3 {
|
|
||||||
return TierAction::Publish;
|
|
||||||
}
|
|
||||||
if already_pending_this_version {
|
|
||||||
TierAction::StillPending
|
|
||||||
} else {
|
|
||||||
TierAction::NewlyPending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_package(
|
|
||||||
client: &reqwest::blocking::Client,
|
|
||||||
host: &dyn ReleaseSource,
|
|
||||||
state_dir: &Path,
|
|
||||||
work_dir: &Path,
|
|
||||||
name: &str,
|
|
||||||
pkg: &Package,
|
|
||||||
) -> Result<()> {
|
|
||||||
let latest = host.latest_release(client, &pkg.repo)?;
|
|
||||||
let last_seen = state::load_last_version(state_dir, name);
|
|
||||||
if last_seen.as_deref() == Some(latest.as_str()) {
|
|
||||||
println!(" up to date at {latest}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
println!(" new version detected: {latest} (previously: {last_seen:?})");
|
|
||||||
|
|
||||||
let fetched = fetch_and_verify(client, host, work_dir, name, pkg, &latest)?;
|
|
||||||
println!(" fetched {}", fetched.asset.path.display());
|
|
||||||
println!(
|
|
||||||
" verification (tier {}): {} — {}",
|
|
||||||
fetched.verification.tier,
|
|
||||||
if fetched.verification.passed {
|
|
||||||
"PASS"
|
|
||||||
} else {
|
|
||||||
"FAIL"
|
|
||||||
},
|
|
||||||
fetched.verification.justification
|
|
||||||
);
|
|
||||||
|
|
||||||
let previously_pending = state::load_pending_version(state_dir, name);
|
|
||||||
let already_pending = previously_pending.as_deref() == Some(latest.as_str());
|
|
||||||
match decide_tier_action(
|
|
||||||
fetched.verification.tier,
|
|
||||||
fetched.verification.passed,
|
|
||||||
already_pending,
|
|
||||||
) {
|
|
||||||
// Bail rather than just print-and-return: a verification failure
|
|
||||||
// is exactly the kind of event a monitoring setup (systemd
|
|
||||||
// OnFailure=, cron mail-on-error) needs a non-zero exit to catch —
|
|
||||||
// see run_check, which treats an Err here as a failed package.
|
|
||||||
TierAction::VerificationFailed => {
|
|
||||||
bail!("verification failed — not publishing, not updating state");
|
|
||||||
}
|
|
||||||
TierAction::Publish => {
|
|
||||||
println!(" tier 1-3 pass: building + publishing");
|
|
||||||
build_and_publish(name, pkg, &latest, &fetched)?;
|
|
||||||
state::save_last_version(state_dir, name, &latest)?;
|
|
||||||
state::clear_pending_version(state_dir, name)?;
|
|
||||||
println!(" published {name} {latest}");
|
|
||||||
notifier::notify(Event::Published {
|
|
||||||
name,
|
|
||||||
version: &latest,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
TierAction::StillPending => {
|
|
||||||
println!(" tier 4-6 pass: still pending review (`pkgwatch review` to see it)");
|
|
||||||
}
|
|
||||||
TierAction::NewlyPending => {
|
|
||||||
state::save_pending_version(state_dir, name, &latest)?;
|
|
||||||
match previously_pending {
|
|
||||||
Some(superseded) => println!(
|
|
||||||
" tier 4-6 pass: flagged for human review, superseding still-unreviewed {superseded} (`pkgwatch review` to approve {latest})"
|
|
||||||
),
|
|
||||||
None => println!(
|
|
||||||
" tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
notifier::notify(Event::NeedsReview {
|
|
||||||
name,
|
|
||||||
version: &latest,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FetchVerifyResult {
|
|
||||||
version: String,
|
|
||||||
asset_name: String,
|
|
||||||
dest_dir: PathBuf,
|
|
||||||
asset: DownloadedAsset,
|
|
||||||
verification: VerificationResult,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared by the normal check loop (tier 1-3 auto-path) and `pkgwatch
|
|
||||||
/// review --approve` (which re-verifies before publishing rather than
|
|
||||||
/// trusting a possibly-stale flag from an earlier run).
|
|
||||||
fn fetch_and_verify(
|
|
||||||
client: &reqwest::blocking::Client,
|
|
||||||
host: &dyn ReleaseSource,
|
|
||||||
work_dir: &Path,
|
|
||||||
name: &str,
|
|
||||||
pkg: &Package,
|
|
||||||
tag: &str,
|
|
||||||
) -> Result<FetchVerifyResult> {
|
|
||||||
let version = checker::version_from_tag(tag).to_string();
|
|
||||||
let asset_name = pkg.asset_pattern.replace("{version}", &version);
|
|
||||||
let dest_dir = work_dir.join(name).join(tag);
|
|
||||||
|
|
||||||
let api = host.api();
|
|
||||||
let asset = fetcher::download_asset(client, api, &pkg.repo, tag, &asset_name, &dest_dir)?;
|
|
||||||
let verification = verifier::verify(
|
|
||||||
client,
|
|
||||||
api,
|
|
||||||
&pkg.verification,
|
|
||||||
&pkg.repo,
|
|
||||||
tag,
|
|
||||||
&asset.path,
|
|
||||||
&dest_dir,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(FetchVerifyResult {
|
|
||||||
version,
|
|
||||||
asset_name,
|
|
||||||
dest_dir,
|
|
||||||
asset,
|
|
||||||
verification,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_and_publish(
|
|
||||||
name: &str,
|
|
||||||
pkg: &Package,
|
|
||||||
tag: &str,
|
|
||||||
fetched: &FetchVerifyResult,
|
|
||||||
) -> Result<()> {
|
|
||||||
// Fail fast if the repo isn't registered in pacman.conf, before
|
|
||||||
// spending several seconds on a makepkg build that would otherwise
|
|
||||||
// succeed and then publish somewhere pacman never syncs from.
|
|
||||||
let repo_dir = custom_repo_dir()?;
|
|
||||||
publisher::ensure_registered(CUSTOM_REPO_NAME, &repo_dir)?;
|
|
||||||
|
|
||||||
let build_dir = fetched.dest_dir.join("build");
|
|
||||||
let req = builder::BuildRequest {
|
|
||||||
pkg_name: name,
|
|
||||||
pkg,
|
|
||||||
version: &fetched.version,
|
|
||||||
repo: &pkg.repo,
|
|
||||||
asset_name: &fetched.asset_name,
|
|
||||||
download_url: &fetched.asset.download_url,
|
|
||||||
artifact_path: &fetched.asset.path,
|
|
||||||
};
|
|
||||||
let built = builder::build(&req, &build_dir)?;
|
|
||||||
println!(" built {}", built.package_path.display());
|
|
||||||
|
|
||||||
if let Some(check) = &pkg.sanity_check {
|
|
||||||
let bin_dir = built.pkgdir.join("usr/bin");
|
|
||||||
sanity::run(check, &bin_dir, &fetched.version)
|
|
||||||
.with_context(|| format!("sanity check for {name} {tag}"))?;
|
|
||||||
println!(" sanity check passed");
|
|
||||||
}
|
|
||||||
|
|
||||||
let published = publisher::publish(&built.package_path, &repo_dir, CUSTOM_REPO_NAME)?;
|
|
||||||
println!(
|
|
||||||
" added to {} repo: {}",
|
|
||||||
CUSTOM_REPO_NAME,
|
|
||||||
published.display()
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" not installed automatically — run `sudo pacman -Syu` (or `sudo pacman -S {name}`) to pick it up"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_review(args: &[String]) -> Result<()> {
|
|
||||||
let Paths {
|
|
||||||
packages_dir,
|
|
||||||
state_dir,
|
|
||||||
work_dir,
|
|
||||||
} = Paths::from_env()?;
|
|
||||||
let packages = load_packages(&packages_dir)?;
|
|
||||||
|
|
||||||
match args {
|
|
||||||
[] => {
|
|
||||||
let mut any = false;
|
|
||||||
for (name, _) in &packages {
|
|
||||||
if let Some(pending) = state::load_pending_version(&state_dir, name) {
|
|
||||||
println!(
|
|
||||||
"{name}: {pending} pending review (run `pkgwatch review {name} --approve`)"
|
|
||||||
);
|
|
||||||
any = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !any {
|
|
||||||
println!("no packages pending review");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
[name, flag] if flag == "--approve" => {
|
|
||||||
let (_, pkg) = packages.iter().find(|(n, _)| n == name).with_context(|| {
|
|
||||||
format!("no package named '{name}' in {}/", packages_dir.display())
|
|
||||||
})?;
|
|
||||||
let tag = state::load_pending_version(&state_dir, name)
|
|
||||||
.with_context(|| format!("'{name}' has no pending review"))?;
|
|
||||||
approve(&state_dir, &work_dir, name, pkg, &tag)
|
|
||||||
}
|
|
||||||
_ => bail!("usage: pkgwatch review [<name> --approve]"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &str) -> Result<()> {
|
|
||||||
let client = build_client()?;
|
|
||||||
let host = release_source::for_package(pkg)?;
|
|
||||||
|
|
||||||
// Re-verify rather than trusting the earlier flag: the artifact at
|
|
||||||
// this tag could in principle have changed since it was queued.
|
|
||||||
let fetched = fetch_and_verify(&client, host.as_ref(), work_dir, name, pkg, tag)?;
|
|
||||||
if !fetched.verification.passed {
|
|
||||||
bail!(
|
|
||||||
"re-verification failed on approve: {}",
|
|
||||||
fetched.verification.justification
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
"re-verified (tier {}): {}",
|
|
||||||
fetched.verification.tier, fetched.verification.justification
|
|
||||||
);
|
|
||||||
|
|
||||||
build_and_publish(name, pkg, tag, &fetched)?;
|
|
||||||
state::save_last_version(state_dir, name, tag)?;
|
|
||||||
state::clear_pending_version(state_dir, name)?;
|
|
||||||
println!("approved and published {name} {tag}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::release_source::{ForgejoEndpoints, GithubEndpoints};
|
|
||||||
use crate::test_support::same_origin_package;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decide_tier_action_failed_verification_overrides_everything() {
|
|
||||||
assert_eq!(
|
|
||||||
decide_tier_action(2, false, false),
|
|
||||||
TierAction::VerificationFailed
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decide_tier_action(4, false, true),
|
|
||||||
TierAction::VerificationFailed
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decide_tier_action_tier_1_to_3_publishes() {
|
|
||||||
assert_eq!(decide_tier_action(1, true, false), TierAction::Publish);
|
|
||||||
assert_eq!(decide_tier_action(2, true, false), TierAction::Publish);
|
|
||||||
assert_eq!(decide_tier_action(3, true, true), TierAction::Publish);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decide_tier_action_tier_4_to_6_newly_pending_when_not_seen_before() {
|
|
||||||
assert_eq!(decide_tier_action(4, true, false), TierAction::NewlyPending);
|
|
||||||
assert_eq!(decide_tier_action(6, true, false), TierAction::NewlyPending);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decide_tier_action_tier_4_to_6_still_pending_when_already_flagged() {
|
|
||||||
assert_eq!(decide_tier_action(4, true, true), TierAction::StillPending);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mocks the release-tag, asset, and checksum endpoints for `o/r@v1.0.0`
|
|
||||||
/// with a checksum that doesn't match the asset, so verification fails.
|
|
||||||
/// These are identical on GitHub and Forgejo (see `ReleaseSource::api`); only
|
|
||||||
/// how the latest tag is found differs per test. Returned mocks must
|
|
||||||
/// stay alive for the test's duration.
|
|
||||||
fn mock_release_with_bad_checksum(server: &mut mockito::ServerGuard) -> Vec<mockito::Mock> {
|
|
||||||
let asset_url = format!("{}/download/thing.tar.gz", server.url());
|
|
||||||
let sums_url = format!("{}/download/SHA256SUMS", server.url());
|
|
||||||
let release_body = format!(
|
|
||||||
r#"{{"assets": [
|
|
||||||
{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}},
|
|
||||||
{{"name": "SHA256SUMS", "browser_download_url": "{sums_url}"}}
|
|
||||||
]}}"#
|
|
||||||
);
|
|
||||||
vec![
|
|
||||||
server
|
|
||||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(release_body)
|
|
||||||
.create(),
|
|
||||||
server
|
|
||||||
.mock("GET", "/download/thing.tar.gz")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(b"artifact-bytes".as_slice())
|
|
||||||
.create(),
|
|
||||||
// Wrong hash for "artifact-bytes" — forces a verification failure.
|
|
||||||
server
|
|
||||||
.mock("GET", "/download/SHA256SUMS")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(
|
|
||||||
"0000000000000000000000000000000000000000000000000000000000000000 thing.tar.gz\n",
|
|
||||||
)
|
|
||||||
.create(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs `process_package` for a package whose checksum is wrong and
|
|
||||||
/// asserts it errors with "verification failed" while leaving no trace
|
|
||||||
/// in state.
|
|
||||||
fn assert_verification_failure_is_an_error(host: &dyn ReleaseSource, pkg: &Package) {
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
let state_dir = tempfile::tempdir().unwrap();
|
|
||||||
let work_dir = tempfile::tempdir().unwrap();
|
|
||||||
|
|
||||||
let err = process_package(
|
|
||||||
&client,
|
|
||||||
host,
|
|
||||||
state_dir.path(),
|
|
||||||
work_dir.path(),
|
|
||||||
"thing",
|
|
||||||
pkg,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("verification failed"));
|
|
||||||
// Neither published nor queued for review — a failed verification
|
|
||||||
// shouldn't leave any trace in state.
|
|
||||||
assert_eq!(state::load_last_version(state_dir.path(), "thing"), None);
|
|
||||||
assert_eq!(state::load_pending_version(state_dir.path(), "thing"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression test for the exit-code gap this PR fixes: a verification
|
|
||||||
/// failure previously returned `Ok(())` from `process_package`, so
|
|
||||||
/// `run_check` never counted it as a failure and the process exited 0
|
|
||||||
/// even though the single most security-relevant check had failed.
|
|
||||||
/// Exercises the full check -> fetch -> verify path against a mocked
|
|
||||||
/// GitHub (no real network), stopping before any build/publish step
|
|
||||||
/// since verification failure returns before reaching those.
|
|
||||||
#[test]
|
|
||||||
fn process_package_returns_err_on_verification_failure() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let github = GithubEndpoints {
|
|
||||||
web: server.url(),
|
|
||||||
api: server.url(),
|
|
||||||
};
|
|
||||||
let feed = format!(
|
|
||||||
r#"<feed><link rel="alternate" href="{}/o/r/releases/tag/v1.0.0"/></feed>"#,
|
|
||||||
server.url()
|
|
||||||
);
|
|
||||||
let _atom = server
|
|
||||||
.mock("GET", "/o/r/releases.atom")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(feed)
|
|
||||||
.create();
|
|
||||||
let _release_mocks = mock_release_with_bad_checksum(&mut server);
|
|
||||||
|
|
||||||
assert_verification_failure_is_an_error(&github, &same_origin_package(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same as above but through a Forgejo source, proving the whole
|
|
||||||
/// check -> fetch -> verify path works there too: the error is the
|
|
||||||
/// verification failure, not a fetch or check failure on the way to it.
|
|
||||||
#[test]
|
|
||||||
fn process_package_returns_err_on_verification_failure_via_forgejo() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let forgejo = ForgejoEndpoints { api: server.url() };
|
|
||||||
let _latest = server
|
|
||||||
.mock("GET", "/repos/o/r/releases/latest")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(r#"{"tag_name": "v1.0.0"}"#)
|
|
||||||
.create();
|
|
||||||
let _release_mocks = mock_release_with_bad_checksum(&mut server);
|
|
||||||
|
|
||||||
// The package's own `source` is irrelevant here: `forgejo` is
|
|
||||||
// hand-built to point at the mock server, bypassing `for_package`.
|
|
||||||
assert_verification_failure_is_an_error(&forgejo, &same_origin_package(""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
200
src/publisher.rs
200
src/publisher.rs
|
|
@ -1,200 +0,0 @@
|
||||||
//! Gets a built package into the local pacman repo: copies it in, runs
|
|
||||||
//! `repo-add`, and checks the repo is actually registered in
|
|
||||||
//! `/etc/pacman.conf` first. The only module that touches the repo
|
|
||||||
//! directory or pacman.conf.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// Pacman's system-wide config — hardcoded like the rest of this tool's
|
|
||||||
/// Arch/Manjaro-specific assumptions (see docs/SPEC.md > Scope).
|
|
||||||
const PACMAN_CONF: &str = "/etc/pacman.conf";
|
|
||||||
|
|
||||||
/// Copies the built package into `repo_dir` and runs `repo-add` against
|
|
||||||
/// `<repo_name>.db.tar.gz` there. Creates `repo_dir` if it doesn't exist
|
|
||||||
/// yet — `repo-add` itself creates the database file on its first run, so
|
|
||||||
/// everything filesystem-side is self-healing.
|
|
||||||
///
|
|
||||||
/// What is *not* self-healing, and can't safely be: registering
|
|
||||||
/// `repo_name` in `/etc/pacman.conf` (see `ensure_registered`) — that
|
|
||||||
/// needs root, which this process doesn't have and shouldn't grab for
|
|
||||||
/// itself. Getting a published version onto the running system is a
|
|
||||||
/// separate, deliberate `pacman -Syu`/`pacman -S` step too, also left to
|
|
||||||
/// the operator.
|
|
||||||
pub fn publish(package_path: &Path, repo_dir: &Path, repo_name: &str) -> Result<PathBuf> {
|
|
||||||
publish_with(Path::new("repo-add"), package_path, repo_dir, repo_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `repo_add_bin` is injectable so tests can point it at a stub script
|
|
||||||
/// instead of the real `repo-add` (or a mutated global `PATH`, which would
|
|
||||||
/// race with `cargo test`'s parallel test threads).
|
|
||||||
fn publish_with(
|
|
||||||
repo_add_bin: &Path,
|
|
||||||
package_path: &Path,
|
|
||||||
repo_dir: &Path,
|
|
||||||
repo_name: &str,
|
|
||||||
) -> Result<PathBuf> {
|
|
||||||
std::fs::create_dir_all(repo_dir)
|
|
||||||
.with_context(|| format!("creating repo dir {}", repo_dir.display()))?;
|
|
||||||
let file_name = package_path
|
|
||||||
.file_name()
|
|
||||||
.context("built package path has no filename")?;
|
|
||||||
let dest = repo_dir.join(file_name);
|
|
||||||
std::fs::copy(package_path, &dest)
|
|
||||||
.with_context(|| format!("copying {} to {}", package_path.display(), dest.display()))?;
|
|
||||||
|
|
||||||
let db_path = repo_dir.join(format!("{repo_name}.db.tar.gz"));
|
|
||||||
let status = Command::new(repo_add_bin)
|
|
||||||
.arg(&db_path)
|
|
||||||
.arg(&dest)
|
|
||||||
.status()
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"running {} (is pacman-contrib installed?)",
|
|
||||||
repo_add_bin.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("repo-add failed for {}", dest.display());
|
|
||||||
}
|
|
||||||
Ok(dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifies `repo_name` is registered as an active `[section]` in
|
|
||||||
/// `/etc/pacman.conf`, so a build isn't wasted on a repo pacman will never
|
|
||||||
/// actually sync from. Call this before `publish` — ideally before even
|
|
||||||
/// starting the build, so a missing repo fails fast instead of after
|
|
||||||
/// several seconds of `makepkg` work.
|
|
||||||
///
|
|
||||||
/// Doesn't check that the section's `Server =`/`Include =` line points at
|
|
||||||
/// `repo_dir` specifically — just that a repo by this name exists at all.
|
|
||||||
/// A same-named repo pointed somewhere else is a rare, easily-diagnosed
|
|
||||||
/// misconfiguration, not worth the parsing complexity to catch here.
|
|
||||||
pub fn ensure_registered(repo_name: &str, repo_dir: &Path) -> Result<()> {
|
|
||||||
ensure_registered_at(Path::new(PACMAN_CONF), repo_name, repo_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_registered_at(pacman_conf: &Path, repo_name: &str, repo_dir: &Path) -> Result<()> {
|
|
||||||
let conf = std::fs::read_to_string(pacman_conf)
|
|
||||||
.with_context(|| format!("reading {}", pacman_conf.display()))?;
|
|
||||||
let header = format!("[{repo_name}]");
|
|
||||||
let registered = conf.lines().map(str::trim).any(|line| line == header);
|
|
||||||
if !registered {
|
|
||||||
bail!(
|
|
||||||
"'{repo_name}' is not registered in {} — add this once, as root, then re-run:\n\n\
|
|
||||||
[{repo_name}]\n\
|
|
||||||
SigLevel = Optional TrustAll\n\
|
|
||||||
Server = file://{}\n",
|
|
||||||
pacman_conf.display(),
|
|
||||||
repo_dir.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::test_support::write_executable_script;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn publish_copies_package_and_invokes_repo_add() {
|
|
||||||
let stub_dir = tempfile::tempdir().unwrap();
|
|
||||||
let log_path = stub_dir.path().join("invoked_with.txt");
|
|
||||||
let repo_add = write_executable_script(
|
|
||||||
stub_dir.path(),
|
|
||||||
"fake-repo-add",
|
|
||||||
&format!("echo \"$@\" > {}", log_path.display()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let src_dir = tempfile::tempdir().unwrap();
|
|
||||||
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
|
|
||||||
std::fs::write(&package_path, b"pkg-bytes").unwrap();
|
|
||||||
|
|
||||||
let repo_dir = tempfile::tempdir().unwrap();
|
|
||||||
let dest = publish_with(&repo_add, &package_path, repo_dir.path(), "custom").unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
dest,
|
|
||||||
repo_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst")
|
|
||||||
);
|
|
||||||
assert_eq!(std::fs::read(&dest).unwrap(), b"pkg-bytes");
|
|
||||||
|
|
||||||
let invoked_with = std::fs::read_to_string(&log_path).unwrap();
|
|
||||||
assert!(invoked_with.contains("custom.db.tar.gz"));
|
|
||||||
assert!(invoked_with.contains("thing-1.0.0-1-x86_64.pkg.tar.zst"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn publish_errors_when_repo_add_fails() {
|
|
||||||
let stub_dir = tempfile::tempdir().unwrap();
|
|
||||||
let repo_add = write_executable_script(stub_dir.path(), "fake-repo-add-fail", "exit 1");
|
|
||||||
|
|
||||||
let src_dir = tempfile::tempdir().unwrap();
|
|
||||||
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
|
|
||||||
std::fs::write(&package_path, b"pkg-bytes").unwrap();
|
|
||||||
|
|
||||||
let repo_dir = tempfile::tempdir().unwrap();
|
|
||||||
let err = publish_with(&repo_add, &package_path, repo_dir.path(), "custom").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("repo-add failed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn publish_creates_repo_dir_if_missing() {
|
|
||||||
let stub_dir = tempfile::tempdir().unwrap();
|
|
||||||
let repo_add = write_executable_script(stub_dir.path(), "fake-repo-add-ok", "exit 0");
|
|
||||||
|
|
||||||
let src_dir = tempfile::tempdir().unwrap();
|
|
||||||
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
|
|
||||||
std::fs::write(&package_path, b"pkg-bytes").unwrap();
|
|
||||||
|
|
||||||
let parent = tempfile::tempdir().unwrap();
|
|
||||||
let repo_dir = parent.path().join("nested/repo");
|
|
||||||
publish_with(&repo_add, &package_path, &repo_dir, "custom").unwrap();
|
|
||||||
assert!(repo_dir.join("thing-1.0.0-1-x86_64.pkg.tar.zst").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_pacman_conf(dir: &Path, contents: &str) -> PathBuf {
|
|
||||||
let path = dir.join("pacman.conf");
|
|
||||||
std::fs::write(&path, contents).unwrap();
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_registered_passes_when_section_present() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = write_pacman_conf(
|
|
||||||
dir.path(),
|
|
||||||
"[options]\nArchitecture = auto\n\n[extra]\nInclude = /etc/pacman.d/mirrorlist\n\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///home/austin/.local/share/pacman/custom\n",
|
|
||||||
);
|
|
||||||
assert!(ensure_registered_at(&conf, "custom", Path::new("/repo")).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_registered_fails_when_section_missing() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = write_pacman_conf(dir.path(), "[options]\nArchitecture = auto\n\n[extra]\n");
|
|
||||||
let err = ensure_registered_at(&conf, "custom", Path::new("/repo")).unwrap_err();
|
|
||||||
let msg = err.to_string();
|
|
||||||
assert!(msg.contains("not registered"));
|
|
||||||
assert!(msg.contains("[custom]"));
|
|
||||||
assert!(msg.contains("/repo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_registered_does_not_match_substring_of_another_section() {
|
|
||||||
// "custom" must match the whole section header, not just appear
|
|
||||||
// as a substring of e.g. "[custom-extra]".
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let conf = write_pacman_conf(dir.path(), "[custom-extra]\nServer = file:///elsewhere\n");
|
|
||||||
assert!(ensure_registered_at(&conf, "custom", Path::new("/repo")).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ensure_registered_errors_when_pacman_conf_missing() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let missing = dir.path().join("does-not-exist.conf");
|
|
||||||
assert!(ensure_registered_at(&missing, "custom", Path::new("/repo")).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
//! The `ReleaseSource` trait: what the pipeline needs from a hosting
|
|
||||||
//! service a package's releases are published on. Each host implements it
|
|
||||||
//! in its own file next to this one, so per-host logic never accumulates
|
|
||||||
//! here.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
/// `Debug` so a `Box<dyn ReleaseSource>` can sit in a `Result` that tests
|
|
||||||
/// unwrap.
|
|
||||||
pub trait ReleaseSource: std::fmt::Debug {
|
|
||||||
/// The latest release tag for `repo`.
|
|
||||||
fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result<String>;
|
|
||||||
|
|
||||||
/// The releases API root, which `fetcher` and `verifier` build their
|
|
||||||
/// own paths under. GitHub and Forgejo both serve
|
|
||||||
/// `/repos/{owner}/{repo}/releases/tags/{tag}` there with the same
|
|
||||||
/// `assets[].{name, browser_download_url}` shape, which is why those
|
|
||||||
/// stages need only this and not the source itself.
|
|
||||||
fn api(&self) -> &str;
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
//! A Forgejo (or Gitea) instance as a release source: its API root, and
|
|
||||||
//! how to find a repo's latest release there.
|
|
||||||
|
|
||||||
use super::ReleaseSource;
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ForgejoEndpoints {
|
|
||||||
/// The instance's API root, i.e. `<base_url>/api/v1`.
|
|
||||||
pub api: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ForgejoEndpoints {
|
|
||||||
/// `base_url` is the instance's web root, e.g.
|
|
||||||
/// `https://code.austinschaefer.com`; a trailing slash is tolerated.
|
|
||||||
pub fn from_base_url(base_url: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
api: format!("{}/api/v1", base_url.trim_end_matches('/')),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReleaseSource for ForgejoEndpoints {
|
|
||||||
/// One call, unlike GitHub's feed-then-confirm dance: Forgejo's
|
|
||||||
/// `releases/latest` already returns only the newest non-draft,
|
|
||||||
/// non-prerelease *release object*, so a stray tag with no release
|
|
||||||
/// behind it (GitHub's scaleway-cli `-dbg1` problem) can't be returned.
|
|
||||||
fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result<String> {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Latest {
|
|
||||||
tag_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = format!("{}/repos/{repo}/releases/latest", self.api);
|
|
||||||
let response = client.get(&url).send()?;
|
|
||||||
// Forgejo answers 404 both for an unknown repo and for one with no
|
|
||||||
// releases yet — the common state for a project's very first
|
|
||||||
// release.
|
|
||||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
bail!("no published release found at {url} (repo missing, or nothing released yet)");
|
|
||||||
}
|
|
||||||
let latest: Latest = response
|
|
||||||
.error_for_status()
|
|
||||||
.with_context(|| format!("fetching latest release from {url}"))?
|
|
||||||
.json()?;
|
|
||||||
Ok(latest.tag_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api(&self) -> &str {
|
|
||||||
&self.api
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_base_url_appends_api_v1() {
|
|
||||||
let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com");
|
|
||||||
assert_eq!(endpoints.api(), "https://code.example.com/api/v1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn from_base_url_tolerates_trailing_slash() {
|
|
||||||
let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com/");
|
|
||||||
assert_eq!(endpoints.api(), "https://code.example.com/api/v1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_release_returns_tag_name() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let _latest = server
|
|
||||||
.mock("GET", "/repos/o/r/releases/latest")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(r#"{"tag_name": "v0.1.0", "assets": []}"#)
|
|
||||||
.create();
|
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
let endpoints = ForgejoEndpoints { api: server.url() };
|
|
||||||
assert_eq!(endpoints.latest_release(&client, "o/r").unwrap(), "v0.1.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_release_names_the_no_releases_case() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let _latest = server
|
|
||||||
.mock("GET", "/repos/o/r/releases/latest")
|
|
||||||
.with_status(404)
|
|
||||||
.create();
|
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
let endpoints = ForgejoEndpoints { api: server.url() };
|
|
||||||
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("no published release"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_release_surfaces_server_errors() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let _latest = server
|
|
||||||
.mock("GET", "/repos/o/r/releases/latest")
|
|
||||||
.with_status(500)
|
|
||||||
.create();
|
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
let endpoints = ForgejoEndpoints { api: server.url() };
|
|
||||||
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("fetching latest release"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
//! GitHub as a release source: its endpoints, and how to find a repo's
|
|
||||||
//! latest release there.
|
|
||||||
|
|
||||||
use super::ReleaseSource;
|
|
||||||
use anyhow::{Result, bail};
|
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
/// Base URLs for GitHub's public web host (Atom feeds, release pages) and
|
|
||||||
/// its REST API, factored out so tests can point both at a local mock
|
|
||||||
/// server instead of the real github.com/api.github.com.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct GithubEndpoints {
|
|
||||||
pub web: String,
|
|
||||||
pub api: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GithubEndpoints {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
web: "https://github.com".to_string(),
|
|
||||||
api: "https://api.github.com".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReleaseSource for GithubEndpoints {
|
|
||||||
/// Resolves the latest release tag for `repo` via its public Atom feed.
|
|
||||||
///
|
|
||||||
/// Deliberately not a full XML parse: the feed lists entries
|
|
||||||
/// newest-first, and each `<link rel="alternate"
|
|
||||||
/// .../releases/tag/<tag>"/>` is matched in document order. Revisit
|
|
||||||
/// with a real XML parser if GitHub's feed shape ever changes.
|
|
||||||
///
|
|
||||||
/// The feed can list a tag newer than any tag with a real Release
|
|
||||||
/// object behind it — observed on scaleway/scaleway-cli, which pushes a
|
|
||||||
/// `vX.Y.Z-dbg1` tag (no corresponding Release; `releases/tags/<tag>`
|
|
||||||
/// 404s) right after each real release, and that tag sorts newest in
|
|
||||||
/// the feed. So each candidate is confirmed against the releases API in
|
|
||||||
/// feed order, returning the first that actually resolves.
|
|
||||||
fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result<String> {
|
|
||||||
let url = format!("{}/{repo}/releases.atom", self.web);
|
|
||||||
let body = client.get(&url).send()?.error_for_status()?.text()?;
|
|
||||||
|
|
||||||
let re = Regex::new(r#"releases/tag/([^"]+)""#)?;
|
|
||||||
let mut candidates = re
|
|
||||||
.captures_iter(&body)
|
|
||||||
.map(|caps| caps[1].to_string())
|
|
||||||
.peekable();
|
|
||||||
if candidates.peek().is_none() {
|
|
||||||
bail!("no release tag found in {url}");
|
|
||||||
}
|
|
||||||
|
|
||||||
for tag in candidates {
|
|
||||||
let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", self.api);
|
|
||||||
if client.get(&release_url).send()?.status().is_success() {
|
|
||||||
return Ok(tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bail!("no release tag in {url} resolved to a real release via the API")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api(&self) -> &str {
|
|
||||||
&self.api
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_points_at_real_github() {
|
|
||||||
let endpoints = GithubEndpoints::default();
|
|
||||||
assert_eq!(endpoints.web, "https://github.com");
|
|
||||||
assert_eq!(endpoints.api, "https://api.github.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn atom_feed(tags: &[&str]) -> String {
|
|
||||||
let entries: String = tags
|
|
||||||
.iter()
|
|
||||||
.map(|t| {
|
|
||||||
format!(r#"<link rel="alternate" href="https://github.com/o/r/releases/tag/{t}"/>"#)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
format!("<feed>{entries}</feed>")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_release_skips_tags_with_no_real_release() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let endpoints = GithubEndpoints {
|
|
||||||
web: server.url(),
|
|
||||||
api: server.url(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mirrors the real scaleway-cli case: newest feed entry (a -dbg1
|
|
||||||
// tag) has no Release object behind it and 404s.
|
|
||||||
let _feed = server
|
|
||||||
.mock("GET", "/o/r/releases.atom")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"]))
|
|
||||||
.create();
|
|
||||||
let _missing = server
|
|
||||||
.mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1")
|
|
||||||
.with_status(404)
|
|
||||||
.create();
|
|
||||||
let _real = server
|
|
||||||
.mock("GET", "/repos/o/r/releases/tags/v2.62.0")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body("{}")
|
|
||||||
.create();
|
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
let tag = endpoints.latest_release(&client, "o/r").unwrap();
|
|
||||||
assert_eq!(tag, "v2.62.0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_release_errors_when_feed_has_no_tags() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let endpoints = GithubEndpoints {
|
|
||||||
web: server.url(),
|
|
||||||
api: server.url(),
|
|
||||||
};
|
|
||||||
let _feed = server
|
|
||||||
.mock("GET", "/o/r/releases.atom")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body("<feed></feed>")
|
|
||||||
.create();
|
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("no release tag found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn latest_release_errors_when_no_candidate_resolves() {
|
|
||||||
let mut server = mockito::Server::new();
|
|
||||||
let endpoints = GithubEndpoints {
|
|
||||||
web: server.url(),
|
|
||||||
api: server.url(),
|
|
||||||
};
|
|
||||||
let _feed = server
|
|
||||||
.mock("GET", "/o/r/releases.atom")
|
|
||||||
.with_status(200)
|
|
||||||
.with_body(atom_feed(&["v1.0.0-dbg1"]))
|
|
||||||
.create();
|
|
||||||
let _missing = server
|
|
||||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1")
|
|
||||||
.with_status(404)
|
|
||||||
.create();
|
|
||||||
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("resolved to a real release"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn api_is_the_configured_api_root() {
|
|
||||||
let endpoints = GithubEndpoints {
|
|
||||||
web: "http://w".into(),
|
|
||||||
api: "http://a".into(),
|
|
||||||
};
|
|
||||||
assert_eq!(endpoints.api(), "http://a");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
//! Where a package's releases are published: the `ReleaseSource` trait,
|
|
||||||
//! one file per host implementing it, and `for_package`, which picks the
|
|
||||||
//! implementation for a package from its configured `source`. The
|
|
||||||
//! submodules are private and re-exported here, so the rest of the crate
|
|
||||||
//! imports everything from `crate::release_source` and never a host's
|
|
||||||
//! file.
|
|
||||||
|
|
||||||
mod contract;
|
|
||||||
mod forgejo;
|
|
||||||
mod github;
|
|
||||||
|
|
||||||
pub use contract::ReleaseSource;
|
|
||||||
pub use forgejo::ForgejoEndpoints;
|
|
||||||
pub use github::GithubEndpoints;
|
|
||||||
|
|
||||||
use crate::config::{Package, Source};
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
|
|
||||||
/// Defensive: errors only if a `forgejo-release` package has no
|
|
||||||
/// `base_url`, which `config::load_packages_dir` already guarantees.
|
|
||||||
pub fn for_package(pkg: &Package) -> Result<Box<dyn ReleaseSource>> {
|
|
||||||
match pkg.source {
|
|
||||||
Source::GithubRelease => Ok(Box::new(GithubEndpoints::default())),
|
|
||||||
Source::ForgejoRelease => {
|
|
||||||
let base_url = pkg
|
|
||||||
.base_url
|
|
||||||
.as_deref()
|
|
||||||
.context("source = \"forgejo-release\" needs a base_url")?;
|
|
||||||
Ok(Box::new(ForgejoEndpoints::from_base_url(base_url)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::test_support::same_origin_package as package;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn github_source_uses_real_github() {
|
|
||||||
let source = for_package(&package("")).unwrap();
|
|
||||||
assert_eq!(source.api(), "https://api.github.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn forgejo_source_uses_the_instances_api() {
|
|
||||||
let pkg = package("source = \"forgejo-release\"\nbase_url = \"https://code.example.com\"");
|
|
||||||
let source = for_package(&pkg).unwrap();
|
|
||||||
assert_eq!(source.api(), "https://code.example.com/api/v1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn forgejo_source_without_base_url_errors() {
|
|
||||||
let err = for_package(&package("source = \"forgejo-release\"")).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("needs a base_url"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
src/sanity.rs
122
src/sanity.rs
|
|
@ -1,122 +0,0 @@
|
||||||
//! Post-build correctness check: runs the freshly built binary and
|
|
||||||
//! confirms it reports the version pkgwatch believes it just built. Not a
|
|
||||||
//! security control — see docs/SPEC.md > Verification trust tiers.
|
|
||||||
|
|
||||||
use crate::config::SanityCheck;
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use regex::Regex;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// Runs `check.command` with `pkg_bin_dir` prepended to `PATH`, so it
|
|
||||||
/// exercises the binary pkgwatch just built (still sitting in makepkg's
|
|
||||||
/// package staging directory, not installed system-wide) rather than
|
|
||||||
/// whatever's already on the system. Confirms `check.version_regex`'s
|
|
||||||
/// capture group matches `expected_version`.
|
|
||||||
///
|
|
||||||
/// Correctness check only, not a security control — see docs/SPEC.md >
|
|
||||||
/// Verification trust tiers. Catches checker bugs and mangled/wrong-asset
|
|
||||||
/// downloads, not malicious releases.
|
|
||||||
pub fn run(check: &SanityCheck, pkg_bin_dir: &Path, expected_version: &str) -> Result<()> {
|
|
||||||
let path_env = format!(
|
|
||||||
"{}:{}",
|
|
||||||
pkg_bin_dir.display(),
|
|
||||||
std::env::var("PATH").unwrap_or_default()
|
|
||||||
);
|
|
||||||
let output = Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(&check.command)
|
|
||||||
.env("PATH", path_env)
|
|
||||||
.output()
|
|
||||||
.with_context(|| format!("running sanity check command '{}'", check.command))?;
|
|
||||||
if !output.status.success() {
|
|
||||||
bail!(
|
|
||||||
"sanity check command '{}' exited with {}: {}",
|
|
||||||
check.command,
|
|
||||||
output.status,
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let combined = format!(
|
|
||||||
"{}{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
);
|
|
||||||
let re = Regex::new(&check.version_regex)
|
|
||||||
.with_context(|| format!("invalid version_regex '{}'", check.version_regex))?;
|
|
||||||
let found = re
|
|
||||||
.captures(&combined)
|
|
||||||
.and_then(|caps| caps.get(1))
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"version_regex '{}' did not match sanity check output: {combined:?}",
|
|
||||||
check.version_regex
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.as_str();
|
|
||||||
|
|
||||||
if found != expected_version {
|
|
||||||
bail!(
|
|
||||||
"sanity check reported version '{found}', pkgwatch built '{expected_version}' — mismatch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::test_support::write_executable_script as write_fake_binary;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_passes_when_reported_version_matches() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
write_fake_binary(dir.path(), "uv", "echo 'uv 0.12.15 (abc 2026-09-01)'");
|
|
||||||
|
|
||||||
let check = SanityCheck {
|
|
||||||
command: "uv --version".to_string(),
|
|
||||||
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
||||||
};
|
|
||||||
assert!(run(&check, dir.path(), "0.12.15").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_fails_when_reported_version_differs() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
write_fake_binary(dir.path(), "uv", "echo 'uv 0.12.14 (abc 2026-08-01)'");
|
|
||||||
|
|
||||||
let check = SanityCheck {
|
|
||||||
command: "uv --version".to_string(),
|
|
||||||
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
||||||
};
|
|
||||||
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("mismatch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_fails_when_command_exits_nonzero() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
write_fake_binary(dir.path(), "uv", "exit 1");
|
|
||||||
|
|
||||||
let check = SanityCheck {
|
|
||||||
command: "uv --version".to_string(),
|
|
||||||
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
||||||
};
|
|
||||||
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("exited with"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn run_fails_when_output_does_not_match_regex() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
write_fake_binary(dir.path(), "uv", "echo 'not a version'");
|
|
||||||
|
|
||||||
let check = SanityCheck {
|
|
||||||
command: "uv --version".to_string(),
|
|
||||||
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
||||||
};
|
|
||||||
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
|
|
||||||
assert!(err.to_string().contains("did not match"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
78
src/state.rs
78
src/state.rs
|
|
@ -1,14 +1,10 @@
|
||||||
//! Persists two independent per-package facts as plain files: the last
|
|
||||||
//! published version, and any version currently pending human review.
|
|
||||||
//! The only module that touches the state directory (see `paths.rs`) on disk.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Last-known-published version per package, so re-runs don't re-flag a
|
/// Last-known-published version per package, so re-runs don't re-flag a
|
||||||
/// version already handled. Deliberately just one file per package for
|
/// version already handled. Deliberately just one file per package for
|
||||||
/// now — this is where a real review-queue persistence layer plugs in
|
/// now — this is where a real review-queue persistence layer plugs in
|
||||||
/// later (see docs/SPEC.md > Architecture > Reviewer queue).
|
/// later (see SPEC.md > Architecture > Reviewer queue).
|
||||||
pub fn load_last_version(state_dir: &Path, name: &str) -> Option<String> {
|
pub fn load_last_version(state_dir: &Path, name: &str) -> Option<String> {
|
||||||
std::fs::read_to_string(state_dir.join(format!("{name}.version")))
|
std::fs::read_to_string(state_dir.join(format!("{name}.version")))
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -21,33 +17,6 @@ pub fn save_last_version(state_dir: &Path, name: &str, version: &str) -> Result<
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag currently awaiting human review for a tier 4-6 package (see
|
|
||||||
/// docs/SPEC.md > Architecture > Reviewer queue), if any. Separate from
|
|
||||||
/// `load_last_version`/`save_last_version`: approving a review doesn't
|
|
||||||
/// mean future versions auto-publish, so the two must be tracked
|
|
||||||
/// independently.
|
|
||||||
pub fn load_pending_version(state_dir: &Path, name: &str) -> Option<String> {
|
|
||||||
std::fs::read_to_string(state_dir.join(format!("{name}.pending")))
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_pending_version(state_dir: &Path, name: &str, version: &str) -> Result<()> {
|
|
||||||
std::fs::create_dir_all(state_dir)?;
|
|
||||||
std::fs::write(state_dir.join(format!("{name}.pending")), version)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears a pending review, e.g. once it's been approved and published.
|
|
||||||
/// Not an error if there was nothing pending.
|
|
||||||
pub fn clear_pending_version(state_dir: &Path, name: &str) -> Result<()> {
|
|
||||||
match std::fs::remove_file(state_dir.join(format!("{name}.pending"))) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -88,49 +57,4 @@ mod tests {
|
||||||
Some("0.12.15".to_string())
|
Some("0.12.15".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_pending_version_missing_file_returns_none() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
assert_eq!(load_pending_version(dir.path(), "scaleway-cli"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_then_load_pending_roundtrips() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
save_pending_version(dir.path(), "scaleway-cli", "v2.62.0").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
load_pending_version(dir.path(), "scaleway-cli"),
|
|
||||||
Some("v2.62.0".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_pending_version_removes_it() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
save_pending_version(dir.path(), "scaleway-cli", "v2.62.0").unwrap();
|
|
||||||
clear_pending_version(dir.path(), "scaleway-cli").unwrap();
|
|
||||||
assert_eq!(load_pending_version(dir.path(), "scaleway-cli"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_pending_version_is_a_noop_when_nothing_pending() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
assert!(clear_pending_version(dir.path(), "scaleway-cli").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_and_last_version_are_tracked_independently() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
save_last_version(dir.path(), "scaleway-cli", "v2.61.0").unwrap();
|
|
||||||
save_pending_version(dir.path(), "scaleway-cli", "v2.62.0").unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
load_last_version(dir.path(), "scaleway-cli"),
|
|
||||||
Some("v2.61.0".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
load_pending_version(dir.path(), "scaleway-cli"),
|
|
||||||
Some("v2.62.0".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
//! Test-only fixture helpers shared across modules' `#[cfg(test)]` code
|
|
||||||
//! — not production code, and not built outside `cargo test`. See
|
|
||||||
//! docs/ARCHITECTURE.md > "organize by pipeline stage, not by layer": this
|
|
||||||
//! exists to remove specific pieces of duplication (near-identical copies
|
|
||||||
//! of "write an executable shell script" and of "build a same-origin
|
|
||||||
//! `Package` from TOML"), not as a general test-utils dump.
|
|
||||||
|
|
||||||
use crate::config::Package;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// Set by `wait_until_executable`'s probe so the script exits before
|
|
||||||
/// running its real body.
|
|
||||||
const PROBE_ENV: &str = "PKGWATCH_TEST_SCRIPT_PROBE";
|
|
||||||
/// `ETXTBSY`: exec of a file some process still has open for writing.
|
|
||||||
const TEXT_FILE_BUSY: i32 = 26;
|
|
||||||
|
|
||||||
/// Writes an executable `#!/bin/sh` script named `name` into `dir`,
|
|
||||||
/// running `body` as its contents. Used to stand in for a real binary
|
|
||||||
/// (`repo-add`, a package's own `--version` command) in tests, without
|
|
||||||
/// needing the real tool installed or a mutated global `PATH`.
|
|
||||||
///
|
|
||||||
/// Doesn't return until the script can actually be exec'd. `cargo test`
|
|
||||||
/// runs tests on parallel threads, and a `fork` on another thread between
|
|
||||||
/// this function's write-open and close leaves the child holding a copy of
|
|
||||||
/// the write fd until it execs, so an immediate exec of the new script can
|
|
||||||
/// fail with `Text file busy`. Once no holder is left none can appear (our
|
|
||||||
/// fd is closed), so a probe exec that succeeds proves later ones will.
|
|
||||||
pub(crate) fn write_executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
|
||||||
let path = dir.join(name);
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
format!("#!/bin/sh\n[ -z \"${PROBE_ENV}\" ] || exit 0\n{body}\n"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut perms = std::fs::metadata(&path).unwrap().permissions();
|
|
||||||
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
|
|
||||||
std::fs::set_permissions(&path, perms).unwrap();
|
|
||||||
wait_until_executable(&path);
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_until_executable(path: &Path) {
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
match Command::new(path).env(PROBE_ENV, "1").status() {
|
|
||||||
Ok(_) => return,
|
|
||||||
Err(err) if err.raw_os_error() == Some(TEXT_FILE_BUSY) && Instant::now() < deadline => {
|
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
|
||||||
}
|
|
||||||
Err(err) => panic!("probe-exec of {} failed: {err}", path.display()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `same-origin-sha256` `Package` for repo `o/r`, asset `thing.tar.gz`,
|
|
||||||
/// checksum asset `SHA256SUMS`. `extra` is spliced in as top-level keys
|
|
||||||
/// before the `[verification]` table (e.g. `source`/`base_url`), and is
|
|
||||||
/// parsed directly rather than through `config::load_packages_dir`, so it
|
|
||||||
/// skips load-time validation.
|
|
||||||
pub(crate) fn same_origin_package(extra: &str) -> Package {
|
|
||||||
toml::from_str(&format!(
|
|
||||||
r#"
|
|
||||||
repo = "o/r"
|
|
||||||
asset_pattern = "thing.tar.gz"
|
|
||||||
{extra}
|
|
||||||
[verification]
|
|
||||||
method = "same-origin-sha256"
|
|
||||||
checksum_asset_pattern = "SHA256SUMS"
|
|
||||||
"#
|
|
||||||
))
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
//! Runs the trust-tier-specific check declared for a package against a
|
|
||||||
//! downloaded artifact, and reports a pass/fail plus the tier it implies.
|
|
||||||
//! The only module that knows what each `Verification::method` actually
|
|
||||||
//! proves — see docs/SPEC.md > Verification trust tiers.
|
|
||||||
|
|
||||||
use crate::checker::version_from_tag;
|
use crate::checker::version_from_tag;
|
||||||
use crate::config::Verification;
|
use crate::config::Verification;
|
||||||
use crate::fetcher;
|
use crate::fetcher;
|
||||||
use crate::hash;
|
use crate::github::GithubEndpoints;
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
|
@ -18,11 +14,11 @@ pub struct VerificationResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the verification method declared for a package against a
|
/// Runs the verification method declared for a package against a
|
||||||
/// downloaded artifact. See docs/SPEC.md > Verification trust tiers for what
|
/// downloaded artifact. See SPEC.md > Verification trust tiers for what
|
||||||
/// each tier does and does not prove.
|
/// each tier does and does not prove.
|
||||||
pub fn verify(
|
pub fn verify(
|
||||||
client: &reqwest::blocking::Client,
|
client: &reqwest::blocking::Client,
|
||||||
api: &str,
|
endpoints: &GithubEndpoints,
|
||||||
verification: &Verification,
|
verification: &Verification,
|
||||||
repo: &str,
|
repo: &str,
|
||||||
tag: &str,
|
tag: &str,
|
||||||
|
|
@ -35,16 +31,23 @@ pub fn verify(
|
||||||
} => {
|
} => {
|
||||||
let checksum_asset_name =
|
let checksum_asset_name =
|
||||||
checksum_asset_pattern.replace("{version}", version_from_tag(tag));
|
checksum_asset_pattern.replace("{version}", version_from_tag(tag));
|
||||||
let checksum_asset =
|
let checksum_path = fetcher::download_asset(
|
||||||
fetcher::download_asset(client, api, repo, tag, &checksum_asset_name, dest_dir)?;
|
client,
|
||||||
let checksum_text = std::fs::read_to_string(&checksum_asset.path)?;
|
endpoints,
|
||||||
|
repo,
|
||||||
|
tag,
|
||||||
|
&checksum_asset_name,
|
||||||
|
dest_dir,
|
||||||
|
)?;
|
||||||
|
let checksum_text = std::fs::read_to_string(&checksum_path)?;
|
||||||
let artifact_name = artifact_path
|
let artifact_name = artifact_path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.context("artifact path has no filename")?;
|
.context("artifact path has no filename")?;
|
||||||
let expected = expected_checksum(&checksum_text, artifact_name)?;
|
let expected = expected_checksum(&checksum_text, artifact_name)?;
|
||||||
|
|
||||||
let actual = hash::sha256_hex_file(artifact_path)?;
|
let data = std::fs::read(artifact_path)?;
|
||||||
|
let actual = sha256_hex(&data);
|
||||||
let passed = actual == expected;
|
let passed = actual == expected;
|
||||||
|
|
||||||
Ok(VerificationResult {
|
Ok(VerificationResult {
|
||||||
|
|
@ -52,7 +55,7 @@ pub fn verify(
|
||||||
passed,
|
passed,
|
||||||
justification: if passed {
|
justification: if passed {
|
||||||
"same-origin sha256 matched — proves transport integrity only, \
|
"same-origin sha256 matched — proves transport integrity only, \
|
||||||
not authorship (see tier 4 in docs/SPEC.md)"
|
not authorship (see tier 4 in SPEC.md)"
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
format!("sha256 mismatch: expected {expected}, got {actual}")
|
format!("sha256 mismatch: expected {expected}, got {actual}")
|
||||||
|
|
@ -91,6 +94,12 @@ pub fn verify(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
/// Finds the expected hash for `artifact_name` in a checksum file.
|
/// Finds the expected hash for `artifact_name` in a checksum file.
|
||||||
///
|
///
|
||||||
/// Handles both a bare-hash file covering a single asset (e.g. uv's
|
/// Handles both a bare-hash file covering a single asset (e.g. uv's
|
||||||
|
|
@ -177,11 +186,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_same_origin_sha256_passes_on_matching_checksum() {
|
fn verify_same_origin_sha256_passes_on_matching_checksum() {
|
||||||
let mut server = mockito::Server::new();
|
let mut server = mockito::Server::new();
|
||||||
let api = server.url();
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
let dest_dir = tempfile::tempdir().unwrap();
|
let dest_dir = tempfile::tempdir().unwrap();
|
||||||
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
||||||
std::fs::write(&artifact_path, b"hello world").unwrap();
|
std::fs::write(&artifact_path, b"hello world").unwrap();
|
||||||
let expected_hash = hash::sha256_hex(b"hello world");
|
let expected_hash = sha256_hex(b"hello world");
|
||||||
|
|
||||||
let release_url = format!("{}/download/SHA256SUMS", server.url());
|
let release_url = format!("{}/download/SHA256SUMS", server.url());
|
||||||
let release_body = format!(
|
let release_body = format!(
|
||||||
|
|
@ -204,7 +216,7 @@ mod tests {
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let result = verify(
|
let result = verify(
|
||||||
&client,
|
&client,
|
||||||
&api,
|
&endpoints,
|
||||||
&verification,
|
&verification,
|
||||||
"o/r",
|
"o/r",
|
||||||
"v1.0.0",
|
"v1.0.0",
|
||||||
|
|
@ -220,7 +232,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_same_origin_sha256_fails_on_mismatched_checksum() {
|
fn verify_same_origin_sha256_fails_on_mismatched_checksum() {
|
||||||
let mut server = mockito::Server::new();
|
let mut server = mockito::Server::new();
|
||||||
let api = server.url();
|
let endpoints = GithubEndpoints {
|
||||||
|
web: server.url(),
|
||||||
|
api: server.url(),
|
||||||
|
};
|
||||||
let dest_dir = tempfile::tempdir().unwrap();
|
let dest_dir = tempfile::tempdir().unwrap();
|
||||||
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
let artifact_path = dest_dir.path().join("thing.tar.gz");
|
||||||
std::fs::write(&artifact_path, b"hello world").unwrap();
|
std::fs::write(&artifact_path, b"hello world").unwrap();
|
||||||
|
|
@ -248,7 +263,7 @@ mod tests {
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
let result = verify(
|
let result = verify(
|
||||||
&client,
|
&client,
|
||||||
&api,
|
&endpoints,
|
||||||
&verification,
|
&verification,
|
||||||
"o/r",
|
"o/r",
|
||||||
"v1.0.0",
|
"v1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# Triggered by pkgwatch.service's OnFailure=. Covers what the in-process
|
|
||||||
# notifier can't: a verification failure, build failure, or network error
|
|
||||||
# exits non-zero, and that would otherwise only show up in the journal.
|
|
||||||
[Unit]
|
|
||||||
Description=Notify that a pkgwatch run failed
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
# Absolute path: systemd requires one, unlike the in-process notifier,
|
|
||||||
# which resolves notify-send from PATH.
|
|
||||||
ExecStart=/usr/bin/notify-send --app-name=pkgwatch --urgency=critical "pkgwatch run failed" "See: journalctl --user -u pkgwatch.service"
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# User-level oneshot: one check -> fetch -> verify -> build -> publish pass.
|
|
||||||
# Install: see docs/SPEC.md > Scheduling.
|
|
||||||
#
|
|
||||||
# No WorkingDirectory: config, state and work dirs come from the XDG paths
|
|
||||||
# in src/paths.rs (see docs/SPEC.md > Paths), not the cwd.
|
|
||||||
# The binary is the release build in the main checkout (`cargo build
|
|
||||||
# --release`), so a rebuild is what picks up code changes.
|
|
||||||
[Unit]
|
|
||||||
Description=pkgwatch: check tracked packages for new upstream releases
|
|
||||||
OnFailure=pkgwatch-failure.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
ExecStart=%h/dev/pkgwatch/target/release/pkgwatch
|
|
||||||
# Builds (makepkg, large Go/Rust binaries) can legitimately take a while.
|
|
||||||
TimeoutStartSec=30min
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# Periodic, not persistent (see docs/SPEC.md > Vision): no catch-up burst
|
|
||||||
# for missed ticks. The laptop is only on while logged in, so the user
|
|
||||||
# manager starting (= login) is what matters: OnStartupSec runs a check
|
|
||||||
# right after login (10s, so the session bus and network are up) instead
|
|
||||||
# of waiting up to an hour for the next tick.
|
|
||||||
#
|
|
||||||
# No RandomizedDelaySec: it applies to every trigger, including the
|
|
||||||
# startup one, and a single machine has no herd to spread out anyway.
|
|
||||||
[Unit]
|
|
||||||
Description=Run pkgwatch at login and hourly
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnStartupSec=10s
|
|
||||||
OnCalendar=hourly
|
|
||||||
Persistent=false
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
Loading…
Reference in a new issue