From f25c4907bf728da39ad140399df7177f7d4cf855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Mon, 14 Sep 2026 14:31:51 -0600 Subject: [PATCH 1/4] Fall back to the only installable for the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases often name their binaries differently than the repository: atomdrift-project/scan ships atomscan--.tar.gz. With no name in the spec, drop only tried the repository name and failed with "no asset found for scan" even though the release had exactly one thing to install. When nothing is named after the repository, use the only installable that ships a variant for the target platform. Several candidates are an error listing their names and the #name syntax to pick one. An explicit name is never second-guessed. Signed-off-by: Adolfo García Veytia (Puerco) --- pkg/drop/attest_test.go | 2 +- pkg/drop/implementation.go | 56 ++++++++++++++++++++++++++--- pkg/drop/install.go | 6 +++- pkg/drop/install_test.go | 73 +++++++++++++++++++++++++++++++++++++- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/pkg/drop/attest_test.go b/pkg/drop/attest_test.go index 734e350..0616b85 100644 --- a/pkg/drop/attest_test.go +++ b/pkg/drop/attest_test.go @@ -103,7 +103,7 @@ func testResultSet(status string) *papi.ResultSet { func testAsset() *github.Asset { return &github.Asset{ - Host: "github.com", Org: testOrg, Repo: testAppName, + Host: github.DefaultHost, Org: testOrg, Repo: testAppName, Version: testVersion, Name: testBinFile, DownloadURL: testAssetURL, } } diff --git a/pkg/drop/implementation.go b/pkg/drop/implementation.go index 86c77c9..f089d5c 100644 --- a/pkg/drop/implementation.go +++ b/pkg/drop/implementation.go @@ -101,14 +101,58 @@ func (di *defaultImplementation) GetSystemInfo(*Options) (*system.Info, error) { // findInstallable looks in a list of release assets for the installable (or // plain asset) matching the spec name, defaulting to the repository name. -func findInstallable(assets []github.AssetDataProvider, spec github.AssetDataProvider) github.AssetDataProvider { +// findInstallable returns the installable (or plain asset) a spec points to. +// The spec name, defaulting to the repository name, is matched first. When +// the spec carries no name and nothing in the release is named after the +// repository, the fallback is the only installable shipping a variant for +// the platform (releases often name binaries differently than the repo). +// Several such installables are an error listing them, so the user can pick +// one with the #name syntax. Nothing matching returns nil without error. +func findInstallable(assets []github.AssetDataProvider, spec github.AssetDataProvider, osName, arch string) (github.AssetDataProvider, error) { name := specName(spec) for _, asset := range assets { if asset.GetName() == name { - return asset + return asset, nil } } - return nil + + // An explicit name is never second-guessed + if spec.GetName() != "" { + return nil, nil + } + + candidates := []string{} + var found github.AssetDataProvider + for _, asset := range assets { + inst, ok := asset.(*github.Installable) + if !ok || !hasVariant(inst, osName, arch) { + continue + } + candidates = append(candidates, inst.GetName()) + found = inst + } + switch len(candidates) { + case 0: + return nil, nil + case 1: + logrus.Debugf("no asset named %q, using the only installable for %s/%s: %s", name, osName, arch, candidates[0]) + return found, nil + default: + return nil, fmt.Errorf( + "%w for %s/%s (%s): pick one with %s/%s/%s#", ErrAmbiguousInstallable, + osName, arch, strings.Join(candidates, ", "), spec.GetHost(), spec.GetOrg(), spec.GetRepo(), + ) + } +} + +// hasVariant reports if an installable ships a variant for a platform. +func hasVariant(inst *github.Installable, osName, arch string) bool { + for _, v := range inst.Variants { + if v.Os == osName && v.Arch == arch { + return true + } + } + return false } // ChooseAsset selects an installable matching the spec name and local platform @@ -118,7 +162,11 @@ func (di *defaultImplementation) ChooseAsset(opts *GetOptions, client *github.Cl return nil, fmt.Errorf("fetching release assets: %w", err) } - if asset := findInstallable(assets, spec); asset != nil { + asset, err := findInstallable(assets, spec, opts.OS, opts.Arch) + if err != nil { + return nil, err + } + if asset != nil { // Found. Now check if it has variants for the local OS if installable, ok := asset.(*github.Installable); ok { var wantedVariant github.AssetDataProvider diff --git a/pkg/drop/install.go b/pkg/drop/install.go index 264d4a9..69344b0 100644 --- a/pkg/drop/install.go +++ b/pkg/drop/install.go @@ -26,6 +26,7 @@ import ( var ( ErrNoInstallableArtifact = errors.New("release has no binary or compatible package for this platform") ErrOnlyArchives = errors.New("release only ships archives in unsupported formats for this platform") + ErrAmbiguousInstallable = errors.New("release ships several installables") ) // ArtifactKind distinguishes the kinds of artifacts the installer can handle. @@ -488,7 +489,10 @@ func (di *defaultImplementation) SelectInstallArtifact( pkgFormat = "" } - found := findInstallable(assets, spec) + found, err := findInstallable(assets, spec, opts.OS, opts.Arch) + if err != nil { + return nil, err + } if found == nil { // Check the variant filenames in case the user pinned an exact file // in the URL spec: diff --git a/pkg/drop/install_test.go b/pkg/drop/install_test.go index 1c6180c..514e02d 100644 --- a/pkg/drop/install_test.go +++ b/pkg/drop/install_test.go @@ -593,7 +593,7 @@ func TestRecordInstall(t *testing.T) { wantDigest := hex.EncodeToString(sum[:]) asset := &github.Asset{ - Host: "github.com", + Host: github.DefaultHost, Org: testOrg, Repo: testAppName, Version: "v0.1.0", @@ -954,3 +954,74 @@ func TestRemoveInstalledBinary(t *testing.T) { require.NoError(t, di.RemoveInstalled(opts, &inventory.Record{Name: testAppName, Kind: string(ArtifactBinary)})) require.Error(t, di.RemoveInstalled(opts, &inventory.Record{Name: testAppName, Kind: "other"})) } + +func TestFindInstallable(t *testing.T) { + t.Parallel() + inst := func(name string, variants ...*github.Asset) *github.Installable { + return &github.Installable{Name: name, Variants: variants} + } + linux := &github.Asset{Name: "x-linux-amd64.tar.gz", Os: system.OSLinux, Arch: system.ArchAMD64} + darwin := &github.Asset{Name: "x-darwin-arm64.tar.gz", Os: system.OSDarwin, Arch: system.ArchArm64} + // A variant whose architecture was not recognized (leaked into the name) + noArch := &github.Asset{Name: "x-2.0-loongarch64-unknown-linux.tar.gz", Os: system.OSLinux} + spec := func(name string) *github.Asset { + return &github.Asset{Host: github.DefaultHost, Org: "atomdrift-project", Repo: "scan", Name: name} + } + + for _, tc := range []struct { + name string + assets []github.AssetDataProvider + spec *github.Asset + expect string // installable name, "" = nil + expectErr error + }{ + { + name: "named-after-repo", + assets: []github.AssetDataProvider{inst("atomscan", linux), inst("scan", linux)}, + spec: spec(""), expect: "scan", + }, + { + name: "explicit-name", + assets: []github.AssetDataProvider{inst("atomscan", linux), inst("scan", linux)}, + spec: spec("atomscan"), expect: "atomscan", + }, + { + name: "explicit-name-never-guessed", + assets: []github.AssetDataProvider{inst("atomscan", linux)}, + spec: spec("other"), expect: "", + }, + { + name: "fallback-to-only-installable-for-platform", + assets: []github.AssetDataProvider{inst("atomscan", linux, darwin), inst("atomscan-2.0-loongarch64-unknown", noArch), &github.Asset{Name: "SHA256SUMS"}}, + spec: spec(""), expect: "atomscan", + }, + { + name: "no-installable-for-platform", + assets: []github.AssetDataProvider{inst("atomscan", darwin)}, + spec: spec(""), expect: "", + }, + { + name: "ambiguous", + assets: []github.AssetDataProvider{inst("atomscan", linux), inst("atomctl", linux)}, + spec: spec(""), expectErr: ErrAmbiguousInstallable, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + found, err := findInstallable(tc.assets, tc.spec, system.OSLinux, system.ArchAMD64) + if tc.expectErr != nil { + require.ErrorIs(t, err, tc.expectErr) + require.ErrorContains(t, err, "atomscan, atomctl") + require.ErrorContains(t, err, "github.com/atomdrift-project/scan#") + return + } + require.NoError(t, err) + if tc.expect == "" { + require.Nil(t, found) + return + } + require.NotNil(t, found) + require.Equal(t, tc.expect, found.GetName()) + }) + } +} From 42bf54dcce54154a67f7350c35888fdb0c0b1f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Mon, 14 Sep 2026 14:34:49 -0600 Subject: [PATCH 2/4] Recognize Rust target triple architecture names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust releases name assets after target triples with the architecture first: loongarch64-unknown-linux-musl, powerpc64le-unknown-linux-gnu, riscv64gc-unknown-linux-gnu. Unknown architectures leaked into the installable name, so drop ls showed bogus entries such as atomscan-2.11.0-loongarch64-unknown next to the real installable. Add loongarch64 (as loong64), powerpc64le, powerpc64 and riscv64gc to the architecture aliases so these assets are grouped as variants of the same installable. Signed-off-by: Adolfo García Veytia (Puerco) --- pkg/github/elements_installable_test.go | 41 +++++++++++++++++++++++++ pkg/system/data.go | 12 ++++++-- pkg/system/system_test.go | 2 +- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/pkg/github/elements_installable_test.go b/pkg/github/elements_installable_test.go index 9f4c752..7568985 100644 --- a/pkg/github/elements_installable_test.go +++ b/pkg/github/elements_installable_test.go @@ -233,3 +233,44 @@ func TestAssetListToInstallableList(t *testing.T) { }) } } + +// TestRustTargetTriples checks that assets named after Rust target triples +// (arch first, then vendor and OS) are grouped into a single installable, +// including the architectures drop does not install on. +func TestRustTargetTriples(t *testing.T) { + t.Parallel() + files := []string{ + "atomscan-2.11.0-aarch64-apple-darwin.tar.gz", + "atomscan-2.11.0-aarch64-unknown-linux-gnu.tar.gz", + "atomscan-2.11.0-loongarch64-unknown-linux-musl.tar.gz", + "atomscan-2.11.0-powerpc64le-unknown-linux-gnu.tar.gz", + "atomscan-2.11.0-riscv64gc-unknown-linux-gnu.tar.gz", + "atomscan-2.11.0-s390x-unknown-linux-gnu.tar.gz", + "atomscan-2.11.0-x86_64-pc-windows-msvc.tar.gz", + "atomscan-2.11.0-x86_64-unknown-linux-gnu.tar.gz", + "SHA256SUMS", + } + expectArch := map[string]string{ + files[0]: system.ArchArm64, files[1]: system.ArchArm64, files[2]: system.ArchLoong64, + files[3]: system.ArchPPC64LE, files[4]: system.ArchRiscV64, files[5]: system.ArchS390X, + files[6]: system.ArchX8664, files[7]: system.ArchX8664, files[8]: "", + } + assets := make([]AssetDataProvider, 0, len(files)) + for _, f := range files { + require.Equal(t, expectArch[f], getArchFromFilename(f), f) + assets = append(assets, &Asset{Name: f, Version: "v2.11.0"}) + } + + list := assetListToInstallableList(assets) + require.Len(t, list, 2, "one installable plus the checksums file") + names := make([]string, 0, len(list)) + for _, a := range list { + names = append(names, a.GetName()) + } + require.ElementsMatch(t, []string{"atomscan", "SHA256SUMS"}, names) + for _, a := range list { + if inst, ok := a.(*Installable); ok { + require.Len(t, inst.Variants, 8) + } + } +} diff --git a/pkg/system/data.go b/pkg/system/data.go index 042df6a..e1c0650 100644 --- a/pkg/system/data.go +++ b/pkg/system/data.go @@ -31,9 +31,10 @@ var ArchAliases = map[string]LabelList{ ArchArm64: {ArchArm64, ArchAarch64}, ArchArm: {ArchArm, ArchArmHF, ArchArmV7, ArchArmV7HL}, Arch386: {Arch386, ArchI686, ArchI386, Arch32Bit}, - ArchRiscV64: {ArchRiscV64}, + ArchRiscV64: {ArchRiscV64, ArchRiscV64GC}, ArchS390X: {ArchS390X}, - ArchPPC64LE: {ArchPPC64LE, ArchPPC64EL, ArchPPC64}, + ArchPPC64LE: {ArchPPC64LE, ArchPPC64EL, ArchPPC64, ArchPowerPC64LE, ArchPowerPC64}, + ArchLoong64: {ArchLoong64, ArchLoongArch64}, } // Platform constants @@ -64,6 +65,13 @@ const ( ArchPPC64LE = "ppc64le" // IBM Power (redhat naming) ArchPPC64EL = "ppc64el" // IBM Power (debian naming) ArchPPC64 = "ppc64" + ArchLoong64 = "loong64" // LoongArch (go naming) + + // Rust target triple names + ArchPowerPC64LE = "powerpc64le" + ArchPowerPC64 = "powerpc64" + ArchLoongArch64 = "loongarch64" + ArchRiscV64GC = "riscv64gc" // Aliases ArchArmHF = "armhf" diff --git a/pkg/system/system_test.go b/pkg/system/system_test.go index 2c79159..cd6901b 100644 --- a/pkg/system/system_test.go +++ b/pkg/system/system_test.go @@ -9,7 +9,7 @@ import ( func TestMainSplitPattern(t *testing.T) { s := MainSplitPattern() - require.Equal(t, "(?i)(aarch64|armv7hl|freebsd|illumos|openbsd|ppc64el|ppc64le|riscv64|solaris|windows|darwin|netbsd|x86_64|32bit|64bit|amd64|arm64|armhf|armv7|linux|macos|ppc64|s390x|i386|i686|386|arm|osx|x64|x86)", s) + require.Equal(t, "(?i)(loongarch64|powerpc64le|powerpc64|riscv64gc|aarch64|armv7hl|freebsd|illumos|loong64|openbsd|ppc64el|ppc64le|riscv64|solaris|windows|darwin|netbsd|x86_64|32bit|64bit|amd64|arm64|armhf|armv7|linux|macos|ppc64|s390x|i386|i686|386|arm|osx|x64|x86)", s) } func TestParseOSReleaseForFamily(t *testing.T) { From 84511537fbd24bb2a98842041345f2b663d711ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Mon, 14 Sep 2026 16:50:59 -0600 Subject: [PATCH 3/4] Build file:// policy locators portably in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy fetch tests joined "file://" with a temp directory, which on windows produces file://C:\... and fails URL parsing. Build the locators the way --policy-repo does: forward slashes with a leading slash before the drive letter. Signed-off-by: Adolfo García Veytia (Puerco) --- pkg/drop/policies_test.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/pkg/drop/policies_test.go b/pkg/drop/policies_test.go index 638e477..2f11ab5 100644 --- a/pkg/drop/policies_test.go +++ b/pkg/drop/policies_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -26,8 +27,20 @@ func policySetAttestation(id string) string { `"policies":[{"id":"pass","meta":{"description":"test","version":1},"tenets":[{"id":"t","code":"true"}]}]}}`, id) } +// fileLocator returns the file:// locator of a local path, in the form +// localPolicyRepository builds for --policy-repo: forward slashes and a +// leading slash before a windows drive letter (file:///C:/...). A bare +// "file://" + path is not a valid URL on windows. +func fileLocator(path string) string { + slashed := filepath.ToSlash(path) + if !strings.HasPrefix(slashed, "/") { + slashed = "/" + slashed + } + return fileScheme + slashed +} + // newPolicyRepo creates a committed git repository holding one policy set -// per directory, identified by the directory name. +// per directory, identified by the directory name, and returns its locator. func newPolicyRepo(t *testing.T, dirs ...string) string { t.Helper() dir := t.TempDir() @@ -50,7 +63,7 @@ func newPolicyRepo(t *testing.T, dirs ...string) string { Author: &object.Signature{Name: "test", Email: "test@example.com", When: time.Now()}, }) require.NoError(t, err) - return dir + return fileLocator(dir) } func TestFetchPolicies(t *testing.T) { @@ -70,7 +83,7 @@ func TestFetchPolicies(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { t.Parallel() - opts := &Options{Listener: &NoopListener{}, PolicyRepository: "file://" + newPolicyRepo(t, tc.dirs...)} + opts := &Options{Listener: &NoopListener{}, PolicyRepository: newPolicyRepo(t, tc.dirs...)} asset := &github.Asset{Host: github.DefaultHost, Org: testOrg, Repo: testAppName} sets, err := (&defaultImplementation{}).FetchPolicies(opts, asset) @@ -88,7 +101,7 @@ func TestFetchPoliciesMissingRepo(t *testing.T) { t.Parallel() opts := &Options{ Listener: &NoopListener{}, - PolicyRepository: "file://" + filepath.Join(t.TempDir(), "missing"), + PolicyRepository: fileLocator(filepath.Join(t.TempDir(), "missing")), } asset := &github.Asset{Host: github.DefaultHost, Org: testOrg, Repo: testAppName} sets, err := (&defaultImplementation{}).FetchPolicies(opts, asset) @@ -119,13 +132,13 @@ func TestFetchPoliciesCommunityFallback(t *testing.T) { if tc.community { communityDirs = append(communityDirs, communityDir) } - orgRepo := "file://" + newPolicyRepo(t, tc.orgDirs...) + orgRepo := newPolicyRepo(t, tc.orgDirs...) di := &defaultImplementation{ policyRepository: func(_, _ string) string { return orgRepo }, } opts := &Options{ Listener: &NoopListener{}, - CommunityPolicyRepository: "file://" + newPolicyRepo(t, communityDirs...), + CommunityPolicyRepository: newPolicyRepo(t, communityDirs...), } if tc.override { opts.PolicyRepository = orgRepo From 2140c21c57c8812da1635d45b9691de72c46eddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20Garc=C3=ADa=20Veytia=20=28Puerco=29?= Date: Mon, 14 Sep 2026 16:51:56 -0600 Subject: [PATCH 4/4] Cut patch release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is an intentionally empty commit that records release intent. It makes no source changes, its only payload is the git trailers below, which the Carabiner tagger reads when the pull request is merged to create a signed, policy-checked semantic version tag. Removing this commit before merge simply cancels the release request. Tag-commit-as: patch Signed-off-by: Adolfo García Veytia (Puerco)