fix: never generate a shim named "asdf" during reshim - #2307
Conversation
A plugin-managed toolchain can end up installing a binary that happens to be named "asdf" - for example, if asdf's own source is built via `go install` under an asdf-managed Go, the resulting $GOPATH/bin/asdf binary sits in a directory reshim scans for executables. `asdf reshim` doesn't special-case this and generates a shim for it like any other executable. Since the shims directory is placed ahead of the real asdf binary on PATH, every subsequent `asdf` invocation resolves to that shim instead. The shim's generated body is `exec asdf exec "asdf" "$@"`, which itself resolves to the same shim again, recursing into itself forever and hanging every asdf command. Add a small reserved-names check in shims.Write, so a shim is never generated for "asdf" regardless of which installed tool's bin directory it came from. Fixes asdf-vm#2166
There was a problem hiding this comment.
Pull request overview
Prevents asdf reshim from generating a shim named asdf, which can shadow the real asdf binary on PATH and cause infinite recursion/hangs.
Changes:
- Add a reserved shim-name check in
shims.Writeto skip generating a shim namedasdf. - Add a regression subtest ensuring
Writedoes not createshims/asdfwhen given an executable whose basename isasdf.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| internal/shims/shims.go | Introduces a reserved-name guard in Write to prevent generating a shim named asdf. |
| internal/shims/shims_test.go | Adds a regression test to assert no shims/asdf is created. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| shimName := filepath.Base(executablePath) | ||
| if reservedShimNames[shimName] { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Good catch. Fixed in a3ad2e1 — Write now removes any pre-existing shim under the reserved name (ignoring os.IsNotExist) before returning, instead of just skipping creation. This matters specifically for asdf reshim <tool> <version>, which calls Write directly without a prior RemoveAll (unlike bare asdf reshim), so a stale shims/asdf from before this guard existed would otherwise persist indefinitely on that path.
| t.Run("never creates a shim named asdf, no matter what executable is passed", func(t *testing.T) { | ||
| fakeAsdfExecutable := filepath.Join(filepath.Dir(executable), "asdf") | ||
| err := Write(conf, plugin, version, fakeAsdfExecutable) | ||
| assert.Nil(t, err) | ||
|
|
||
| shimPath := Path(conf, "asdf") | ||
| _, statErr := os.Stat(shimPath) | ||
| assert.True(t, os.IsNotExist(statErr), "expected no shim to be created for the reserved name \"asdf\"") |
There was a problem hiding this comment.
Added a test for this in a3ad2e1 — "removes a pre-existing shim named asdf left over from before this guard existed". It pre-creates a stale shims/asdf, calls Write, and asserts the file is gone afterward, so it covers the removal behavior from the first comment as well as the upgrade scenario you're describing.
…w one asdf reshim <tool> <version> calls Write directly without RemoveAll, so a shims/asdf left over from before this guard existed would otherwise persist (and keep recursing) until a bare `asdf reshim` happened to run. Addresses Copilot review feedback on the guard added in 313194e.
Summary
asdf reshimcan generate a shim literally namedasdf, which then shadows the realasdfbinary onPATHand causes every subsequentasdfinvocation to hang forever in infinite recursion:Root cause
GenerateForVersion(internal/shims/shims.go) scans every installed tool version's bin directory (viaToolExecutables) and callsWritefor each executable found, with no exclusion list.Writederives the shim's name purely fromfilepath.Base(executablePath)and writes it unconditionally.If a plugin-managed toolchain happens to produce a binary literally named
asdfin its bin directory - the comment on the issue traced this to a Go toolchain havinggo install-ed asdf's own source at some point, landing anasdfbinary under~/.asdf/installs/golang/*/go/bin/-reshimwill generateshims/asdffor it. Since the shims directory precedes the real asdf binary onPATH, everyasdfcall afterward resolves to that shim instead, whose generated body is:which itself resolves to the same shim, recursing forever.
Fix
Added a small reserved-names check at the top of
shims.Write: if the derived shim name isasdf, skip writing a shim for it entirely, regardless of which plugin/version/bin-directory it was found in.asdfshould never be shimmed, since asdf itself must always remain directly callable.Test plan
go build ./...TestWriteininternal/shims/shims_test.go: callsWritewith an executable path whose basename isasdfand asserts no shim file is created atshims/asdfgo test ./internal/shims/...— all pass (this package doesn't build on native Windows due togolang.org/x/sys/unixsymbols likeAccess/X_OKnot existing for that GOOS, so I validated using a Linux container:docker run --rm -v $(pwd):/src -w /src golang:1.26 go test ./internal/shims/... -v)internal/shimsis fully green (11/11 tests). Three unrelated packages (cmd/asdf,internal/execute,internal/help) show failures on my Windows dev machine, but they're plainly CRLF line-ending artifacts from my local checkout (visible\r\nbytes leaking into fixture/help-text comparisons in the failure output) - unrelated tointernal/shimsand not something I touchedFixes #2166