diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 69e82f1..6ed9c80 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.6.0" + ".": "2.7.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index dc749a0..b23da41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## [2.7.0](https://github.com/aaronflorey/bin/compare/v2.6.0...v2.7.0) (2026-09-30) + + +### Features + +* add macos app signing ([cfd883f](https://github.com/aaronflorey/bin/commit/cfd883f59922946429c0c469f4f5c565b463e071)) +* add read-only artifact inspection ([3240497](https://github.com/aaronflorey/bin/commit/3240497609397b4fe17562d888cad7c13df91214)) +* add repository search and release browsing ([8ee779d](https://github.com/aaronflorey/bin/commit/8ee779d96dcff0ce1a419f35b9138b20708814a7)) +* **assets:** expose platform-compatible installable assets ([1471130](https://github.com/aaronflorey/bin/commit/147113080a1b0b66dcfa0aba98ba2b71e8d7ec9c)) +* cache github responses ([1c0a3cd](https://github.com/aaronflorey/bin/commit/1c0a3cd47112b76747fa2882cc9b3965506972b6)) +* carry bundled completion artifacts ([501e28a](https://github.com/aaronflorey/bin/commit/501e28a02b97a942b5945a9735c5f66fb3775420)) +* carry bundled completion artifacts ([4248d9b](https://github.com/aaronflorey/bin/commit/4248d9b2fd045da94e924fe35134db94c8fdbebc)) +* **config:** default use_gh_for_github_token to true ([5da1914](https://github.com/aaronflorey/bin/commit/5da1914ab21c9ee3ad36c5b1c0929b89e2515216)) +* harden binary payload validation and selection ([4499d08](https://github.com/aaronflorey/bin/commit/4499d087a579b86923afeb08ccadc3a8d59bc845)) +* **install:** select compatible products and release lanes ([8322028](https://github.com/aaronflorey/bin/commit/8322028815fc2baa27a5dd462167dd2bada46ac6)) +* integrate managed completion lifecycle ([6e12db6](https://github.com/aaronflorey/bin/commit/6e12db615ad2e52f91eb51c12ce531ec2082b84e)) +* **logging:** expose GitHub auth/rate-limit state and archive contents in errors ([1a513e1](https://github.com/aaronflorey/bin/commit/1a513e19d4044907ca3aad77ac33089342d94101)) +* persist stable selection intent ([3e5145e](https://github.com/aaronflorey/bin/commit/3e5145e856305068bd8b3bfadba20f2e28fd470d)) +* prefer bundled managed completions ([2ea9ebb](https://github.com/aaronflorey/bin/commit/2ea9ebbf3e43ae7ae1746465d5abb83022fe31d3)) +* prefer bundled managed completions ([d1e0ccc](https://github.com/aaronflorey/bin/commit/d1e0ccc5167b4451d1d927a39970929f4209f01a)) +* sync managed binary completions ([2d898a1](https://github.com/aaronflorey/bin/commit/2d898a1930843d5f0c750ce60fd5aa9934940cf9)) + + +### Bug Fixes + +* address release binary compatibility audit findings ([e9e7725](https://github.com/aaronflorey/bin/commit/e9e77252b0b5f0f6bfa5e963e5f7d27400405a1e)) +* **assets:** log gzip inner name during decompression ([4c78b14](https://github.com/aaronflorey/bin/commit/4c78b14f4b854c8f177f176ea83c9ecf424f5d36)) +* **assets:** preserve archive install selection ([1173539](https://github.com/aaronflorey/bin/commit/1173539109cb9371383dc98a276e53b6a148119a)) +* bind checksums to exact artifacts ([1d7ef29](https://github.com/aaronflorey/bin/commit/1d7ef2931c50e2b937b15d5f2fae904525ff948e)) +* bound artifact processing inventory ([41d8419](https://github.com/aaronflorey/bin/commit/41d84191375653ee48ab6d8649befc941d794089)) +* commit binary installs recoverably ([ad8d74b](https://github.com/aaronflorey/bin/commit/ad8d74b6dba21949fb57482c4db8bebed7388473)) +* **config:** recreate cached default path if missing ([16a0c67](https://github.com/aaronflorey/bin/commit/16a0c67d3a6574de4001ad14e04eaddee47619fd)) +* contain remote artifact names ([11327d2](https://github.com/aaronflorey/bin/commit/11327d26162ce05d249da83c33c90cf66f5429f7)) +* improve archive member resolution ([1ae6e0d](https://github.com/aaronflorey/bin/commit/1ae6e0d31f24bb678d0a730e5dc6fed4718e7b40)) +* **install:** preserve requested release lane for existing binaries ([2ecd586](https://github.com/aaronflorey/bin/commit/2ecd5862b52e95ced5be2fb069c26b98f5838e2c)) +* preserve Go import path separators ([c3750de](https://github.com/aaronflorey/bin/commit/c3750dea9bf2809b82bcfa52e3933ac662b8e1b3)) +* process generic URLs through artifact resolver ([a8d392b](https://github.com/aaronflorey/bin/commit/a8d392b0e9260a85b042c9a1d7db05712a5cd0b0)) +* replace binaries atomically ([2ab8115](https://github.com/aaronflorey/bin/commit/2ab8115e385930c9a69cd77c8814309016dd69da)) +* resolve archive members by identity ([91a8629](https://github.com/aaronflorey/bin/commit/91a8629745cff221ad8ea8d40d38760cfdae38d0)) +* resolve managed Windows targets ([e4cb2c5](https://github.com/aaronflorey/bin/commit/e4cb2c556c267b51a31c1161fcfb8fa16e1d2745)) +* resolve release products before packaging ([1fbd6e6](https://github.com/aaronflorey/bin/commit/1fbd6e67f059534950fa3686e618f7cd77c1db2d)) +* select dmg bundles by managed identity ([5eb2fef](https://github.com/aaronflorey/bin/commit/5eb2fef4cfa04b020a6be00b5a7b85d7a35042e1)) +* smoketests failing ([5341066](https://github.com/aaronflorey/bin/commit/534106675b7860a0b6bf8bbc770840a8ade39a88)) +* verify artifact integrity across installs ([c0b307a](https://github.com/aaronflorey/bin/commit/c0b307a10a3bf5b71d542aeeb7ed490396160309)) +* warn when bundled completions are skipped ([770f256](https://github.com/aaronflorey/bin/commit/770f2567764cb225b481f7998832d7a138caf5da)) +* warn when bundled completions are skipped ([1954251](https://github.com/aaronflorey/bin/commit/1954251fa1470789bdd6a231641160badd963303)) + ## [2.6.0](https://github.com/aaronflorey/bin/compare/v2.5.4...v2.6.0) (2026-07-09) diff --git a/cmd/completion_native_test.go b/cmd/completion_native_test.go index b7cd625..c7a9026 100644 --- a/cmd/completion_native_test.go +++ b/cmd/completion_native_test.go @@ -63,7 +63,7 @@ func TestCompletionNativeHelperProcess(_ *testing.T) { if os.Getenv("BIN_COMPLETION_TEST_SECRET") != "" { completionNativeHelperFailure("generator inherited secret environment variable") } - if os.Getenv("HOME") != workingDirectory || os.Getenv("TMPDIR") != workingDirectory { + if !sameTestFilePath(os.Getenv("HOME"), workingDirectory) || !sameTestFilePath(os.Getenv("TMPDIR"), workingDirectory) { completionNativeHelperFailure("generator environment does not use its temporary working directory") } var input [1]byte @@ -93,6 +93,12 @@ func completionNativeHelperFailure(format string, args ...any) { os.Exit(1) } +func sameTestFilePath(left, right string) bool { + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) +} + func TestGenerateNativeCompletionUsesDirectBoundedExecution(t *testing.T) { requirePOSIXShellFixture(t) diff --git a/cmd/completions_test.go b/cmd/completions_test.go index c025f7c..3ec85e9 100644 --- a/cmd/completions_test.go +++ b/cmd/completions_test.go @@ -302,14 +302,15 @@ func TestCompletionSyncRejectsOwnershipConflictAndAllowsExplicitRenamedCommand(t }) t.Run("renamed command requires explicit argv", func(t *testing.T) { - setupCompletionSyncBinary(t, "alias", "upstream", `printf '%s|%s' "$1" "$2"`, installModeBinary) - if _, err := runCompletionSync(t, "alias", "fish"); err == nil { + const command = "bin-test-renamed-command" + setupCompletionSyncBinary(t, command, "upstream", `printf '%s|%s' "$1" "$2"`, installModeBinary) + if _, err := runCompletionSync(t, command, "fish"); err == nil { t.Fatal("sync generated a default completion for renamed command") } - if _, err := runCompletionSync(t, "alias", "fish", "--", "custom", "fish"); err != nil { + if _, err := runCompletionSync(t, command, "fish", "--", "custom", "fish"); err != nil { t.Fatalf("sync explicit argv for renamed command: %v", err) } - assertCompletionContent(t, "fish", "alias", "custom|fish") + assertCompletionContent(t, "fish", command, "custom|fish") }) } diff --git a/cmd/ensure_test.go b/cmd/ensure_test.go index c7b842f..53d8d29 100644 --- a/cmd/ensure_test.go +++ b/cmd/ensure_test.go @@ -79,7 +79,7 @@ func TestEnsureAppliesPersistedSelectionIntent(t *testing.T) { t.Fatalf("ensure bundled completion request = (%q, %q)", opts.BundledCompletionShell, opts.BundledCompletionCommand) } return &providers.File{ - Data: strings.NewReader("#!/bin/sh\nexit 0\n"), + Data: strings.NewReader(testRunnablePayloadString(t)), Name: "missing-tool", Version: "1.0.0", BundledCompletion: []byte("bundled completion"), diff --git a/cmd/install_test.go b/cmd/install_test.go index ef78ba0..a9a887e 100644 --- a/cmd/install_test.go +++ b/cmd/install_test.go @@ -42,7 +42,7 @@ func TestInstallCompletionFlag(t *testing.T) { installProviderFactory = func(string, string) (providers.Provider, error) { return testFetchProvider{file: &providers.File{ - Data: strings.NewReader("#!/bin/sh\nexit 0\n"), + Data: strings.NewReader(testRunnablePayloadString(t)), Name: "tool", Version: "1.0.0", }}, nil diff --git a/cmd/installer.go b/cmd/installer.go index 27deba3..294be81 100644 --- a/cmd/installer.go +++ b/cmd/installer.go @@ -477,7 +477,7 @@ func absExpandedPath(path string) (string, error) { // must remain literal. func expandTrackedBinaryPath(path string) string { dir, name := filepath.Split(path) - return os.ExpandEnv(dir) + name + return filepath.Join(os.ExpandEnv(dir), name) } func ensureReleaseAge(providerID, version string, publishedAt *time.Time, minAgeDays int) error { diff --git a/cmd/installer_test.go b/cmd/installer_test.go index a326187..20a02ac 100644 --- a/cmd/installer_test.go +++ b/cmd/installer_test.go @@ -86,7 +86,7 @@ func TestInstallBinaryPreservesArchiveMemberResolutionOutcomes(t *testing.T) { t.Fatal("interactive install unexpectedly requested non-interactive fetch") } return &providers.File{ - Data: strings.NewReader("#!/bin/sh\nexit 0\n"), + Data: strings.NewReader(testRunnablePayloadString(t)), Name: "tool", Version: "1.0.0", PackagePath: "two/tool", @@ -177,7 +177,7 @@ func TestInstallBinaryPersistsResolvedSelectionIntent(t *testing.T) { intent := &config.SelectionDescriptor{LogicalProduct: "tool-cli", Target: &config.SelectionTarget{OS: "linux", Architecture: "amd64", ABI: "musl"}, ArchiveMember: "bin/tool"} installProviderFactory = func(string, string) (providers.Provider, error) { return fetchBinaryTestProvider{id: "test", fetchFn: func(*providers.FetchOpts) (*providers.File, error) { - return &providers.File{Data: strings.NewReader("#!/bin/sh\nexit 0\n"), Name: "tool", Version: "2.0.0", ReleaseTagPrefix: "nightly-", SourceAsset: "tool-cli-v2.0.0-linux-amd64-musl.tar.gz", PackagePath: "tool-v2/bin/tool", SelectionIntent: intent}, nil + return &providers.File{Data: strings.NewReader(testRunnablePayloadString(t)), Name: "tool", Version: "2.0.0", ReleaseTagPrefix: "nightly-", SourceAsset: "tool-cli-v2.0.0-linux-amd64-musl.tar.gz", PackagePath: "tool-v2/bin/tool", SelectionIntent: intent}, nil }}, nil } @@ -496,7 +496,7 @@ func TestExistingConfigBinaryMatchesExpandedPath(t *testing.T) { func TestSaveToDiskValidatesExpectedSHA(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "tool") - payload := "#!/bin/sh\necho hello\n" + payload := testRunnablePayloadString(t) sum := sha256.Sum256([]byte(payload)) _, err := saveToDisk(&providers.File{ @@ -564,7 +564,7 @@ func TestSaveToDiskOverwritePublishesRunnableBytesAndCleansStage(t *testing.T) { if err := os.WriteFile(target, []byte("old executable"), 0o755); err != nil { t.Fatal(err) } - payload := "#!/bin/sh\necho updated\n" + payload := testRunnablePayloadString(t) hash, err := saveToDisk(&providers.File{Data: strings.NewReader(payload), Name: "tool"}, target, true) if err != nil { @@ -598,7 +598,7 @@ func TestSaveToDiskConcurrentNonForcePublishesOneCandidate(t *testing.T) { } dir := t.TempDir() target := filepath.Join(dir, "tool") - payloads := []string{"#!/bin/sh\necho first\n", "#!/bin/sh\necho second\n"} + payloads := []string{testRunnablePayloadString(t), testRunnablePayloadString(t)} start := make(chan struct{}) staged := make(chan struct{}, len(payloads)) release := make(chan struct{}) @@ -1029,7 +1029,7 @@ func TestInstallBinaryKeepsProviderEnvironmentSyntaxLiteral(t *testing.T) { return fetchBinaryTestProvider{ id: "test", fetchFn: func(*providers.FetchOpts) (*providers.File, error) { - return &providers.File{Name: "$REMOTE_NAME", Version: "1.2.3", Data: strings.NewReader("#!/bin/sh\nexit 0\n")}, nil + return &providers.File{Name: "$REMOTE_NAME", Version: "1.2.3", Data: strings.NewReader(testRunnablePayloadString(t))}, nil }, }, nil } @@ -1065,7 +1065,7 @@ func TestInstallBinaryExpandsDestinationWithoutPathResolution(t *testing.T) { return fetchBinaryTestProvider{ id: "test", fetchFn: func(*providers.FetchOpts) (*providers.File, error) { - return &providers.File{Name: "tool", Version: "1.2.3", Data: strings.NewReader("#!/bin/sh\nexit 0\n")}, nil + return &providers.File{Name: "tool", Version: "1.2.3", Data: strings.NewReader(testRunnablePayloadString(t))}, nil }, }, nil } diff --git a/cmd/remove_test.go b/cmd/remove_test.go index 57413de..ac61fd9 100644 --- a/cmd/remove_test.go +++ b/cmd/remove_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -243,7 +244,11 @@ func TestRemoveWarnsForUnmanagedBinary(t *testing.T) { setupTestConfig(t) toolDir := t.TempDir() - toolPath := filepath.Join(toolDir, "external-tool") + toolName := "external-tool" + if runtime.GOOS == "windows" { + toolName += ".exe" + } + toolPath := filepath.Join(toolDir, toolName) if err := os.WriteFile(toolPath, []byte("binary"), 0o755); err != nil { t.Fatalf("failed creating executable: %v", err) } @@ -467,10 +472,15 @@ func TestRemoveMissingExplicitPathCleansConfig(t *testing.T) { if err != nil { t.Fatal(err) } - argument, err = filepath.Rel(cwd, path) - if err != nil { + if err := os.Chdir(directory); err != nil { t.Fatal(err) } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Errorf("restore cwd: %v", err) + } + }) + argument = filepath.Base(path) } root := newRemoveCmd() root.newProvider = func(string, string) (providers.Provider, error) { return removeTestProvider{}, nil } diff --git a/cmd/run_test.go b/cmd/run_test.go index 2058461..123d10b 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -26,7 +26,7 @@ type runTestProvider struct { err error } -const runnableRunScript = "#!/bin/sh\nexit 0\n" +var runnableRunScript = string(sharedRunnableTestPayload) func (p *runTestProvider) Fetch(opts *providers.FetchOpts) (*providers.File, error) { p.fetchCount++ diff --git a/cmd/runnable_fixture_test.go b/cmd/runnable_fixture_test.go new file mode 100644 index 0000000..b6af4ae --- /dev/null +++ b/cmd/runnable_fixture_test.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "os" + "runtime" + "testing" +) + +var sharedRunnableTestPayload = loadRunnableTestPayload() + +func loadRunnableTestPayload() []byte { + if runtime.GOOS == "windows" { + path, err := os.Executable() + if err != nil { + panic(err) + } + payload, err := os.ReadFile(path) + if err != nil { + panic(err) + } + return payload + } + return []byte("#!/bin/sh\nexit 0\n") +} + +func testRunnablePayload(t testing.TB) []byte { + t.Helper() + return append([]byte(nil), sharedRunnableTestPayload...) +} + +func testRunnablePayloadString(t testing.TB) string { + t.Helper() + return string(testRunnablePayload(t)) +} diff --git a/cmd/system_package.go b/cmd/system_package.go index 987aa6b..c9c0b15 100644 --- a/cmd/system_package.go +++ b/cmd/system_package.go @@ -280,10 +280,14 @@ func resolveDMGSourceExecutable(mountPoint, bundlePath string) (string, error) { if !pathWithin(canonicalBundle, canonicalExecutable) { return "", fmt.Errorf("app bundle %q executable escapes bundle", filepath.Base(bundlePath)) } - if err := assets.ValidateRunnablePayload(executable, filepath.Base(executable)); err != nil { + if err := assets.ValidateRunnablePayload(canonicalExecutable, filepath.Base(canonicalExecutable)); err != nil { return "", fmt.Errorf("app bundle %q contains an invalid executable: %w", filepath.Base(bundlePath), err) } - return executable, nil + relativeExecutable, err := filepath.Rel(canonicalBundle, canonicalExecutable) + if err != nil { + return "", fmt.Errorf("resolve app executable path: %w", err) + } + return filepath.Join(bundlePath, relativeExecutable), nil } func offerToSignUnsignedApp(appPath string, nonInteractive bool) error { @@ -364,12 +368,13 @@ func eligibleDMGAppBundles(root string) ([]dmgAppBundleCandidate, error) { return nil, fmt.Errorf("read dmg mount root: %w", err) } - byIdentity := make(map[string]dmgAppBundleCandidate) + candidates := make([]dmgAppBundleCandidate, 0, len(entries)) for _, entry := range entries { if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".app") { continue } - candidatePath, err := filepath.EvalSymlinks(filepath.Join(canonicalRoot, entry.Name())) + sourcePath := filepath.Join(root, entry.Name()) + candidatePath, err := filepath.EvalSymlinks(sourcePath) if err != nil { return nil, fmt.Errorf("resolve dmg app bundle %q: %w", entry.Name(), err) } @@ -380,23 +385,29 @@ func eligibleDMGAppBundles(root string) ([]dmgAppBundleCandidate, error) { if err != nil { return nil, err } - candidate := dmgAppBundleCandidate{path: candidatePath, name: entry.Name(), identity: identity} - if existing, ok := byIdentity[identity]; ok { - names := []string{existing.name, candidate.name} - sort.Strings(names) - return nil, fmt.Errorf("dmg contained colliding app bundle identities (%s)", strings.Join(names, ", ")) - } - byIdentity[identity] = candidate + candidates = append(candidates, dmgAppBundleCandidate{path: sourcePath, name: entry.Name(), identity: identity}) } - candidates := make([]dmgAppBundleCandidate, 0, len(byIdentity)) - for _, candidate := range byIdentity { - candidates = append(candidates, candidate) + if err := validateUniqueDMGAppBundleIdentities(candidates); err != nil { + return nil, err } sort.Slice(candidates, func(i, j int) bool { return candidates[i].name < candidates[j].name }) return candidates, nil } +func validateUniqueDMGAppBundleIdentities(candidates []dmgAppBundleCandidate) error { + byIdentity := make(map[string]string, len(candidates)) + for _, candidate := range candidates { + if existing, ok := byIdentity[candidate.identity]; ok { + names := []string{existing, candidate.name} + sort.Strings(names) + return fmt.Errorf("dmg contained colliding app bundle identities (%s)", strings.Join(names, ", ")) + } + byIdentity[candidate.identity] = candidate.name + } + return nil +} + func firstDMGAppIdentity(requestedIdentity, storedIdentity, legacyName string) (string, string, error) { for _, selection := range []struct { value string diff --git a/cmd/system_package_test.go b/cmd/system_package_test.go index 2d8712f..748e21f 100644 --- a/cmd/system_package_test.go +++ b/cmd/system_package_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -31,6 +32,7 @@ func TestSystemPackagePathLooksExplicitForEitherSeparator(t *testing.T) { } func TestResolveAppBundleExecutablePrefersBundleName(t *testing.T) { + requireDMGFixturePlatform(t) appPath := filepath.Join(t.TempDir(), "Paseo.app") execDir := filepath.Join(appPath, "Contents", "MacOS") if err := os.MkdirAll(execDir, 0o755); err != nil { @@ -53,6 +55,13 @@ func TestResolveAppBundleExecutablePrefersBundleName(t *testing.T) { } } +func requireDMGFixturePlatform(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("DMG app bundle fixtures require Unix executable semantics") + } +} + func TestResolveDMGAppBundle(t *testing.T) { tests := []struct { name string @@ -95,11 +104,6 @@ func TestResolveDMGAppBundle(t *testing.T) { requestedIdentity: " fAsTpOtIfY.aPp ", wantBundle: "Fastpotify.APP", }, - { - name: "case collisions are rejected", - bundles: []string{"Fastpotify.app", "fastpotify.APP"}, - wantError: "colliding app bundle identities", - }, { name: "missing named identity does not fall back", bundles: []string{"Fastpotify.app"}, @@ -152,6 +156,49 @@ func TestResolveDMGAppBundle(t *testing.T) { } } +func TestResolveDMGAppBundlePreservesCallerRootPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink path spelling regression is Unix-specific") + } + canonicalRoot := t.TempDir() + executableDir := filepath.Join(canonicalRoot, "Fastpotify.app", "Contents", "MacOS") + if err := os.MkdirAll(executableDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(executableDir, "Fastpotify"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + aliasRoot := filepath.Join(t.TempDir(), "mount-alias") + if err := os.Symlink(canonicalRoot, aliasRoot); err != nil { + t.Fatal(err) + } + + got, err := resolveDMGAppBundle(aliasRoot, "", "", "") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(aliasRoot, "Fastpotify.app"); got != want { + t.Fatalf("resolveDMGAppBundle() = %q, want caller-rooted path %q", got, want) + } + executable, err := resolveDMGSourceExecutable(aliasRoot, got) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(aliasRoot, "Fastpotify.app", "Contents", "MacOS", "Fastpotify"); executable != want { + t.Fatalf("resolveDMGSourceExecutable() = %q, want caller-rooted path %q", executable, want) + } +} + +func TestValidateUniqueDMGAppBundleIdentitiesRejectsCaseCollisions(t *testing.T) { + candidates := []dmgAppBundleCandidate{ + {name: "Fastpotify.app", identity: "fastpotify"}, + {name: "fastpotify.APP", identity: "fastpotify"}, + } + if err := validateUniqueDMGAppBundleIdentities(candidates); err == nil || !strings.Contains(err.Error(), "colliding app bundle identities") { + t.Fatalf("validateUniqueDMGAppBundleIdentities() error = %v, want collision", err) + } +} + func TestResolveDMGAppBundleIgnoresSymlinkedBundleOutsideMount(t *testing.T) { root := t.TempDir() outside := filepath.Join(t.TempDir(), "Fastpotify.app") @@ -319,6 +366,7 @@ func TestFindManagedBinByAliasMatchesAppBundleName(t *testing.T) { } func TestInstallSystemPackageDMGTracksInstalledAppBundle(t *testing.T) { + requireDMGFixturePlatform(t) setupTestConfig(t) originalApplicationsDir := applicationsDir @@ -429,6 +477,7 @@ func TestInstallSystemPackageDMGTracksInstalledAppBundle(t *testing.T) { } func TestInstallSystemPackageDMGReinstallUsesStoredBundleOverSibling(t *testing.T) { + requireDMGFixturePlatform(t) setupTestConfig(t) originalApplicationsDir := applicationsDir originalExec := execCommand @@ -508,6 +557,7 @@ func TestInstallSystemPackageDMGReinstallUsesStoredBundleOverSibling(t *testing. } func TestInstallDMGAppRejectsInvalidSourceBeforeCopyOrSigning(t *testing.T) { + requireDMGFixturePlatform(t) originalApplicationsDir := applicationsDir originalExec := execCommand applicationsDir = t.TempDir() diff --git a/cmd/update_test.go b/cmd/update_test.go index b15007e..438b59b 100644 --- a/cmd/update_test.go +++ b/cmd/update_test.go @@ -550,7 +550,7 @@ func TestUpdateYesFlagNoArgsSkipsInteractiveSelector(t *testing.T) { func TestUpdateAppliesPersistedSelectionIntent(t *testing.T) { installDir := setupTestConfig(t) path := filepath.Join(installDir, "tool") - if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + if err := os.WriteFile(path, testRunnablePayload(t), 0o755); err != nil { t.Fatal(err) } intent := &config.SelectionDescriptor{LogicalProduct: "tool-cli", Target: &config.SelectionTarget{OS: "linux", Architecture: "amd64", ABI: "musl"}, ArchiveMember: "bin/tool"} @@ -573,7 +573,7 @@ func TestUpdateAppliesPersistedSelectionIntent(t *testing.T) { t.Fatalf("update bundled completion request = (%q, %q)", opts.BundledCompletionShell, opts.BundledCompletionCommand) } return &providers.File{ - Data: strings.NewReader("#!/bin/sh\nexit 0\n"), + Data: strings.NewReader(testRunnablePayloadString(t)), Name: "tool", Version: "v2.0.0", BundledCompletion: []byte("bundled completion"), diff --git a/pkg/assets/artifact_processing_test.go b/pkg/assets/artifact_processing_test.go index 2fdb2b7..56126bc 100644 --- a/pkg/assets/artifact_processing_test.go +++ b/pkg/assets/artifact_processing_test.go @@ -927,7 +927,7 @@ func TestProcessReleaseArtifactPersistsOnlyRealArchiveMembers(t *testing.T) { originalResolver := resolver resolver = testLinuxAMDResolver t.Cleanup(func() { resolver = originalResolver }) - fixture := testRunnableFixture("tool") + fixture := runnableTestFixture{name: "tool", contents: "#!/bin/sh\nexit 0\n"} process := func(t *testing.T, name string, payload []byte, intent *config.SelectionDescriptor) (*Filter, *artifactProcessingResult, error) { t.Helper() diff --git a/pkg/assets/assets_test.go b/pkg/assets/assets_test.go index 0dfb99c..b60139c 100644 --- a/pkg/assets/assets_test.go +++ b/pkg/assets/assets_test.go @@ -189,9 +189,13 @@ func TestProcessURLRejectsArchiveChecksumMismatch(t *testing.T) { } func TestProcessURLReportsByteTransformationEvidence(t *testing.T) { - plainName, renamedName, archiveAssetName := "tool-linux-amd64", "tool-renamed-linux-amd64", "tool-linux-amd64.zip" + plainName := "tool-" + runtime.GOOS + "-" + runtime.GOARCH + renamedName := "tool-renamed-" + runtime.GOOS + "-" + runtime.GOARCH + archiveAssetName := plainName + ".zip" if runtime.GOOS == "windows" { - plainName, renamedName, archiveAssetName = "tool-windows-amd64", "tool-renamed-windows-amd64", "tool-windows-amd64.zip" + plainName = "tool-windows-" + runtime.GOARCH + renamedName = "tool-renamed-windows-" + runtime.GOARCH + archiveAssetName = plainName + ".zip" } plainFixture := testRunnableFixture(plainName) renamedFixture := testRunnableFixture(renamedName) diff --git a/pkg/providers/generic_url_test.go b/pkg/providers/generic_url_test.go index 8b6e0aa..2e669fb 100644 --- a/pkg/providers/generic_url_test.go +++ b/pkg/providers/generic_url_test.go @@ -157,7 +157,7 @@ func TestGenericURLGetLatestVersionNoVersionReturnsError(t *testing.T) { func TestGenericURLFetchReturnsFileNameVersionAndData(t *testing.T) { payload := genericRunnablePayload(t) - filename := genericArtifactName("", "") + filename := genericArtifactName("", genericScriptExtension()) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) _, _ = w.Write(payload) @@ -393,7 +393,7 @@ func TestGenericURLFetchProcessesTarAndRejectsNonRunnablePayload(t *testing.T) { } func TestGenericURLFetchUsesResponseMetadataAndOneDownload(t *testing.T) { - filename := genericArtifactName("content", "") + filename := genericArtifactName("content", genericScriptExtension()) var getCalls, sidecarCalls int mux := http.NewServeMux() mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) { @@ -401,7 +401,7 @@ func TestGenericURLFetchUsesResponseMetadataAndOneDownload(t *testing.T) { t.Fatalf("request method = %s, want GET", r.Method) } getCalls++ - http.Redirect(w, r, "/redirected/"+genericArtifactName("redirect", ""), http.StatusFound) + http.Redirect(w, r, "/redirected/"+genericArtifactName("redirect", genericScriptExtension()), http.StatusFound) }) mux.HandleFunc("/redirected/", func(w http.ResponseWriter, r *http.Request) { getCalls++ @@ -428,7 +428,7 @@ func TestGenericURLFetchUsesResponseMetadataAndOneDownload(t *testing.T) { } func TestGenericURLFetchDoesNotForwardBasicAuthAcrossRedirect(t *testing.T) { - filename := genericArtifactName("redirected", "") + filename := genericArtifactName("redirected", genericScriptExtension()) target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if authorization := r.Header.Get("Authorization"); authorization != "" { t.Fatalf("redirected request Authorization = %q, want empty", authorization) @@ -473,9 +473,9 @@ func TestGenericURLFetchDoesNotForwardBasicAuthAcrossRedirect(t *testing.T) { } func TestGenericURLFetchFilenamePrecedence(t *testing.T) { - contentName := genericArtifactName("content", "") - redirectName := genericArtifactName("redirect", "") - originalName := genericArtifactName("original", "") + contentName := genericArtifactName("content", genericScriptExtension()) + redirectName := genericArtifactName("redirect", genericScriptExtension()) + originalName := genericArtifactName("original", genericScriptExtension()) for _, test := range []struct { name string @@ -576,9 +576,10 @@ func genericScriptExtension() string { func platformFixtureName(unixName, windowsName string) string { if runtime.GOOS == "windows" { - return windowsName + return strings.ReplaceAll(windowsName, "amd64", runtime.GOARCH) } - return unixName + name := strings.ReplaceAll(unixName, "linux", runtime.GOOS) + return strings.ReplaceAll(name, "amd64", runtime.GOARCH) } func genericRunnablePayload(t *testing.T) []byte { diff --git a/pkg/providers/github_integrity_test.go b/pkg/providers/github_integrity_test.go index bd3415a..aa74538 100644 --- a/pkg/providers/github_integrity_test.go +++ b/pkg/providers/github_integrity_test.go @@ -138,22 +138,27 @@ func TestGitHubFetchArchiveDoesNotInheritDownloadIntegrity(t *testing.T) { func TestGitHubFetchExplicitSelectionValidatesFinalPayload(t *testing.T) { resetGitHubReleaseCache(t) + filename := platformFixtureName("tool-linux-amd64", "tool-windows-amd64"+genericScriptExtension()) + invalidPayload := "not executable" + if genericScriptExtension() != "" { + invalidPayload = "#!/bin/sh\nexit 0\n" + } var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/repos/acme/tool/releases/latest": _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.2.3", "assets": []map[string]string{ - {"name": "tool-linux-amd64", "url": server.URL + "/asset"}, + {"name": filename, "url": server.URL + "/asset"}, }}) case "/asset": - _, _ = fmt.Fprint(w, "not executable") + _, _ = fmt.Fprint(w, invalidPayload) default: http.NotFound(w, r) } })) defer server.Close() - _, err := newTestGitHubProvider(t, server.URL, "acme", "tool", "").Fetch(&FetchOpts{AutoSelect: "tool-linux-amd64"}) + _, err := newTestGitHubProvider(t, server.URL, "acme", "tool", "").Fetch(&FetchOpts{AutoSelect: filename}) if !errors.Is(err, assets.ErrNoCompatibleFiles) { t.Fatalf("Fetch() error = %v, want final runnable-payload validation failure", err) } diff --git a/pkg/providers/selection_intent_test.go b/pkg/providers/selection_intent_test.go index c90cbd5..87e9fca 100644 --- a/pkg/providers/selection_intent_test.go +++ b/pkg/providers/selection_intent_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "runtime" "testing" "github.com/aaronflorey/bin/pkg/config" @@ -13,6 +14,11 @@ func TestGitHubFetchAppliesAndReturnsSelectionIntent(t *testing.T) { resetGitHubReleaseCache(t) firstAssetName := platformFixtureName("tool-v2.0.0-linux-amd64-musl", "tool-v2.0.0-windows-amd64-gnu"+genericScriptExtension()) secondAssetName := platformFixtureName("tool-v2.0.0-linux-amd64-glibc", "tool-v2.0.0-windows-amd64-msvc"+genericScriptExtension()) + if runtime.GOOS == "darwin" { + // Darwin has no libc preference with which to distinguish the two Linux-style fixtures. + // Keep a second product so this still proves the persisted logical product is applied. + secondAssetName = "other-v2.0.0-darwin-" + runtime.GOARCH + } payload := genericRunnablePayload(t) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -42,7 +48,10 @@ func TestGitHubFetchAppliesAndReturnsSelectionIntent(t *testing.T) { t.Errorf("close fetched file: %v", err) } }) - if file.SelectionIntent == nil || file.SelectionIntent.LogicalProduct != "tool" || file.SelectionIntent.Target == nil || file.SelectionIntent.Target.ABI == "" { + if file.SelectionIntent == nil || file.SelectionIntent.LogicalProduct != "tool" || file.SelectionIntent.Target == nil || file.SelectionIntent.Target.OS == "" || file.SelectionIntent.Target.Architecture == "" { t.Fatalf("fetched file provenance/intent = %#v", file) } + if runtime.GOOS == "linux" && file.SelectionIntent.Target.ABI == "" { + t.Fatalf("Linux fetched file omitted ABI provenance: %#v", file) + } }