diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 735b1ec..cb6e6aa 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,3 +1,4 @@ +--- self-hosted-runner: labels: - windows-latest-arm64-s diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78e76d0..7a56602 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version: '1.26.4' - name: Install system tools run: | @@ -38,7 +38,7 @@ jobs: - name: Install goneat via sfetch (dogfooding) run: | ./bin/sfetch --repo fulmenhq/goneat \ - --tag v0.5.10 \ + --tag v0.5.13 \ --dest-dir bin \ --require-minisign ./bin/goneat version @@ -75,7 +75,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version: '1.26.4' - name: Build sfetch (from this commit) shell: bash @@ -94,7 +94,7 @@ jobs: export PATH="$BIN_DIR:$PATH" sfetch --repo fulmenhq/goneat \ - --tag v0.5.10 \ + --tag v0.5.13 \ --dest-dir "$BIN_DIR" \ --cache-dir "$CACHE_DIR" \ --require-minisign @@ -109,7 +109,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version: '1.26.4' - name: Install minisign shell: pwsh @@ -127,7 +127,7 @@ jobs: run: | .\bin\sfetch.exe ` --repo fulmenhq/goneat ` - --tag v0.5.10 ` + --tag v0.5.13 ` --dest-dir bin ` --require-minisign .\bin\goneat.exe version @@ -140,7 +140,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version: '1.26.4' - name: Install minisign shell: pwsh @@ -174,7 +174,7 @@ jobs: run: | .\bin\sfetch.exe ` --repo fulmenhq/goneat ` - --tag v0.5.10 ` + --tag v0.5.13 ` --dest-dir bin ` --require-minisign .\bin\goneat.exe version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b73e3c8..13a5d5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,34 +15,25 @@ env: jobs: create_release: runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} steps: - uses: actions/checkout@v5 - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # softprops/action-gh-release@v2 (Node 24) replaces the archived, + # Node12-era actions/create-release@v1 + actions/upload-release-asset@v1. + # It creates the release for the pushed tag (idempotent) and uploads the + # install script. The build matrix below appends per-platform archives to + # the same release. Maintainer signs + adds SHA256SUMS/notes afterward via + # scripts/ (see README "Manual signing workflow"). + - name: Create release and upload install script + uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref }} - release_name: sfetch ${{ github.ref }} + name: sfetch ${{ github.ref_name }} body: | - ## sfetch ${{ github.ref }} + ## sfetch ${{ github.ref_name }} Auto-generated release. draft: false prerelease: false - - - name: Upload install script - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: scripts/install-sfetch.sh - asset_name: install-sfetch.sh - asset_content_type: text/x-shellscript + files: scripts/install-sfetch.sh build: needs: create_release @@ -77,7 +68,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version: '1.26.4' - name: License check # go-licenses v1.6.0 errors on stdlib packages ("Package fmt does not @@ -128,19 +119,16 @@ jobs: # Per-file .sha256 removed - signed SHA256SUMS is the authoritative source # Maintainer generates SHA256SUMS locally and signs with minisign/pgp + # Appends this matrix entry's archive to the release created above. + # action-gh-release@v2 is idempotent on the tag; parallel matrix jobs each + # upload a distinctly-named asset, so no upload_url plumbing is needed. - name: Upload Archive - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@v2 with: - upload_url: ${{ needs.create_release.outputs.upload_url }} - asset_path: ${{ env.ASSET_FILE }} - asset_name: ${{ env.ASSET_FILE }} - asset_content_type: ${{ env.ASSET_CONTENT_TYPE }} + files: ${{ env.ASSET_FILE }} - name: Signing handled manually # NOTE: actionlint rejects constant expressions like `${{ false }}`. # Keep this step effectively disabled while leaving a breadcrumb. if: ${{ github.ref == 'refs/tags/__disabled__' }} run: echo "Signing occurs via scripts/sign-release.sh after CI" -... diff --git a/.goneat/tools.yaml b/.goneat/tools.yaml index c34a275..b9b0c05 100644 --- a/.goneat/tools.yaml +++ b/.goneat/tools.yaml @@ -71,8 +71,8 @@ tools: - scoop - winget version_scheme: semver - minimum_version: 1.21.0 - recommended_version: 1.22.0 + minimum_version: 1.25.5 + recommended_version: 1.26.4 golangci-lint: name: golangci-lint description: Fast linters runner for Go diff --git a/.yamlfmt b/.yamlfmt new file mode 100644 index 0000000..44c69c9 --- /dev/null +++ b/.yamlfmt @@ -0,0 +1,26 @@ +# Pinned yamlfmt config — stops the YAML formatting tug-of-war. +# +# Without this file, goneat's bundled yamlfmt and a standalone `yamlfmt` +# disagree on inline-comment padding: goneat deliberately writes two spaces +# before `#` (the yamllint-compatible default), while standalone yamlfmt +# defaults to one. That mismatch caused unrelated files (.goneat/*.yaml, +# .github/workflows/*) to flip back and forth and fail `make precommit`. +# +# Pinning here makes every yamlfmt invocation — goneat's `assess --categories +# format` and any bare `yamlfmt` (which auto-loads .yamlfmt from the repo +# root) — agree on two spaces, which is also what yamllint's `comments` rule +# expects by default. See the goneat appnote `yaml-format-lint-alignment` and +# https://github.com/google/yamlfmt/blob/main/docs/config-file.md. + +formatter: + type: basic + indent: 2 + line_ending: lf + # Two spaces before "#" in inline comments — matches goneat's pinned default + # and yamllint convention. This is the setting that actually stops the churn. + pad_line_comments: 2 + # sfetch house style differs from gonimbus: every YAML file opens with `---` + # and uses blank lines to separate top-level blocks. Preserve both so this + # config pins comment padding WITHOUT reformatting every workflow/config file. + include_document_start: true + retain_line_breaks: true diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..a749e0d --- /dev/null +++ b/.yamllint @@ -0,0 +1,11 @@ +extends: default + +rules: + # sfetch YAML consistently opens with `---`, but goneat runs yamllint in + # strict mode (.goneat/assess.yaml), so a single stray file without it would + # fail an unrelated `make precommit`. Disable rather than churn every file. + document-start: disable + # Workflow `run:` blocks, URLs, and signing commands routinely exceed 80 + # columns. Leaving line-length on forces cosmetic reflow churn on files that + # are otherwise fine; keep that out of mechanical config PRs. + line-length: disable diff --git a/CHANGELOG.md b/CHANGELOG.md index 221ca19..4600f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8] - 2026-06-22 + +### Changed +- **Go toolchain pinned to 1.26.4.** `go.mod` now declares `go 1.25.5` with `toolchain go1.26.4`, and CI/release `setup-go` pins `1.26.4` (was the floating `1.26`). Default builds (`GOTOOLCHAIN=auto`) transparently use the patched 1.26.4 standard library while the module still builds on Go 1.25.x, keeping us inside Go's supported-major window and clearing the stdlib advisories that source-based vulnerability scans were flagging. +- **Dependencies updated:** `golang.org/x/crypto` v0.47.0 → v0.53.0, `golang.org/x/text` v0.33.0 → v0.38.0, `golang.org/x/sys` v0.40.0 → v0.46.0, `github.com/dlclark/regexp2` v1.11.5 → v1.12.0, and `github.com/jedisct1/go-minisign` refreshed to its latest commit. `govulncheck ./...` reports no vulnerabilities. +- **goneat pinned to v0.5.13** (was v0.5.10) across the `Makefile` and the CI dogfood install steps. +- **Release workflow migrated off archived Node12 actions.** `actions/create-release@v1` and `actions/upload-release-asset@v1` (both archived, Node12-era) are replaced with `softprops/action-gh-release@v2` (Node 24), completing the Node 20 → 24 runner transition begun in v0.4.7 (which had already moved `checkout`/`setup-go`). + +### Added +- **Pinned YAML formatting standard.** Added repo-root `.yamlfmt` and `.yamllint` so goneat's bundled yamlfmt and any standalone yamlfmt agree on inline-comment padding (two spaces) and document-start handling. Ends the intermittent `make precommit`/CI churn on workflow and config files. The config preserves sfetch's `---` + blank-line house style rather than collapsing it. + +### Fixed +- Normalized `scripts/install-sfetch.sh` to 4-space indentation, matching the other `scripts/*.sh` and `.goneat/assess.yaml`'s shfmt `-i 4` setting. Whitespace-only; platform detection and argument handling verified unchanged. +- Re-applied go1.26 `gofmt` alignment to `internal/host/github/client.go`. + ## [0.4.7] - 2026-04-20 ### Removed @@ -132,107 +147,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation - Documented proxy support in `README.md`. -## [0.3.3] - 2026-01-10 - -### Documentation -- Added a local agent role catalog and operating model guidance for supervised sessions. - -## [0.3.2] - 2026-01-02 - -### Added -- **shellsentry integration:** Added shellsentry to `testdata/corpus.json` with minisign verification fixtures. -- **Test coverage improvements:** Comprehensive test suite expansion across 5 passes: - - Pass 1: Pure functions (inferBinaryName, archiveFormat, tokenCI, etc.) - - Pass 2: internal/verify edge cases (checksum detection, signature formats) - - Pass 3: CLI flag validation (mutual exclusivity, required flags, help/version) - - Pass 4: Asset selection logic (heuristics, match/regex, pattern rendering) - - Pass 5: Trust score calculation (algorithms, penalties, score capping) - -### Changed -- **stdout/stderr convention:** All human-readable output now goes to stderr; stdout reserved for JSON only. Affected: `--version`, `--version-extended`, `--self-verify`, `--show-trust-anchors`, `--dry-run`, `--helpextended`, success messages. This enables clean piping of JSON output in CI/CD pipelines. - -### Fixed -- **LICENSE:** Added project name notice and updated contact email. - -## [0.3.1] - 2025-12-31 - -### Fixed -- **Raw scripts no longer misclassified as archives:** Fixed regression where raw scripts (e.g., `install-sfetch.sh`) were incorrectly treated as archives when the default config includes `archiveType: "tar.gz"`. The legacy `archiveType` field now only applies to assets that are actually archives or have unknown type. - -### Documentation -- Added `make release-verify-checksums` step to `RELEASE_CHECKLIST.md`. - -## [0.3.0] - 2025-12-29 - -### Added -- **Trust rating system (v0.3.0):** Numeric trust score (0–100) with transparent factor breakdown. -- **Policy gating:** New `--trust-minimum <0-100>` blocks downloads below the threshold. -- **Workflow `none`:** Explicitly represents sources that provide no verification artifacts (distinct from bypass via `--insecure`). -- **Dogfood corpus expansion:** Corpus continues to live in `testdata/corpus.json` and is runnable via `make corpus-dryrun`. - -### Changed -- **Provenance schema:** Added `trust` object (`score`, `level`, `levelName`, `factors`) while retaining legacy `trustLevel` for one minor cycle. -- **CLI output:** Normal runs now print trust score; dry-run includes a verifiable/validated breakdown. - -### Fixed -- Clarified dry-run messaging to avoid implying integrity when signature artifacts exist but no verification key is available. - -## [0.2.9] - 2025-12-25 - -### Fixed -- **Asset selection now works for `minisign` and similar tool names:** Fixed false positive in supplemental file detection where tools containing "sig" in their name (like `minisign`, `cosign`, `design-tool`) were incorrectly excluded from asset selection. The fix removes the overly broad substring check and adds explicit `.minisig` suffix detection. - -### Documentation -- Added "Install permissions" section to README documenting permission behavior for archives, raw scripts/binaries, and cross-device installs. - -## [0.2.8] - 2025-12-14 - -### Added -- **Linux `noexec` detection (warn-only):** sfetch now warns when the install destination appears to be mounted with `noexec`. - -### Changed -- **Release notes source is now versioned:** `make release-notes` now requires `docs/releases/$RELEASE_TAG.md` and fails if missing. -- **More deterministic install behavior tests:** install logic is factored into a helper to enable unit tests for rename vs EXDEV copy fallback. - -### Security -- Expanded test coverage for ZIP extraction edge cases (zip slip, absolute paths, symlinks). - -## [0.2.7] - 2025-12-14 - -### Changed -- **ZIP extraction is now pure-Go:** `.zip` assets are extracted via the Go standard library (`archive/zip`), removing the runtime dependency on `unzip`. - -### Security -- **Hardened ZIP extraction:** ZIP slip/path traversal, absolute paths, and symlinks are rejected during extraction. - -## [0.2.6] - 2025-12-14 - -### Fixed -- **Cross-device installs/caching (EXDEV):** When `--dest-dir` or `--cache-dir` is on a different filesystem than the temp directory (common in containerized CI), sfetch now falls back to copy when `rename(2)` fails with "invalid cross-device link". - -### Changed -- Refactored internals to improve auditability and testability (moved logic into `internal/*` and introduced an injectable CLI entrypoint); CLI behavior is intended to be unchanged. - -### Documentation -- Added CI/CD usage guide: `docs/cicd-usage-guide.md`. - -## [0.2.5] - 2025-12-13 - -### Added -- **Self-update version check:** `--self-update` now skips reinstall when already at the target version; `--self-update-force` reinstalls; `--tag` allows explicit downgrades (major-version guard still applies). -- **Embedded self-update config:** Self-update uses an embedded, schema-backed update target config (`configs/update/sfetch.json`), with `--show-update-config` and `--validate-update-config`. -- **Update library (initial):** New `pkg/update` package exposes self-update decision logic for reuse. -- **Dry-run version info:** `--self-update --dry-run` now shows version comparison (current/target/status). - -### Changed -- Build now targets the package (not `./main.go`) so multi-file `main` builds work (`Makefile` `MAIN ?= .`). -- **Signing env vars standardized:** All signing-related environment variables now use an `SFETCH_` prefix for CI/scripting consistency: - - `MINISIGN_KEY` → `SFETCH_MINISIGN_KEY` - - `PGP_KEY_ID` → `SFETCH_PGP_KEY_ID` - - `GPG_HOMEDIR` → `SFETCH_GPG_HOMEDIR` - - Added `SFETCH_MINISIGN_PUB` for explicit public key path. -- Dev builds no longer require `--self-update-force` to proceed (easier exit path for developers). - --- > **Maintenance note:** This file is pruned to the latest 10 releases. For older entries, see `docs/releases/`. diff --git a/Makefile b/Makefile index 110c11c..8534fe2 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ BIN_DIR := $(CURDIR)/bin # Pinned tool versions (minimums; existing installs are respected) SFETCH_VERSION := v0.3.4 -GONEAT_VERSION ?= v0.5.10 +GONEAT_VERSION ?= v0.5.13 # Tool paths (sfetch bootstrap may land in bin/; goneat must be on PATH) SFETCH = $(shell [ -x "$(BIN_DIR)/sfetch" ] && echo "$(BIN_DIR)/sfetch" || command -v sfetch 2>/dev/null) diff --git a/README.md b/README.md index 86a49e1..5079a26 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # sfetch -[![Go 1.23+](https://img.shields.io/badge/Go-1.23+-00ADD8?logo=go&logoColor=white)](https://go.dev/) +[![Go 1.25+](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white)](https://go.dev/) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Release](https://img.shields.io/github/v/release/3leaps/sfetch?include_prereleases)](https://github.com/3leaps/sfetch/releases) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c03587a..1d5784b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,29 @@ +## v0.4.8 + +### Summary +Maintenance release: refreshes the Go toolchain and dependencies, completes the GitHub Actions Node 20 → 24 migration, bumps goneat, and pins a repo-wide YAML formatting standard. No user-facing feature or behavior changes. + +### Highlights + +**Toolchain & supply chain** +- `go.mod` pins `go 1.25.5` + `toolchain go1.26.4`; CI/release build with Go `1.26.4`. Binaries ship with the patched 1.26.4 standard library while the module still builds on Go 1.25.x. +- Updated `golang.org/x/crypto`, `golang.org/x/text`, `golang.org/x/sys`, `github.com/dlclark/regexp2`, and `github.com/jedisct1/go-minisign`. `govulncheck ./...` is clean. + +**CI / release** +- Replaced the archived Node12 `actions/create-release@v1` + `actions/upload-release-asset@v1` with `softprops/action-gh-release@v2` (Node 24), completing the runner transition started in v0.4.7. +- Pinned goneat to `v0.5.13`. + +**Developer experience** +- Added repo-root `.yamlfmt` and `.yamllint` so goneat and standalone yamlfmt no longer disagree on YAML formatting — ending intermittent `make precommit`/CI churn. sfetch's `---` + blank-line style is preserved. + +### Install + +```bash +curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash +``` + +--- + ## v0.4.7 ### Summary @@ -326,28 +352,3 @@ sfetch --self-update --yes ### Details - See `CHANGELOG.md` for the complete list. - ---- - -## v0.3.3 - -### Summary -Local agent role catalog and operating model guidance for supervised sessions. - -### Highlights -- Added `docs/agent-roles.md` for offline role guidance -- Clarified default role and operating model in `AGENTS.md` - -### Install - -```bash -curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash -``` - -Or self-update: -```bash -sfetch --self-update --yes -``` - -### Details -- See `CHANGELOG.md` for the complete list. diff --git a/VERSION b/VERSION index f905682..cb498ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.7 +0.4.8 diff --git a/docs/releases/v0.3.3.md b/docs/releases/v0.3.3.md new file mode 100644 index 0000000..33386bb --- /dev/null +++ b/docs/releases/v0.3.3.md @@ -0,0 +1,18 @@ +## Summary +Local agent role catalog and operating model guidance for supervised sessions. + +## Highlights +- Added `docs/agent-roles.md` for offline role guidance +- Clarified default role and operating model in `AGENTS.md` + +## Install + +```bash +curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash +``` + +Or self-update: + +```bash +sfetch --self-update --yes +``` diff --git a/docs/releases/v0.4.8.md b/docs/releases/v0.4.8.md new file mode 100644 index 0000000..f52f913 --- /dev/null +++ b/docs/releases/v0.4.8.md @@ -0,0 +1,36 @@ +## Summary +Maintenance release: refreshes the Go toolchain and dependencies, completes the GitHub Actions Node 20 → 24 migration, bumps the dogfooded goneat toolchain, and pins a repo-wide YAML formatting standard. No user-facing feature or behavior changes, no API changes. + +## Highlights + +### Toolchain & supply chain +- `go.mod` now pins `go 1.25.5` with `toolchain go1.26.4`; CI and the release workflow build with Go `1.26.4`. Released binaries ship with the patched 1.26.4 standard library while the module still builds on Go 1.25.x, keeping sfetch inside Go's supported-major window and clearing the stdlib advisories that source-based vulnerability scans were flagging. +- Updated `golang.org/x/crypto` (v0.53.0), `golang.org/x/text` (v0.38.0), `golang.org/x/sys` (v0.46.0), `github.com/dlclark/regexp2` (v1.12.0), and `github.com/jedisct1/go-minisign` (latest commit). `govulncheck ./...` reports no vulnerabilities. + +### CI / release +- Replaced the archived, Node12-era `actions/create-release@v1` and `actions/upload-release-asset@v1` with `softprops/action-gh-release@v2` (Node 24), completing the Node 20 → 24 runner transition begun in v0.4.7 (which had already moved `checkout`/`setup-go`). +- Pinned the dogfooded goneat toolchain to `v0.5.13` (was `v0.5.10`) across the `Makefile` and CI install steps. + +### Developer experience +- Added repo-root `.yamlfmt` and `.yamllint` so goneat's bundled yamlfmt and any standalone yamlfmt agree on inline-comment padding and document-start handling, ending intermittent `make precommit`/CI churn on workflow and config files. The configuration preserves sfetch's `---` + blank-line house style. +- Normalized `scripts/install-sfetch.sh` to 4-space indentation to match the other `scripts/*.sh` and the configured shfmt setting; whitespace-only, no behavior change. + +## Validation +- `make precommit` green (gosec 0 issues) +- `make test` / `go test ./...` green +- `govulncheck ./...` reports no vulnerabilities +- `go vet ./...` clean +- `make build-all` produces all five platform binaries +- Installer `detect_platform` and argument handling verified unchanged + +## Install + +```bash +curl -sSfL https://github.com/3leaps/sfetch/releases/latest/download/install-sfetch.sh | bash +``` + +Or self-update: + +```bash +sfetch --self-update --yes +``` diff --git a/go.mod b/go.mod index c7f447b..c5e2025 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,17 @@ module github.com/3leaps/sfetch -go 1.26.0 +go 1.25.5 + +toolchain go1.26.4 require ( - github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 + github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 ) require ( - github.com/dlclark/regexp2 v1.11.5 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 0bdeb64..a68b7cf 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,12 @@ -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= -github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22 h1:C68TAi+k12EKJCAmsdaERzQ22ZxVE6n+CuB3kOkhQ7c= +github.com/jedisct1/go-minisign v0.0.0-20260527172527-a09352b57a22/go.mod h1:vYVVh81Lqe/TP0sPLjiNYcX9Hxy/YSfkUx96lYJeyKo= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= diff --git a/internal/host/github/client.go b/internal/host/github/client.go index 64d7961..fb1c9bb 100644 --- a/internal/host/github/client.go +++ b/internal/host/github/client.go @@ -15,11 +15,11 @@ import ( type TokenSource string const ( - SourceNone TokenSource = "" - SourceSfetch TokenSource = "SFETCH_GITHUB_TOKEN" - SourceGhToken TokenSource = "GH_TOKEN" - SourceGithubTok TokenSource = "GITHUB_TOKEN" - SourceExplicit TokenSource = "--token-env" // prefix; actual env var name appended + SourceNone TokenSource = "" + SourceSfetch TokenSource = "SFETCH_GITHUB_TOKEN" + SourceGhToken TokenSource = "GH_TOKEN" + SourceGithubTok TokenSource = "GITHUB_TOKEN" + SourceExplicit TokenSource = "--token-env" // prefix; actual env var name appended ) // defaultChain is the precedence used when no explicit resolver is set. @@ -42,9 +42,9 @@ type TokenResolver interface { } var ( - resolverMu sync.RWMutex - resolver TokenResolver = defaultResolver{} - trustedHostFn func(url string) bool = defaultTrustedHost + resolverMu sync.RWMutex + resolver TokenResolver = defaultResolver{} + trustedHostFn func(url string) bool = defaultTrustedHost ) // trustedGitHubHosts enumerates exact hostnames that receive the GitHub diff --git a/scripts/install-sfetch.sh b/scripts/install-sfetch.sh index da1f264..4698f14 100755 --- a/scripts/install-sfetch.sh +++ b/scripts/install-sfetch.sh @@ -51,55 +51,55 @@ TRUST_LEVEL="unknown" log() { echo "==> $*" >&2; } warn() { echo "warning: $*" >&2; } err() { - echo "error: $*" >&2 - exit 1 + echo "error: $*" >&2 + exit 1 } need_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - err "required command not found: $1" - fi + if ! command -v "$1" >/dev/null 2>&1; then + err "required command not found: $1" + fi } read_release_tag_name() { - local release_json_file="$1" - local version="" + local release_json_file="$1" + local version="" - # Prefer jq when available (more robust than grep on JSON), but keep a - # dependency-free fallback for minimal bootstrap environments. - if command -v jq >/dev/null 2>&1; then - version=$(jq -r '.tag_name // empty' "$release_json_file" 2>/dev/null || true) - fi + # Prefer jq when available (more robust than grep on JSON), but keep a + # dependency-free fallback for minimal bootstrap environments. + if command -v jq >/dev/null 2>&1; then + version=$(jq -r '.tag_name // empty' "$release_json_file" 2>/dev/null || true) + fi - if [ -z "$version" ]; then - version=$(grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' "$release_json_file" | head -1 | cut -d'"' -f4 || true) - fi + if [ -z "$version" ]; then + version=$(grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' "$release_json_file" | head -1 | cut -d'"' -f4 || true) + fi - if [ -z "$version" ]; then - err "failed to parse release tag from GitHub API response" - fi + if [ -z "$version" ]; then + err "failed to parse release tag from GitHub API response" + fi - echo "$version" + echo "$version" } print_verifier_help() { - echo "To verify signatures, install one of:" - case "$(uname -s)" in - Darwin*) - echo " minisign (recommended): brew install minisign" - echo " gpg (fallback): brew install gnupg" - ;; - Linux*) - echo " minisign (recommended): apt install minisign # Debian/Ubuntu" - echo " brew install minisign # if using Homebrew" - echo " gpg (fallback): apt install gnupg" - ;; - MINGW* | MSYS* | CYGWIN*) - echo " minisign (recommended): scoop bucket add main && scoop install main/minisign" - echo " gpg (fallback): scoop install gpg" - ;; - esac - echo "If you cannot install a verifier here, verify on a trusted machine and copy the binary, or pass --allow-checksum-only (NOT recommended; no authenticity)." + echo "To verify signatures, install one of:" + case "$(uname -s)" in + Darwin*) + echo " minisign (recommended): brew install minisign" + echo " gpg (fallback): brew install gnupg" + ;; + Linux*) + echo " minisign (recommended): apt install minisign # Debian/Ubuntu" + echo " brew install minisign # if using Homebrew" + echo " gpg (fallback): apt install gnupg" + ;; + MINGW* | MSYS* | CYGWIN*) + echo " minisign (recommended): scoop bucket add main && scoop install main/minisign" + echo " gpg (fallback): scoop install gpg" + ;; + esac + echo "If you cannot install a verifier here, verify on a trusted machine and copy the binary, or pass --allow-checksum-only (NOT recommended; no authenticity)." } # ----------------------------------------------------------------------------- @@ -107,78 +107,78 @@ print_verifier_help() { # ----------------------------------------------------------------------------- detect_windows_arch() { - local arch_hint - - # GitHub-hosted runners expose the native runner architecture via RUNNER_ARCH, - # which stays accurate even when Git Bash and spawned processes are emulated. - arch_hint=$(printf '%s' "${RUNNER_ARCH:-}" | tr '[:upper:]' '[:lower:]') - case "$arch_hint" in - amd64 | x64 | x86_64) - echo "amd64" - return 0 - ;; - arm64 | aarch64) - echo "arm64" - return 0 - ;; - esac - - # GitHub's Windows ARM64 runners invoke Git Bash under x64 emulation, and - # both uname -m and PROCESSOR_ARCHITECTURE can still report amd64 there. - # Ask native PowerShell for the host OS architecture next. - if command -v powershell.exe >/dev/null 2>&1; then - arch_hint=$(powershell.exe -NoProfile -NonInteractive -Command "[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture" 2>/dev/null | tr -d '\r' | tr '[:upper:]' '[:lower:]' | head -n1) - case "$arch_hint" in - amd64 | x64 | x86_64) - echo "amd64" - return 0 - ;; - arm64 | aarch64) - echo "arm64" - return 0 - ;; - esac - fi - - arch_hint=$(printf '%s' "${PROCESSOR_ARCHITEW6432:-${PROCESSOR_ARCHITECTURE:-}}" | tr '[:upper:]' '[:lower:]') - case "$arch_hint" in - amd64 | x64 | x86_64) - echo "amd64" - return 0 - ;; - arm64 | aarch64) - echo "arm64" - return 0 - ;; - esac - - return 1 + local arch_hint + + # GitHub-hosted runners expose the native runner architecture via RUNNER_ARCH, + # which stays accurate even when Git Bash and spawned processes are emulated. + arch_hint=$(printf '%s' "${RUNNER_ARCH:-}" | tr '[:upper:]' '[:lower:]') + case "$arch_hint" in + amd64 | x64 | x86_64) + echo "amd64" + return 0 + ;; + arm64 | aarch64) + echo "arm64" + return 0 + ;; + esac + + # GitHub's Windows ARM64 runners invoke Git Bash under x64 emulation, and + # both uname -m and PROCESSOR_ARCHITECTURE can still report amd64 there. + # Ask native PowerShell for the host OS architecture next. + if command -v powershell.exe >/dev/null 2>&1; then + arch_hint=$(powershell.exe -NoProfile -NonInteractive -Command "[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture" 2>/dev/null | tr -d '\r' | tr '[:upper:]' '[:lower:]' | head -n1) + case "$arch_hint" in + amd64 | x64 | x86_64) + echo "amd64" + return 0 + ;; + arm64 | aarch64) + echo "arm64" + return 0 + ;; + esac + fi + + arch_hint=$(printf '%s' "${PROCESSOR_ARCHITEW6432:-${PROCESSOR_ARCHITECTURE:-}}" | tr '[:upper:]' '[:lower:]') + case "$arch_hint" in + amd64 | x64 | x86_64) + echo "amd64" + return 0 + ;; + arm64 | aarch64) + echo "arm64" + return 0 + ;; + esac + + return 1 } detect_platform() { - local os arch - - case "$(uname -s)" in - Linux*) os="linux" ;; - Darwin*) os="darwin" ;; - MINGW* | MSYS* | CYGWIN*) os="windows" ;; - *) err "unsupported OS: $(uname -s)" ;; - esac - - if [ "$os" = "windows" ]; then - if arch=$(detect_windows_arch); then - echo "${os}_${arch}" - return - fi - fi - - case "$(uname -m)" in - x86_64 | amd64) arch="amd64" ;; - arm64 | aarch64) arch="arm64" ;; - *) err "unsupported architecture: $(uname -m)" ;; - esac - - echo "${os}_${arch}" + local os arch + + case "$(uname -s)" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + MINGW* | MSYS* | CYGWIN*) os="windows" ;; + *) err "unsupported OS: $(uname -s)" ;; + esac + + if [ "$os" = "windows" ]; then + if arch=$(detect_windows_arch); then + echo "${os}_${arch}" + return + fi + fi + + case "$(uname -m)" in + x86_64 | amd64) arch="amd64" ;; + arm64 | aarch64) arch="arm64" ;; + *) err "unsupported architecture: $(uname -m)" ;; + esac + + echo "${os}_${arch}" } # ----------------------------------------------------------------------------- @@ -186,52 +186,52 @@ detect_platform() { # ----------------------------------------------------------------------------- check_verification_tools() { - local has_minisign=false - local has_gpg=false - - if command -v minisign >/dev/null 2>&1; then - has_minisign=true - fi - - if command -v gpg >/dev/null 2>&1; then - has_gpg=true - fi - - if [ "$has_minisign" = false ] && [ "$has_gpg" = false ]; then - warn "no signature verification tools found" - echo "" - echo "For signature verification, install one of:" - echo "" - case "$(uname -s)" in - Darwin*) - echo " minisign (recommended):" - echo " brew install minisign" - echo "" - echo " gpg:" - echo " brew install gnupg" - ;; - Linux*) - echo " minisign (recommended):" - echo " brew install minisign # if using Homebrew" - echo " apt install minisign # Debian/Ubuntu" - echo "" - echo " gpg:" - echo " apt install gnupg # Debian/Ubuntu" - ;; - MINGW* | MSYS* | CYGWIN*) - echo " minisign (recommended):" - echo " scoop bucket add main" - echo " scoop install main/minisign" - echo "" - echo " gpg:" - echo " scoop install gpg" - ;; - esac - echo "" - fi - - VERIFY_MINISIGN=$has_minisign - VERIFY_GPG=$has_gpg + local has_minisign=false + local has_gpg=false + + if command -v minisign >/dev/null 2>&1; then + has_minisign=true + fi + + if command -v gpg >/dev/null 2>&1; then + has_gpg=true + fi + + if [ "$has_minisign" = false ] && [ "$has_gpg" = false ]; then + warn "no signature verification tools found" + echo "" + echo "For signature verification, install one of:" + echo "" + case "$(uname -s)" in + Darwin*) + echo " minisign (recommended):" + echo " brew install minisign" + echo "" + echo " gpg:" + echo " brew install gnupg" + ;; + Linux*) + echo " minisign (recommended):" + echo " brew install minisign # if using Homebrew" + echo " apt install minisign # Debian/Ubuntu" + echo "" + echo " gpg:" + echo " apt install gnupg # Debian/Ubuntu" + ;; + MINGW* | MSYS* | CYGWIN*) + echo " minisign (recommended):" + echo " scoop bucket add main" + echo " scoop install main/minisign" + echo "" + echo " gpg:" + echo " scoop install gpg" + ;; + esac + echo "" + fi + + VERIFY_MINISIGN=$has_minisign + VERIFY_GPG=$has_gpg } # ----------------------------------------------------------------------------- @@ -239,50 +239,50 @@ check_verification_tools() { # ----------------------------------------------------------------------------- fetch() { - local url="$1" - local dest="$2" - - if command -v curl >/dev/null 2>&1; then - curl -sSfL -o "$dest" "$url" - elif command -v wget >/dev/null 2>&1; then - wget -q -O "$dest" "$url" - else - err "curl or wget required" - fi + local url="$1" + local dest="$2" + + if command -v curl >/dev/null 2>&1; then + curl -sSfL -o "$dest" "$url" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$dest" "$url" + else + err "curl or wget required" + fi } fetch_json() { - local url="$1" - - # Authenticated GitHub API requests (avoid CI/shared-runner rate limits). - # Prefer the sfetch-specific env var, then standard GitHub tokens. - local token="" - if [ -n "${SFETCH_GITHUB_TOKEN:-}" ]; then - token="$SFETCH_GITHUB_TOKEN" - elif [ -n "${GITHUB_TOKEN:-}" ]; then - token="$GITHUB_TOKEN" - elif [ -n "${GH_TOKEN:-}" ]; then - token="$GH_TOKEN" - fi - - local accept_header="Accept: application/vnd.github.v3+json" - - if command -v curl >/dev/null 2>&1; then - if [ -n "$token" ]; then - # Avoid following redirects when sending auth headers. - curl -sSf -H "$accept_header" -H "Authorization: Bearer ${token}" "$url" - else - curl -sSf -H "$accept_header" "$url" - fi - elif command -v wget >/dev/null 2>&1; then - if [ -n "$token" ]; then - wget -q -O - --header="$accept_header" --header="Authorization: Bearer ${token}" "$url" - else - wget -q -O - --header="$accept_header" "$url" - fi - else - err "curl or wget required" - fi + local url="$1" + + # Authenticated GitHub API requests (avoid CI/shared-runner rate limits). + # Prefer the sfetch-specific env var, then standard GitHub tokens. + local token="" + if [ -n "${SFETCH_GITHUB_TOKEN:-}" ]; then + token="$SFETCH_GITHUB_TOKEN" + elif [ -n "${GITHUB_TOKEN:-}" ]; then + token="$GITHUB_TOKEN" + elif [ -n "${GH_TOKEN:-}" ]; then + token="$GH_TOKEN" + fi + + local accept_header="Accept: application/vnd.github.v3+json" + + if command -v curl >/dev/null 2>&1; then + if [ -n "$token" ]; then + # Avoid following redirects when sending auth headers. + curl -sSf -H "$accept_header" -H "Authorization: Bearer ${token}" "$url" + else + curl -sSf -H "$accept_header" "$url" + fi + elif command -v wget >/dev/null 2>&1; then + if [ -n "$token" ]; then + wget -q -O - --header="$accept_header" --header="Authorization: Bearer ${token}" "$url" + else + wget -q -O - --header="$accept_header" "$url" + fi + else + err "curl or wget required" + fi } # ----------------------------------------------------------------------------- @@ -290,21 +290,21 @@ fetch_json() { # ----------------------------------------------------------------------------- verify_checksum() { - local file="$1" - local expected="$2" - local actual - - if command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$file" | cut -d' ' -f1) - elif command -v shasum >/dev/null 2>&1; then - actual=$(shasum -a 256 "$file" | cut -d' ' -f1) - else - err "sha256sum or shasum required" - fi - - if [ "$actual" != "$expected" ]; then - err "checksum mismatch for $(basename "$file")" - fi + local file="$1" + local expected="$2" + local actual + + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$file" | cut -d' ' -f1) + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$file" | cut -d' ' -f1) + else + err "sha256sum or shasum required" + fi + + if [ "$actual" != "$expected" ]; then + err "checksum mismatch for $(basename "$file")" + fi } # ----------------------------------------------------------------------------- @@ -312,70 +312,70 @@ verify_checksum() { # ----------------------------------------------------------------------------- verify_signature() { - local sums_file="$1" - local tmpdir="$2" - local verified=false - TRUST_LEVEL="checksum-only" - - # Try minisign first (preferred - uses embedded trust anchor) - if [ "$VERIFY_MINISIGN" = true ] && [ -f "${sums_file}.minisig" ]; then - local pubkey_file="${tmpdir}/sfetch-minisign.pub" - echo "untrusted comment: sfetch release signing key" >"$pubkey_file" - echo "$SFETCH_MINISIGN_PUBKEY" >>"$pubkey_file" - - log "Verifying signature with minisign (embedded trust anchor)..." - if minisign -Vm "$sums_file" -p "$pubkey_file" >/dev/null 2>&1; then - log "Minisign signature verified" - verified=true - TRUST_LEVEL="high (minisign)" - else - err "minisign signature verification failed" - fi - fi - - if [ "$verified" = false ] && [ "$REQUIRE_MINISIGN" = true ]; then - if [ "$VERIFY_MINISIGN" = false ]; then - err "minisign is required; install minisign or pass --allow-checksum-only (not recommended)" - fi - if [ ! -f "${sums_file}.minisig" ]; then - err "SHA256SUMS.minisig missing; cannot verify (pass --allow-checksum-only to override)" - fi - fi - - # Try GPG if minisign didn't verify and minisign is not required - if [ "$verified" = false ] && [ "$REQUIRE_MINISIGN" = false ] && [ "$VERIFY_GPG" = true ] && [ -f "${sums_file}.asc" ]; then - local gpg_key="${tmpdir}/sfetch-release-signing-key.asc" - if [ -f "$gpg_key" ]; then - local fpr - fpr=$(gpg --with-colons --import-options show-only --fingerprint "$gpg_key" 2>/dev/null | awk -F: '/^fpr:/ {print $10; exit}') - if [ "$fpr" != "$SFETCH_PGP_FPR" ]; then - err "GPG key fingerprint mismatch (expected ${SFETCH_PGP_FPR}, got ${fpr:-unknown})" - fi - log "Verifying signature with gpg (pinned fingerprint)..." - local gpg_home - gpg_home=$(mktemp -d) - if gpg --batch --no-tty --homedir "$gpg_home" --import "$gpg_key" 2>/dev/null && - gpg --batch --no-tty --homedir "$gpg_home" --trust-model always \ - --verify "${sums_file}.asc" "$sums_file" 2>/dev/null; then - log "GPG signature verified" - verified=true - TRUST_LEVEL="medium (gpg, pinned key)" - else - err "GPG signature verification failed" - fi - rm -rf "$gpg_home" - else - warn "GPG public key not found in release" - fi - fi - - if [ "$verified" = false ]; then - if [ "$REQUIRE_SIGNATURE" = true ]; then - err "signature verification required; install minisign (recommended) or use --allow-checksum-only to bypass (NOT recommended)" - fi - warn "no signature verified - proceeding with checksum only (trust: low)" - TRUST_LEVEL="low (checksum-only)" - fi + local sums_file="$1" + local tmpdir="$2" + local verified=false + TRUST_LEVEL="checksum-only" + + # Try minisign first (preferred - uses embedded trust anchor) + if [ "$VERIFY_MINISIGN" = true ] && [ -f "${sums_file}.minisig" ]; then + local pubkey_file="${tmpdir}/sfetch-minisign.pub" + echo "untrusted comment: sfetch release signing key" >"$pubkey_file" + echo "$SFETCH_MINISIGN_PUBKEY" >>"$pubkey_file" + + log "Verifying signature with minisign (embedded trust anchor)..." + if minisign -Vm "$sums_file" -p "$pubkey_file" >/dev/null 2>&1; then + log "Minisign signature verified" + verified=true + TRUST_LEVEL="high (minisign)" + else + err "minisign signature verification failed" + fi + fi + + if [ "$verified" = false ] && [ "$REQUIRE_MINISIGN" = true ]; then + if [ "$VERIFY_MINISIGN" = false ]; then + err "minisign is required; install minisign or pass --allow-checksum-only (not recommended)" + fi + if [ ! -f "${sums_file}.minisig" ]; then + err "SHA256SUMS.minisig missing; cannot verify (pass --allow-checksum-only to override)" + fi + fi + + # Try GPG if minisign didn't verify and minisign is not required + if [ "$verified" = false ] && [ "$REQUIRE_MINISIGN" = false ] && [ "$VERIFY_GPG" = true ] && [ -f "${sums_file}.asc" ]; then + local gpg_key="${tmpdir}/sfetch-release-signing-key.asc" + if [ -f "$gpg_key" ]; then + local fpr + fpr=$(gpg --with-colons --import-options show-only --fingerprint "$gpg_key" 2>/dev/null | awk -F: '/^fpr:/ {print $10; exit}') + if [ "$fpr" != "$SFETCH_PGP_FPR" ]; then + err "GPG key fingerprint mismatch (expected ${SFETCH_PGP_FPR}, got ${fpr:-unknown})" + fi + log "Verifying signature with gpg (pinned fingerprint)..." + local gpg_home + gpg_home=$(mktemp -d) + if gpg --batch --no-tty --homedir "$gpg_home" --import "$gpg_key" 2>/dev/null && + gpg --batch --no-tty --homedir "$gpg_home" --trust-model always \ + --verify "${sums_file}.asc" "$sums_file" 2>/dev/null; then + log "GPG signature verified" + verified=true + TRUST_LEVEL="medium (gpg, pinned key)" + else + err "GPG signature verification failed" + fi + rm -rf "$gpg_home" + else + warn "GPG public key not found in release" + fi + fi + + if [ "$verified" = false ]; then + if [ "$REQUIRE_SIGNATURE" = true ]; then + err "signature verification required; install minisign (recommended) or use --allow-checksum-only to bypass (NOT recommended)" + fi + warn "no signature verified - proceeding with checksum only (trust: low)" + TRUST_LEVEL="low (checksum-only)" + fi } # ----------------------------------------------------------------------------- @@ -383,42 +383,42 @@ verify_signature() { # ----------------------------------------------------------------------------- install_binary() { - local src="$1" - local dest_dir="$2" - local platform="$3" - local binary_name="sfetch" - - # Windows needs .exe extension - if [[ "$platform" == windows_* ]]; then - binary_name="sfetch.exe" - fi - - local dest="${dest_dir}/${binary_name}" - - # Create destination directory - mkdir -p "$dest_dir" - - # Copy binary - cp "$src" "$dest" - chmod +x "$dest" - - log "Installed ${binary_name} to ${dest}" - log "Trust: ${TRUST_LEVEL}" - log "To verify this installation later: sfetch --self-verify" - - # Path advice - case ":$PATH:" in - *":${dest_dir}:"*) ;; - *) - echo "" - echo "Add to your PATH:" - if [[ "$platform" == windows_* ]]; then - echo " setx PATH \"%PATH%;${dest_dir}\"" - else - echo " export PATH=\"${dest_dir}:\$PATH\"" - fi - ;; - esac + local src="$1" + local dest_dir="$2" + local platform="$3" + local binary_name="sfetch" + + # Windows needs .exe extension + if [[ "$platform" == windows_* ]]; then + binary_name="sfetch.exe" + fi + + local dest="${dest_dir}/${binary_name}" + + # Create destination directory + mkdir -p "$dest_dir" + + # Copy binary + cp "$src" "$dest" + chmod +x "$dest" + + log "Installed ${binary_name} to ${dest}" + log "Trust: ${TRUST_LEVEL}" + log "To verify this installation later: sfetch --self-verify" + + # Path advice + case ":$PATH:" in + *":${dest_dir}:"*) ;; + *) + echo "" + echo "Add to your PATH:" + if [[ "$platform" == windows_* ]]; then + echo " setx PATH \"%PATH%;${dest_dir}\"" + else + echo " export PATH=\"${dest_dir}:\$PATH\"" + fi + ;; + esac } # ----------------------------------------------------------------------------- @@ -426,243 +426,243 @@ install_binary() { # ----------------------------------------------------------------------------- main() { - local tag="latest" - local install_dir="" - local dry_run=false - local yes=false - local require_signature=true - local require_minisign=true - - # Parse arguments - while [ $# -gt 0 ]; do - case "$1" in - --tag) - tag="$2" - shift 2 - ;; - --dir) - install_dir="$2" - shift 2 - ;; - --dry-run) - dry_run=true - shift - ;; - --yes) - yes=true - shift - ;; - --allow-checksum-only) - require_signature=false - require_minisign=false - shift - ;; - --require-signature) - require_signature=true - shift - ;; - --require-minisign) - require_minisign=true - shift - ;; - --no-require-signature) - require_signature=false - shift - ;; - --no-require-minisign) - require_minisign=false - shift - ;; - --help | -h) - head -25 "$0" | tail -20 - exit 0 - ;; - *) - err "unknown option: $1" - ;; - esac - done - - REQUIRE_SIGNATURE=$require_signature - REQUIRE_MINISIGN=$require_minisign - - # Detect platform - local platform - platform=$(detect_platform) - log "Detected platform: ${platform}" - - # macOS Intel (darwin/amd64) artifacts end at v0.4.6. Block unversioned - # installs — which default to "latest" and would 404 on the missing - # asset — but honor an explicit --tag so the documented recovery path - # (--tag v0.4.6) actually works. An invalid tag on Intel Mac still - # 404s with its own error rather than being masked by this guard. - if [ "$platform" = "darwin_amd64" ] && [ "$tag" = "latest" ]; then - err "darwin/amd64 (Intel Mac) is no longer supported as of sfetch v0.4.7. Pin an explicit --tag v0.4.6 to install the last supporting release, or use Apple Silicon." - fi - - # Set default install directory - if [ -z "$install_dir" ]; then - if [[ "$platform" == windows_* ]]; then - install_dir="${USERPROFILE:-$HOME}/bin" - else - install_dir="${HOME}/.local/bin" - fi - fi - - # Check verification tools - check_verification_tools - if [ "$REQUIRE_MINISIGN" = true ] && [ "${VERIFY_MINISIGN:-false}" = false ]; then - print_verifier_help - err "minisign is required; install minisign or pass --allow-checksum-only (NOT recommended)" - fi - if [ "$REQUIRE_SIGNATURE" = true ] && [ "${VERIFY_MINISIGN:-false}" = false ] && [ "${VERIFY_GPG:-false}" = false ]; then - print_verifier_help - err "signature verification required; install minisign (recommended) or gpg, or pass --allow-checksum-only to bypass (NOT recommended)" - fi - - # Create temp directory (not local - needed for EXIT trap) - tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT - - # Fetch release info - local release_url - if [ "$tag" = "latest" ]; then - release_url="${SFETCH_API}/latest" - else - release_url="${SFETCH_API}/tags/${tag}" - fi - - log "Fetching release info..." - local release_json="${tmpdir}/release.json" - fetch_json "$release_url" >"$release_json" - - local version - version=$(read_release_tag_name "$release_json") - log "Installing sfetch ${version}" - - # Determine archive name - local archive_name="sfetch_${platform}" - if [[ "$platform" == windows_* ]]; then - archive_name="${archive_name}.zip" - else - archive_name="${archive_name}.tar.gz" - fi - - # Download assets - local base_url="https://github.com/${SFETCH_REPO}/releases/download/${version}" - - log "Downloading assets..." - fetch "${base_url}/SHA256SUMS" "${tmpdir}/SHA256SUMS" - fetch "${base_url}/${archive_name}" "${tmpdir}/${archive_name}" - - # Download signature files (optional) - # Note: minisign pubkey is embedded in this script (trust anchor), not fetched - fetch "${base_url}/SHA256SUMS.minisig" "${tmpdir}/SHA256SUMS.minisig" 2>/dev/null || true - fetch "${base_url}/SHA256SUMS.asc" "${tmpdir}/SHA256SUMS.asc" 2>/dev/null || true - fetch "${base_url}/sfetch-release-signing-key.asc" "${tmpdir}/sfetch-release-signing-key.asc" 2>/dev/null || true - - # Verify signature on SHA256SUMS - verify_signature "${tmpdir}/SHA256SUMS" "$tmpdir" - - # Verify archive checksum - log "Verifying checksum..." - local expected_hash - expected_hash=$(grep "${archive_name}" "${tmpdir}/SHA256SUMS" | cut -d' ' -f1) - if [ -z "$expected_hash" ]; then - err "archive not found in SHA256SUMS: ${archive_name}" - fi - verify_checksum "${tmpdir}/${archive_name}" "$expected_hash" - log "Checksum verified" - - # Dry run stops here - if [ "$dry_run" = true ]; then - log "Dry run complete - verification passed" - exit 0 - fi - - # Extract - log "Extracting..." - local extract_dir="${tmpdir}/extract" - mkdir -p "$extract_dir" - - # List archive entries to guard against zip-slip/path traversal. - # Reject absolute paths, Windows drive-letter paths, and parent-dir traversal segments. - # This runs BEFORE extraction so a malicious archive cannot write outside $extract_dir. - local entry - local list_cmd=() - - if [[ "$archive_name" == *.zip ]]; then - need_cmd unzip - if unzip -Z1 "${tmpdir}/${archive_name}" >/dev/null 2>&1; then - list_cmd=(unzip -Z1 "${tmpdir}/${archive_name}") - else - # Portability: some unzip builds lack `-Z1`; fall back to parsing `unzip -l` output. - # We treat this as an internal listing mechanism only; extraction still uses unzip itself. - list_cmd=(sh -c "unzip -l \"${tmpdir}/${archive_name}\" | awk 'NR>3 {print \$NF}' | sed '/^$/d'") - fi - else - need_cmd tar - list_cmd=(tar -tzf "${tmpdir}/${archive_name}") - fi - - while IFS= read -r entry; do - # Normalize any leading "./" segments (tar often prefixes paths). - while [[ "$entry" == ./* ]]; do - entry="${entry#./}" - done - if [[ -z "$entry" ]]; then - continue - fi - - # Absolute paths (POSIX/Windows) and drive-letter paths. - if [[ "$entry" == /* ]] || [[ "$entry" == \\* ]] || [[ "$entry" =~ ^[A-Za-z]: ]]; then - err "unsafe path in archive entry: $entry" - fi - - # Parent directory traversal (POSIX style) - if [[ "$entry" == ".." ]] || [[ "$entry" == ../* ]] || [[ "$entry" == */../* ]] || [[ "$entry" == */.. ]]; then - err "unsafe path in archive entry: $entry" - fi - - # Parent directory traversal (Windows style) - if [[ "$entry" == ..\\* ]] || [[ "$entry" == *"\\..\\"* ]] || [[ "$entry" == *"\\.." ]]; then - err "unsafe path in archive entry: $entry" - fi - done < <("${list_cmd[@]}") - - if [[ "$archive_name" == *.zip ]]; then - unzip -q "${tmpdir}/${archive_name}" -d "$extract_dir" - else - tar -xzf "${tmpdir}/${archive_name}" -C "$extract_dir" - fi - - # Find binary - local binary - binary=$(find "$extract_dir" -type f -name "sfetch*" | head -1) - if [ -z "$binary" ]; then - err "binary not found in archive" - fi - - # Confirm installation - if [ "$yes" = false ] && [ -t 0 ]; then - echo "" - echo "Ready to install sfetch ${version} to ${install_dir}" - echo "Verification: ${TRUST_LEVEL}" - printf "Continue? [Y/n] " - read -r confirm - case "$confirm" in - [nN]*) - echo "Aborted." - exit 1 - ;; - esac - fi - - # Install - install_binary "$binary" "$install_dir" "$platform" - - echo "" - log "Done! Run 'sfetch --help' to get started." + local tag="latest" + local install_dir="" + local dry_run=false + local yes=false + local require_signature=true + local require_minisign=true + + # Parse arguments + while [ $# -gt 0 ]; do + case "$1" in + --tag) + tag="$2" + shift 2 + ;; + --dir) + install_dir="$2" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + --yes) + yes=true + shift + ;; + --allow-checksum-only) + require_signature=false + require_minisign=false + shift + ;; + --require-signature) + require_signature=true + shift + ;; + --require-minisign) + require_minisign=true + shift + ;; + --no-require-signature) + require_signature=false + shift + ;; + --no-require-minisign) + require_minisign=false + shift + ;; + --help | -h) + head -25 "$0" | tail -20 + exit 0 + ;; + *) + err "unknown option: $1" + ;; + esac + done + + REQUIRE_SIGNATURE=$require_signature + REQUIRE_MINISIGN=$require_minisign + + # Detect platform + local platform + platform=$(detect_platform) + log "Detected platform: ${platform}" + + # macOS Intel (darwin/amd64) artifacts end at v0.4.6. Block unversioned + # installs — which default to "latest" and would 404 on the missing + # asset — but honor an explicit --tag so the documented recovery path + # (--tag v0.4.6) actually works. An invalid tag on Intel Mac still + # 404s with its own error rather than being masked by this guard. + if [ "$platform" = "darwin_amd64" ] && [ "$tag" = "latest" ]; then + err "darwin/amd64 (Intel Mac) is no longer supported as of sfetch v0.4.7. Pin an explicit --tag v0.4.6 to install the last supporting release, or use Apple Silicon." + fi + + # Set default install directory + if [ -z "$install_dir" ]; then + if [[ "$platform" == windows_* ]]; then + install_dir="${USERPROFILE:-$HOME}/bin" + else + install_dir="${HOME}/.local/bin" + fi + fi + + # Check verification tools + check_verification_tools + if [ "$REQUIRE_MINISIGN" = true ] && [ "${VERIFY_MINISIGN:-false}" = false ]; then + print_verifier_help + err "minisign is required; install minisign or pass --allow-checksum-only (NOT recommended)" + fi + if [ "$REQUIRE_SIGNATURE" = true ] && [ "${VERIFY_MINISIGN:-false}" = false ] && [ "${VERIFY_GPG:-false}" = false ]; then + print_verifier_help + err "signature verification required; install minisign (recommended) or gpg, or pass --allow-checksum-only to bypass (NOT recommended)" + fi + + # Create temp directory (not local - needed for EXIT trap) + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + # Fetch release info + local release_url + if [ "$tag" = "latest" ]; then + release_url="${SFETCH_API}/latest" + else + release_url="${SFETCH_API}/tags/${tag}" + fi + + log "Fetching release info..." + local release_json="${tmpdir}/release.json" + fetch_json "$release_url" >"$release_json" + + local version + version=$(read_release_tag_name "$release_json") + log "Installing sfetch ${version}" + + # Determine archive name + local archive_name="sfetch_${platform}" + if [[ "$platform" == windows_* ]]; then + archive_name="${archive_name}.zip" + else + archive_name="${archive_name}.tar.gz" + fi + + # Download assets + local base_url="https://github.com/${SFETCH_REPO}/releases/download/${version}" + + log "Downloading assets..." + fetch "${base_url}/SHA256SUMS" "${tmpdir}/SHA256SUMS" + fetch "${base_url}/${archive_name}" "${tmpdir}/${archive_name}" + + # Download signature files (optional) + # Note: minisign pubkey is embedded in this script (trust anchor), not fetched + fetch "${base_url}/SHA256SUMS.minisig" "${tmpdir}/SHA256SUMS.minisig" 2>/dev/null || true + fetch "${base_url}/SHA256SUMS.asc" "${tmpdir}/SHA256SUMS.asc" 2>/dev/null || true + fetch "${base_url}/sfetch-release-signing-key.asc" "${tmpdir}/sfetch-release-signing-key.asc" 2>/dev/null || true + + # Verify signature on SHA256SUMS + verify_signature "${tmpdir}/SHA256SUMS" "$tmpdir" + + # Verify archive checksum + log "Verifying checksum..." + local expected_hash + expected_hash=$(grep "${archive_name}" "${tmpdir}/SHA256SUMS" | cut -d' ' -f1) + if [ -z "$expected_hash" ]; then + err "archive not found in SHA256SUMS: ${archive_name}" + fi + verify_checksum "${tmpdir}/${archive_name}" "$expected_hash" + log "Checksum verified" + + # Dry run stops here + if [ "$dry_run" = true ]; then + log "Dry run complete - verification passed" + exit 0 + fi + + # Extract + log "Extracting..." + local extract_dir="${tmpdir}/extract" + mkdir -p "$extract_dir" + + # List archive entries to guard against zip-slip/path traversal. + # Reject absolute paths, Windows drive-letter paths, and parent-dir traversal segments. + # This runs BEFORE extraction so a malicious archive cannot write outside $extract_dir. + local entry + local list_cmd=() + + if [[ "$archive_name" == *.zip ]]; then + need_cmd unzip + if unzip -Z1 "${tmpdir}/${archive_name}" >/dev/null 2>&1; then + list_cmd=(unzip -Z1 "${tmpdir}/${archive_name}") + else + # Portability: some unzip builds lack `-Z1`; fall back to parsing `unzip -l` output. + # We treat this as an internal listing mechanism only; extraction still uses unzip itself. + list_cmd=(sh -c "unzip -l \"${tmpdir}/${archive_name}\" | awk 'NR>3 {print \$NF}' | sed '/^$/d'") + fi + else + need_cmd tar + list_cmd=(tar -tzf "${tmpdir}/${archive_name}") + fi + + while IFS= read -r entry; do + # Normalize any leading "./" segments (tar often prefixes paths). + while [[ "$entry" == ./* ]]; do + entry="${entry#./}" + done + if [[ -z "$entry" ]]; then + continue + fi + + # Absolute paths (POSIX/Windows) and drive-letter paths. + if [[ "$entry" == /* ]] || [[ "$entry" == \\* ]] || [[ "$entry" =~ ^[A-Za-z]: ]]; then + err "unsafe path in archive entry: $entry" + fi + + # Parent directory traversal (POSIX style) + if [[ "$entry" == ".." ]] || [[ "$entry" == ../* ]] || [[ "$entry" == */../* ]] || [[ "$entry" == */.. ]]; then + err "unsafe path in archive entry: $entry" + fi + + # Parent directory traversal (Windows style) + if [[ "$entry" == ..\\* ]] || [[ "$entry" == *"\\..\\"* ]] || [[ "$entry" == *"\\.." ]]; then + err "unsafe path in archive entry: $entry" + fi + done < <("${list_cmd[@]}") + + if [[ "$archive_name" == *.zip ]]; then + unzip -q "${tmpdir}/${archive_name}" -d "$extract_dir" + else + tar -xzf "${tmpdir}/${archive_name}" -C "$extract_dir" + fi + + # Find binary + local binary + binary=$(find "$extract_dir" -type f -name "sfetch*" | head -1) + if [ -z "$binary" ]; then + err "binary not found in archive" + fi + + # Confirm installation + if [ "$yes" = false ] && [ -t 0 ]; then + echo "" + echo "Ready to install sfetch ${version} to ${install_dir}" + echo "Verification: ${TRUST_LEVEL}" + printf "Continue? [Y/n] " + read -r confirm + case "$confirm" in + [nN]*) + echo "Aborted." + exit 1 + ;; + esac + fi + + # Install + install_binary "$binary" "$install_dir" "$platform" + + echo "" + log "Done! Run 'sfetch --help' to get started." } main "$@"