Skip to content

feat: supply-chain pinning (go commit hash + gh SHA256) via --pin on add/ensure/upgrade#79

Merged
kazhuravlev merged 2 commits into
kazhuravlev:mainfrom
mcraq:mcraq/supply-chain-pinning
Jun 13, 2026
Merged

feat: supply-chain pinning (go commit hash + gh SHA256) via --pin on add/ensure/upgrade#79
kazhuravlev merged 2 commits into
kazhuravlev:mainfrom
mcraq:mcraq/supply-chain-pinning

Conversation

@mcraq

@mcraq mcraq commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds opt-in supply-chain verification for both runtimes via a new --pin flag on add, ensure, and upgrade.

What is pinned

Runtime Pin type Verification point
go Git commit hash from proxy.golang.org (Origin.Hash) Before go install — re-fetches from proxy to detect substitution
gh Per-platform SHA256 archive digest (asset.GetDigest()) After download, before extraction — hard-fails on mismatch

Usage

# Pin when adding
toolset add --pin go github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8
toolset add --pin gh golangci/golangci-lint@v2.5.0

# Re-pin an existing tool
toolset ensure --pin go github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8

# Upgrade to latest and re-pin
toolset upgrade --pin go github.com/golangci/golangci-lint/cmd/golangci-lint

The pin is stored in .toolset.json alongside the version. toolset sync enforces it on every fresh install. Tools without --pin are fully backward compatible.

Key design decisions

  • Opt-in: existing lock files without pin unmarshal cleanly to optional.Empty
  • go-runtime: uses ResolvedModPath (module root, not the cmd/ sub-path) to query the proxy — avoids 404s for tools like golangci-lint/cmd/golangci-lint
  • gh-runtime: loops knownPlatforms (7 OS/arch combinations) at pin time and records a digest per platform, enabling cross-platform reproducibility
  • Private go modules: silently return empty pin with a warning (proxy not accessible)
  • upgrade --pin: clears stale pin then immediately fetches the new one

Testing

  • go test ./... — all packages pass (unit tests for SHA256, mock proxy, findPinnedAsset, PIN mismatch rejection for both runtimes)
  • task test:pin — 8 integration scenarios: add/ensure/upgrade happy path + tamper-rejection for both runtimes

Add opt-in supply-chain verification for both go and gh runtimes:
- go-runtime: pins the git commit hash from proxy.golang.org (Origin.Hash)
  and re-fetches it before go install to detect substitution attacks
- gh-runtime: pins per-platform SHA256 archive digests (asset.GetDigest())
  and verifies the downloaded archive before extraction
New flag --pin on add, ensure, and upgrade writes the pin to .toolset.json.
toolset sync reads the pin and enforces it on every fresh install.
Tools without a pin install as before (backward compatible).
Gaps fixed:
- GAP-2: expose ResolvedModPath so proxy.golang.org accepts cmd/ tool paths
- GAP-3/4: Upgrade clears stale pins; ensure/upgrade --pin re-pins at new version
- GAP-5: injectable http.Client on go Runtime for testability
Integration tests: task test:pin covers 8 scenarios (add/ensure/upgrade happy
path + tamper-rejection for both runtimes)
@codecov-commenter

codecov-commenter commented Jun 11, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 61.62791% with 66 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.62%. Comparing base (e1f45e5) to head (dfec057).

Files with missing lines Patch % Lines
internal/workdir/workdir.go 0.00% 24 Missing ⚠️
...nal/workdir/runtimes/runtime-github-release/pin.go 72.97% 9 Missing and 1 partial ⚠️
internal/workdir/runtimes/runtime-go/pin.go 70.58% 7 Missing and 3 partials ⚠️
cmd/toolset/main.go 0.00% 6 Missing ⚠️
.../workdir/runtimes/runtime-github-release/assets.go 78.26% 3 Missing and 2 partials ⚠️
internal/workdir/runtimes/runtime-go/runtime_go.go 73.33% 2 Missing and 2 partials ⚠️
...s/runtime-github-release/runtime_github_release.go 81.25% 2 Missing and 1 partial ⚠️
.../workdir/runtimes/runtime-github-release/verify.go 77.77% 1 Missing and 1 partial ⚠️
internal/workdir/runtimes/runtime-go/private.go 60.00% 1 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #79      +/-   ##
==========================================
+ Coverage   31.77%   38.62%   +6.85%     
==========================================
  Files          20       23       +3     
  Lines        1775     1908     +133     
==========================================
+ Hits          564      737     +173     
+ Misses       1142     1076      -66     
- Partials       69       95      +26     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@mcraq

mcraq commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Fixes in this branch

1. Deterministic TestLocks rewrite

The problem
The original test used wall-clock timeouts to reason about lock state:

// Wait 1s — if ch hasn't closed, the goroutine must still be blocked
select {
case <-ch:
    t.Fatal("File should be locked")
case <-time.After(1 * time.Second):
}
unlock()
// Wait 1s — goroutine should have acquired the lock by now
select {
case <-ch:
case <-time.After(1 * time.Second):
    t.Fatal("File should be unlocked")
}

The second select is where it failed. flock.TryLockContext retries on a timer (retryDelay). After unlock(), the background goroutine could be anywhere in its retry sleep — worst case it needs nearly a full retryDelay before it tries again. The test's 1s window wasn't always enough.
No matter how you tune retryDelay, you're still playing a game of margins — not eliminating the race.
The fix: use context cancellation as a synchronization primitive
The key insight: flock.TryLockContext respects context cancellation. If the context is done and the lock can't be acquired, it returns an error immediately. This gives us a deterministic signal.
Subtest 1: Proving a held lock blocks

t.Run("held_lock_blocks_concurrent_locker", func(t *testing.T) {
    unlock, err := fs.Lock(ctx, file    unlock, err := fs.Lock(ctx, f
    defer unlock()
    // Cancel the context immediately before attempting a second     /    ca    // Cancel the context immediancel(ctx)
    cancel() // already cancelled
    _, err = fs.Lock(cancelCtx, filename)
    require.Error(t, err, "second Lock must fail while first is held")
})

With an already-cancelled context, TryLockContext attempts the lock once, fails (file is held), checks the context, sees it's done, and returns an error — no waiting, no scheduling races. The assertion is: "an already-cancelled lock attempt on a held file must error." That's alwayWith an already-cancelled context, TryLockContext attempts the releases the lock*

t.Run("unlock_allows_re_lock", func(t *testing.T) {
    unlock, err := fs.Lock(ctx, filename)
    require.NoError(t, err)
    unlock() // release
    unlock2, err := fs.Lock(ctx, filename)
    require.NoError(t, err)
    unlock2()
})

No goroutines. Lock, unlock, lock again — sequentially. If unlock didn't work, the second fs.Lock would block forever (caught by Go's test timeout). Deterministic because there's no concurrent actor to race against.
What was eliminated

Before After
1 goroutine 0 goroutines
2x time.After(1s) 0 timers
~2s minimum runtime ~0ms runtime
Flaky under scheduler pressure Always deterministic

2. Lint fix: remove unused helpers in pin_test.go

pin_test.go had three helper functions scaffolded during development that were never called in any test: ptr, makeRelease, makeAsset. golangci-lint's unused linter flagged all three. Removed them along with the now-unused go-github import.

mcraq added a commit to mcraq/toolset that referenced this pull request Jun 12, 2026
…ctx2 redundancy

- Fix goroutine leak in Test_fetchCommitHash_contextCancellation by using <-r.Context().Done()
  instead of select {} to properly listen for cancellation
- Fix process-global side effect in Test_pin_flag_invalid_module by restoring working directory
  with defer os.Chdir(original) to prevent parallel test interference
- Remove 7 redundant ctx2 declarations in runtime_go_test.go, reusing ctx instead

Fixes review feedback from PR kazhuravlev#79 code review (2026-06-12)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mcraq mcraq force-pushed the mcraq/supply-chain-pinning branch 6 times, most recently from b8846a1 to 465bf84 Compare June 12, 2026 16:04
@mcraq mcraq marked this pull request as draft June 12, 2026 16:04
@mcraq mcraq force-pushed the mcraq/supply-chain-pinning branch 3 times, most recently from a614b22 to 635523f Compare June 12, 2026 16:24
@mcraq mcraq marked this pull request as ready for review June 12, 2026 16:27
@mcraq mcraq force-pushed the mcraq/supply-chain-pinning branch from 635523f to 81a53cc Compare June 12, 2026 16:31
Unit tests for go-runtime and gh-runtime supply-chain pinning:

go-runtime (runtime-go):
- roundTripperFunc adapter for in-process HTTP mocking
- fetchCommitHash full error-path suite: success, empty hash, non-200,
  JSON decode error, context cancellation, invalid module path
- Test_Install_pinMismatch_rejected: end-to-end tamper detection via
  httptest.NewServer returning a mismatched commit hash
- Test_Install_SHA256Verification_doesNotRejectMatchingHash: verifies
  the pin check passes when hashes agree (NotContains semantics explicit)
- Test_GetPin_privateModule_returnsEmpty: fetchModuleFn seam returning
  IsPrivate:true asserts optional.Empty + no error

gh-runtime (runtime-github-release):
- newTestRuntime helper wires getAssetFn + downloadFn seams
- Test_Install_pinMismatch_rejected / _SHA256Verification_doesNotRejectMatchingDigest
- Test_Install_pinMissingPlatform, _downloadError, _corruptArchive
- Test_GetPin_emptyDigest, _noAssetsForAnyPlatform, _releaseAPIError
- Test_findPinnedAsset table tests; Test_computeSHA256 known-value tests
- verify_test.go: computeSHA256 with known SHA256 vectors + empty file

Shared:
- workdir_test.go: New() missing spec, invalid JSON, TOOLSET_CACHE_DIR,
  deprecated Dir field, multiple runtimes
- real_fs_test.go: deterministic TestLocks rewrite replacing timing-based
  goroutine test with context-cancellation approach; isolated lock files
  per subtest via os.CreateTemp

Round 4 fixes applied in same commit:
- Delete dead strings.Cut(!ok) branch in gh-runtime pin.go:35 (unreachable;
  parse() enforces owner/repo format upstream)
- Pre-compile regex patterns outside asset loop in assets.go
- Rename weak-assertion tests to make NotContains semantics explicit
- Delete assertion-free Test_pin_flag_invalid_module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@mcraq mcraq force-pushed the mcraq/supply-chain-pinning branch from 81a53cc to dfec057 Compare June 12, 2026 16:43
@kazhuravlev

Copy link
Copy Markdown
Owner

Thanks for the contribution. This is an important change, so I’d like to review it carefully over the next few days.

@kazhuravlev kazhuravlev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work on this MR — the implementation is clean and well thought out. Thanks for the excellent contribution!

@kazhuravlev kazhuravlev merged commit 5a79e60 into kazhuravlev:main Jun 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants