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()) + }) + } +} 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 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) {