├── testdata └── store │ ├── missing │ └── .gitkeep │ ├── empty │ └── .binny.state.json │ └── valid-sha256-only │ ├── glow │ ├── quill │ ├── task │ ├── bouncer │ ├── chronicle │ ├── goreleaser │ ├── gosimports │ ├── golangci-lint │ └── .binny.state.json ├── tool ├── testdata │ └── store │ │ ├── stale │ │ ├── glow │ │ ├── quill │ │ ├── task │ │ ├── bouncer │ │ ├── chronicle │ │ ├── goreleaser │ │ ├── gosimports │ │ ├── golangci-lint │ │ └── .binny.state.json │ │ └── valid-sha256-only │ │ ├── glow │ │ ├── quill │ │ ├── task │ │ ├── bouncer │ │ ├── chronicle │ │ ├── goreleaser │ │ ├── gosimports │ │ ├── golangci-lint │ │ └── .binny.state.json ├── githubrelease │ ├── testdata │ │ ├── archive-contents │ │ │ ├── flat │ │ │ │ ├── LICENSE │ │ │ │ ├── READMD.md │ │ │ │ └── syft │ │ │ ├── nested │ │ │ │ └── syft │ │ │ │ │ ├── LICENSE │ │ │ │ │ ├── READMD.md │ │ │ │ │ └── syft │ │ │ └── multiple-bins │ │ │ │ ├── syft-1 │ │ │ │ └── syft-2 │ │ └── checksums.txt │ ├── methods.go │ ├── gh_release.go │ ├── retry_test.go │ ├── methods_test.go │ └── extract.go ├── git │ ├── methods.go │ └── version_resolver.go ├── goproxy │ ├── methods.go │ ├── methods_test.go │ ├── version_resolver.go │ └── version_resolver_test.go ├── check.go ├── goinstall │ ├── methods.go │ ├── methods_test.go │ └── installer.go ├── resolve_version.go ├── hostedshell │ ├── methods.go │ ├── installer_test.go │ ├── methods_test.go │ └── installer.go ├── check_test.go ├── install.go └── config.go ├── .chronicle.yaml ├── internal ├── algorithms.go ├── bus │ ├── bus.go │ └── helpers.go ├── redact │ └── redact.go ├── semver.go ├── download_file_test.go ├── semver_test.go ├── log │ └── log.go └── download_file.go ├── .gitattributes ├── cmd └── binny │ ├── cli │ ├── option │ │ ├── store.go │ │ ├── core.go │ │ ├── check.go │ │ ├── list.go │ │ ├── version_resolution.go │ │ ├── format.go │ │ ├── go_install.go │ │ ├── tool_test.go │ │ └── tool.go │ ├── command │ │ ├── root.go │ │ ├── run_windows.go │ │ ├── add.go │ │ ├── run_unix.go │ │ ├── run.go │ │ ├── add_github_release.go │ │ ├── utils.go │ │ ├── check.go │ │ ├── add_go_install.go │ │ └── install.go │ ├── ui │ │ ├── __snapshots__ │ │ │ ├── handle_task_started_test.snap │ │ │ ├── handle_update_lock_test.snap │ │ │ └── handle_install_test.snap │ │ ├── handle_task_started.go │ │ ├── utils_test.go │ │ ├── handler.go │ │ ├── handle_task_started_test.go │ │ ├── handle_update_lock.go │ │ ├── handle_update_lock_test.go │ │ ├── handle_install_test.go │ │ └── handle_install.go │ ├── internal │ │ ├── ui │ │ │ ├── __snapshots__ │ │ │ │ └── post_ui_event_writer_test.snap │ │ │ ├── no_ui.go │ │ │ ├── post_ui_event_writer_test.go │ │ │ ├── post_ui_event_writer.go │ │ │ └── ui.go │ │ └── yamlpatch │ │ │ ├── helpers.go │ │ │ └── patcher.go │ └── cli.go │ └── main.go ├── .github ├── workflows │ ├── dependabot-automation.yaml │ ├── validate-github-actions.yaml │ ├── validations.yaml │ └── release.yaml ├── scripts │ ├── ci-check.sh │ ├── coverage.py │ ├── go-mod-tidy-check.sh │ └── trigger-release.sh ├── dependabot.yml └── actions │ └── bootstrap │ └── action.yaml ├── event ├── task.go ├── events.go └── parsers.go ├── test └── cli │ ├── testdata │ └── go-install-method.yaml │ ├── install_cmd_test.go │ ├── trait_assertions_test.go │ └── utils_test.go ├── tool.go ├── scripts └── list_units.py ├── .bouncer.yaml ├── .gitignore ├── Makefile ├── .goreleaser.yaml ├── llms.txt ├── .binny.yaml └── .golangci.yaml /testdata/store/missing/.gitkeep: -------------------------------------------------------------------------------- 1 | keep -------------------------------------------------------------------------------- /testdata/store/empty/.binny.state.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /tool/testdata/store/stale/glow: -------------------------------------------------------------------------------- 1 | glow 2 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/quill: -------------------------------------------------------------------------------- 1 | quill 2 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/task: -------------------------------------------------------------------------------- 1 | task 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/glow: -------------------------------------------------------------------------------- 1 | glow 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/quill: -------------------------------------------------------------------------------- 1 | quill 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/task: -------------------------------------------------------------------------------- 1 | task 2 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/bouncer: -------------------------------------------------------------------------------- 1 | bouncer 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/bouncer: -------------------------------------------------------------------------------- 1 | bouncer 2 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/chronicle: -------------------------------------------------------------------------------- 1 | chronicle 2 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/goreleaser: -------------------------------------------------------------------------------- 1 | goreleaser 2 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/gosimports: -------------------------------------------------------------------------------- 1 | gosimports 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/glow: -------------------------------------------------------------------------------- 1 | glow 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/quill: -------------------------------------------------------------------------------- 1 | quill 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/task: -------------------------------------------------------------------------------- 1 | task 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/chronicle: -------------------------------------------------------------------------------- 1 | chronicle 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/goreleaser: -------------------------------------------------------------------------------- 1 | goreleaser 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/gosimports: -------------------------------------------------------------------------------- 1 | gosimports 2 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/golangci-lint: -------------------------------------------------------------------------------- 1 | golangci-lint 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/bouncer: -------------------------------------------------------------------------------- 1 | bouncer 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/chronicle: -------------------------------------------------------------------------------- 1 | chronicle 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/goreleaser: -------------------------------------------------------------------------------- 1 | goreleaser 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/gosimports: -------------------------------------------------------------------------------- 1 | gosimports 2 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/golangci-lint: -------------------------------------------------------------------------------- 1 | golangci-lint 2 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/golangci-lint: -------------------------------------------------------------------------------- 1 | golangci-lint 2 | -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/flat/LICENSE: -------------------------------------------------------------------------------- 1 | license me this 2 | -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/flat/READMD.md: -------------------------------------------------------------------------------- 1 | readme, indeed 2 | -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/nested/syft/LICENSE: -------------------------------------------------------------------------------- 1 | license me this 2 | -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/nested/syft/READMD.md: -------------------------------------------------------------------------------- 1 | readme, indeed 2 | -------------------------------------------------------------------------------- /.chronicle.yaml: -------------------------------------------------------------------------------- 1 | enforce-v0: true # don't make breaking-change label bump major version before 1.0. 2 | title: "" -------------------------------------------------------------------------------- /internal/algorithms.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | const ( 4 | SHA256Algorithm = "sha256" 5 | XXH64Algorithm = "xxh64" 6 | ) 7 | -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/flat/syft: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/binny/HEAD/tool/githubrelease/testdata/archive-contents/flat/syft -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/nested/syft/syft: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/binny/HEAD/tool/githubrelease/testdata/archive-contents/nested/syft/syft -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/multiple-bins/syft-1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/binny/HEAD/tool/githubrelease/testdata/archive-contents/multiple-bins/syft-1 -------------------------------------------------------------------------------- /tool/githubrelease/testdata/archive-contents/multiple-bins/syft-2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anchore/binny/HEAD/tool/githubrelease/testdata/archive-contents/multiple-bins/syft-2 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Prevent CRLF correction from changing digest of 2 | # text files that are committed as stand-ins for binaries 3 | # in text fixtures: 4 | /tool/testdata/store/** -text 5 | -------------------------------------------------------------------------------- /tool/git/methods.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import "strings" 4 | 5 | const ResolveMethod = "git" 6 | 7 | func IsResolveMethod(method string) bool { 8 | return strings.ToLower(method) == ResolveMethod 9 | } 10 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/store.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | type Store struct { 4 | Root string `json:"root" yaml:"root" mapstructure:"root"` 5 | } 6 | 7 | func DefaultStore() Store { 8 | return Store{ 9 | Root: ".tool", 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/root.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/anchore/clio" 7 | ) 8 | 9 | func Root(app clio.Application) *cobra.Command { 10 | return app.SetupRootCommand(&cobra.Command{}) 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automation.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot Automation 2 | on: 3 | pull_request: 4 | 5 | permissions: 6 | pull-requests: write 7 | 8 | jobs: 9 | run: 10 | uses: anchore/workflows/.github/workflows/dependabot-automation.yaml@main 11 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/run_windows.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | ) 7 | 8 | func run(path string, args []string) error { 9 | c := exec.Command(path, args...) 10 | c.Stdout = os.Stdout 11 | c.Stderr = os.Stderr 12 | return c.Run() 13 | } 14 | -------------------------------------------------------------------------------- /tool/goproxy/methods.go: -------------------------------------------------------------------------------- 1 | package goproxy 2 | 3 | import "strings" 4 | 5 | const ResolveMethod = "goproxy" 6 | 7 | func IsResolveMethod(method string) bool { 8 | switch strings.ToLower(method) { 9 | case "go-proxy", "go proxy", ResolveMethod: 10 | return true 11 | } 12 | return false 13 | } 14 | -------------------------------------------------------------------------------- /.github/scripts/ci-check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | red=$(tput setaf 1) 4 | bold=$(tput bold) 5 | normal=$(tput sgr0) 6 | 7 | # assert we are running in CI (or die!) 8 | if [[ -z "$CI" ]]; then 9 | echo "${bold}${red}This step should ONLY be run in CI. Exiting...${normal}" 10 | exit 1 11 | fi 12 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/core.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | // Core options make up the static application configuration on disk. 4 | type Core struct { 5 | Store `json:"" yaml:",inline" mapstructure:",squash"` 6 | Tools Tools `json:"tools" yaml:"tools" mapstructure:"tools"` 7 | } 8 | 9 | func DefaultCore() Core { 10 | return Core{ 11 | Store: DefaultStore(), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /event/task.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "github.com/wagoodman/go-progress" 4 | 5 | type Task struct { 6 | Title Title 7 | Context string 8 | } 9 | 10 | type Title struct { 11 | Default string 12 | WhileRunning string 13 | OnSuccess string 14 | OnFail string 15 | } 16 | 17 | type ManualStagedProgress struct { 18 | *progress.AtomicStage 19 | *progress.Manual 20 | } 21 | -------------------------------------------------------------------------------- /test/cli/testdata/go-install-method.yaml: -------------------------------------------------------------------------------- 1 | tools: 2 | - name: binny 3 | version: 4 | want: v0.7.0 5 | method: go-proxy 6 | with: 7 | module: github.com/anchore/binny 8 | allow-unresolved-version: true 9 | method: go-install 10 | with: 11 | entrypoint: cmd/binny 12 | module: github.com/anchore/binny 13 | ldflags: 14 | - -X main.version={{ .Version }} 15 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/add.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/anchore/clio" 7 | ) 8 | 9 | func Add(app clio.Application) *cobra.Command { 10 | cmd := app.SetupCommand(&cobra.Command{ 11 | Use: "add", 12 | Short: "Add a new tool to the configuration", 13 | }) 14 | 15 | cmd.AddCommand( 16 | AddGoInstall(app), 17 | AddGithubRelease(app), 18 | ) 19 | 20 | return cmd 21 | } 22 | -------------------------------------------------------------------------------- /cmd/binny/cli/ui/__snapshots__/handle_task_started_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [TestHandler_taskStarted/task_in_progress - 1] 3 | ⠙ doing something ━━━━━━━━━━━━━━━━━━━━ current ctx  4 | --- 5 | 6 | [TestHandler_taskStarted/task_complete - 1] 7 | ✔ done something current ctx  8 | --- 9 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/check.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "github.com/anchore/clio" 5 | ) 6 | 7 | type Check struct { 8 | VerifySHA256Digest bool `json:"verify-sha256" yaml:"verify-sha256" mapstructure:"verify-sha256"` 9 | } 10 | 11 | func (o *Check) AddFlags(flags clio.FlagSet) { 12 | flags.BoolVarP(&o.VerifySHA256Digest, "verify-sha256", "", "Verifying the sha256 digest of already installed tools (by default xxh64 is used)") 13 | } 14 | -------------------------------------------------------------------------------- /tool.go: -------------------------------------------------------------------------------- 1 | package binny 2 | 3 | type Tool interface { 4 | Name() string 5 | Installer 6 | VersionResolver 7 | } 8 | 9 | type Installer interface { 10 | InstallTo(version, destDir string) (string, error) 11 | } 12 | 13 | type VersionResolver interface { 14 | ResolveVersion(want, constraint string) (string, error) 15 | UpdateVersion(want, constraint string) (string, error) 16 | } 17 | 18 | type VersionIntent struct { 19 | Want string 20 | Constraint string 21 | } 22 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/list.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import "github.com/anchore/clio" 4 | 5 | type List struct { 6 | Updates bool `json:"updates" yaml:"updates" mapstructure:"updates"` 7 | IncludeFilter []string `json:"includeFilter" yaml:"includeFilter" mapstructure:"includeFilter"` 8 | } 9 | 10 | func (o *List) AddFlags(flags clio.FlagSet) { 11 | flags.BoolVarP(&o.Updates, "updates", "", "List only tool installations that need to be updated (relative to what is currently installed)") 12 | } 13 | -------------------------------------------------------------------------------- /scripts/list_units.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | def go_list_exclude_pattern(owner, project): 5 | exclude_pattern = f"{owner}/{project}/test" 6 | 7 | result = subprocess.run(["go", "list", "./..."], stdout=subprocess.PIPE, text=True, check=True) 8 | 9 | filtered_lines = [line for line in result.stdout.splitlines() if exclude_pattern not in line] 10 | 11 | joined_output = ' '.join(filtered_lines) 12 | 13 | return joined_output 14 | 15 | owner = sys.argv[1] 16 | project = sys.argv[2] 17 | output = go_list_exclude_pattern(owner, project) 18 | print(output) 19 | -------------------------------------------------------------------------------- /cmd/binny/cli/ui/__snapshots__/handle_update_lock_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [TestHandler_updateLock/update_lock_in_progress - 1] 3 | ⠙ Updating version locks ━━━━━━━━━━━━━━━━━━━━ [current total] 4 |  ├── foo 1.2.3 → 1.3.0 5 |  ├── bar 4.5.6 6 |  └── baz 7 | 8 | --- 9 | 10 | [TestHandler_updateLock/update_lock_completed - 1] 11 | ⠙ Updating version locks ━━━━━━━━━━━━━━━━━━━━ [current total] 12 |  ├── foo 1.2.3 → 1.3.0 13 |  ├── bar 4.5.6 14 |  └── baz 7.8.9 15 | --- 16 | -------------------------------------------------------------------------------- /internal/bus/bus.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import "github.com/wagoodman/go-partybus" 4 | 5 | var publisher partybus.Publisher 6 | 7 | // Set sets the singleton event bus publisher. This is optional; if no bus is provided, the library will 8 | // behave no differently than if a bus had been provided. 9 | func Set(p partybus.Publisher) { 10 | publisher = p 11 | } 12 | 13 | func Get() partybus.Publisher { 14 | return publisher 15 | } 16 | 17 | // Publish an event onto the bus. If there is no bus set by the calling application, this does nothing. 18 | func Publish(e partybus.Event) { 19 | if publisher != nil { 20 | publisher.Publish(e) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: gomod 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | open-pull-requests-limit: 10 10 | labels: 11 | - "dependencies" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "daily" 17 | open-pull-requests-limit: 10 18 | labels: 19 | - "dependencies" 20 | 21 | - package-ecosystem: "github-actions" 22 | directory: "/.github/actions/boostrap" 23 | schedule: 24 | interval: "daily" 25 | open-pull-requests-limit: 10 26 | labels: 27 | - "dependencies" 28 | 29 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/version_resolution.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchore/binny/tool" 7 | "github.com/anchore/clio" 8 | ) 9 | 10 | type VersionResolution struct { 11 | Constraint string `json:"constraint" yaml:"constraint" mapstructure:"constraint"` 12 | Method string `json:"method" yaml:"method" mapstructure:"method"` 13 | } 14 | 15 | func (o *VersionResolution) AddFlags(flags clio.FlagSet) { 16 | flags.StringVarP(&o.Constraint, "constraint", "", "Version constraint (e.g. '<2.0' or '>=1.0.0')") 17 | flags.StringVarP(&o.Method, "version-from", "f", fmt.Sprintf("The method to use to resolve the version (available: %+v)", tool.VersionResolverMethods())) 18 | } 19 | -------------------------------------------------------------------------------- /.bouncer.yaml: -------------------------------------------------------------------------------- 1 | permit: 2 | - BSD.* 3 | - CC0.* 4 | - MIT.* 5 | - Apache.* 6 | - MPL.* 7 | - ISC 8 | - WTFPL 9 | 10 | ignore-packages: 11 | # from: https://github.com/xi2/xz/blob/master/LICENSE 12 | # All these files have been put into the public domain. 13 | # You can do whatever you want with these files. 14 | - github.com/xi2/xz 15 | 16 | # crypto/internal/boring is released under the openSSL license as a part of the Golang Standard Libary 17 | - crypto/internal/boring 18 | 19 | # https://github.com/sorairolake/lzip-go/blob/fef65de9b5037fff09499b582712e8411d347249/go.sum.license#L3 20 | # has `SPDX-License-Identifier: Apache-2.0 OR MIT`, both of which are acceptable 21 | - github.com/sorairolake/lzip-go 22 | -------------------------------------------------------------------------------- /tool/check.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchore/binny" 7 | ) 8 | 9 | type VerifyConfig struct { 10 | VerifyXXH64Digest bool 11 | VerifySHA256Digest bool 12 | } 13 | 14 | func Check(store *binny.Store, toolName string, resolvedVersion string, verifyConfig VerifyConfig) error { 15 | entry, err := store.Get(toolName, resolvedVersion) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | if entry == nil { 21 | return fmt.Errorf("tool %q not installed", toolName) 22 | } 23 | 24 | if err := entry.Verify(verifyConfig.VerifyXXH64Digest, verifyConfig.VerifySHA256Digest); err != nil { 25 | return fmt.Errorf("failed to validate tool %q: %w", toolName, err) 26 | } 27 | 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/format.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/anchore/fangs" 7 | ) 8 | 9 | var _ fangs.FlagAdder = (*Format)(nil) 10 | 11 | type Format struct { 12 | Output string `yaml:"output" json:"output" mapstructure:"output"` 13 | AllowableFormats []string `yaml:"-" json:"-" mapstructure:"-"` 14 | JQCommand string `yaml:"jqCommand" json:"jqCommand" mapstructure:"jqCommand"` 15 | } 16 | 17 | func (o *Format) AddFlags(flags fangs.FlagSet) { 18 | flags.StringVarP( 19 | &o.Output, 20 | "output", "o", 21 | fmt.Sprintf("output format to report results in (allowable values: %s)", o.AllowableFormats), 22 | ) 23 | flags.StringVarP( 24 | &o.JQCommand, 25 | "jq", "", 26 | "JQ command to apply to the JSON output", 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # install dirs 2 | /.tmp 3 | /.tools 4 | /.tool 5 | /.task 6 | /bin 7 | 8 | # release locations 9 | /snapshot 10 | /dist 11 | CHANGELOG.md 12 | VERSION 13 | 14 | # IDEs 15 | .idea 16 | .vscode 17 | 18 | # If you prefer the allow list template instead of the deny list, see community template: 19 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 20 | # 21 | # Binaries for programs and plugins 22 | *.exe 23 | *.exe~ 24 | *.dll 25 | *.so 26 | *.dylib 27 | 28 | # Test binary, built with `go test -c` 29 | *.test 30 | 31 | # Output of the go coverage tool, specifically when used with LiteIDE 32 | *.out 33 | 34 | # Dependency directories (remove the comment below to include it) 35 | # vendor/ 36 | 37 | # Go workspace file 38 | go.work 39 | go.work.sum 40 | 41 | # mac 42 | .DS_Store -------------------------------------------------------------------------------- /cmd/binny/cli/ui/__snapshots__/handle_install_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [TestHandler_install/install_in_progress - 1] 3 | ⠋ current total foo, bar, baz 4 | --- 5 | 6 | [TestHandler_install/install_in_progress - 2] 7 | ⠋ current total 8 |  ├── foo 1.2.3 9 |  ├── bar 4.5.6 10 |  └── baz 11 | 12 | --- 13 | 14 | [TestHandler_install/install_completed - 1] 15 | ✔ current total foo, bar, baz 16 | --- 17 | 18 | [TestHandler_install/install_completed - 2] 19 | ✔ current total 20 |  ├── foo 1.2.3 21 |  ├── bar 4.5.6 22 |  └── baz 7.8.9 23 | 24 | --- 25 | -------------------------------------------------------------------------------- /cmd/binny/cli/internal/ui/__snapshots__/post_ui_event_writer_test.snap: -------------------------------------------------------------------------------- 1 | 2 | [Test_postUIEventWriter_write/no_events/stdout - 1] 3 | 4 | --- 5 | 6 | [Test_postUIEventWriter_write/no_events/stderr - 1] 7 | 8 | --- 9 | 10 | [Test_postUIEventWriter_write/all_events/stdout - 1] 11 | 12 | 13 | 17 | 18 | 19 | --- 20 | 21 | [Test_postUIEventWriter_write/all_events/stderr - 1] 22 | 23 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | --- 32 | 33 | [Test_postUIEventWriter_write/quiet_only_shows_report/stdout - 1] 34 | 35 | 36 | --- 37 | 38 | [Test_postUIEventWriter_write/quiet_only_shows_report/stderr - 1] 39 | 40 | --- 41 | -------------------------------------------------------------------------------- /tool/githubrelease/methods.go: -------------------------------------------------------------------------------- 1 | package githubrelease 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | ResolveMethod = "github-release" 10 | InstallMethod = ResolveMethod 11 | ) 12 | 13 | func IsResolveMethod(method string) bool { 14 | return IsInstallMethod(method) 15 | } 16 | 17 | func IsInstallMethod(method string) bool { 18 | switch strings.ToLower(method) { 19 | case "github", "github release", "githubrelease", InstallMethod: 20 | return true 21 | } 22 | return false 23 | } 24 | 25 | func DefaultVersionResolverConfig(installParams any) (string, any, error) { 26 | params, ok := installParams.(InstallerParameters) 27 | if !ok { 28 | return "", nil, fmt.Errorf("invalid go install parameters") 29 | } 30 | 31 | return ResolveMethod, VersionResolutionParameters{ 32 | Repo: params.Repo, 33 | }, nil 34 | } 35 | -------------------------------------------------------------------------------- /tool/goproxy/methods_test.go: -------------------------------------------------------------------------------- 1 | package goproxy 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMethods(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | methods []string 13 | want bool 14 | }{ 15 | { 16 | name: "valid", 17 | methods: []string{"go-proxy", "go proxy", "goproxy"}, 18 | want: true, 19 | }, 20 | { 21 | name: "invalid", 22 | methods: []string{"made up"}, 23 | want: false, 24 | }, 25 | } 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | for _, method := range tt.methods { 29 | t.Run(method, func(t *testing.T) { 30 | t.Run("IsResolveMethod", func(t *testing.T) { 31 | assert.Equal(t, tt.want, IsResolveMethod(method)) 32 | }) 33 | }) 34 | } 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tool/githubrelease/testdata/checksums.txt: -------------------------------------------------------------------------------- 1 | 00f8d897995b30e5a6a5a789619b818669c5ea971745548aacd96bc344a92b83 gosimports_0.3.8_linux_s390x.tar.gz 2 | 40c4816d59127f5dfdb864703905d4efaab213c931715cd4708ced76d13f2339 gosimports_0.3.8_darwin_arm64.tar.gz 3 | 8ae433ba551394f79e782f96cd1b466b3b88c565197104d04500c47c7a015985 gosimports_0.3.8_darwin_amd64.tar.gz 4 | 95e760adf2d0545c0aa982f2bf8cd3f0358d13307e5ca153de4eb9fabc9d72b7 gosimports_0.3.8_windows_arm64.tar.gz 5 | a7bf6bca7196de2ad14a57153de2b71d6ac32b6e5504d537845150970c8e02f6 gosimports_0.3.8_windows_amd64.tar.gz 6 | b4c6bbf1dae1d3f5bc3cb4d88325a47d6affafbdea41bccf1c750a0ae228dc33 gosimports_0.3.8_linux_arm64.tar.gz 7 | c2e5fd01e70d0fa2f93e75ee8aafdf6117b5b6277022ae61c663d0c37d9c82ed gosimports_0.3.8_linux_amd64.tar.gz 8 | fb131aee783fe4f70f17d6923dde6450a60d733d44d2bc34641a2c1078165dea gosimports_0.3.8_linux_ppc64le.tar.gz -------------------------------------------------------------------------------- /cmd/binny/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/anchore/binny/cmd/binny/cli" 5 | "github.com/anchore/clio" 6 | ) 7 | 8 | // applicationName is the non-capitalized name of the application (do not change this) 9 | const ( 10 | applicationName = "binny" 11 | notProvided = "[not provided]" 12 | ) 13 | 14 | // all variables here are provided as build-time arguments, with clear default values 15 | var ( 16 | version = notProvided 17 | buildDate = notProvided 18 | gitCommit = notProvided 19 | gitDescription = notProvided 20 | ) 21 | 22 | func main() { 23 | app := cli.New( 24 | clio.Identification{ 25 | Name: applicationName, 26 | Version: version, 27 | BuildDate: buildDate, 28 | GitCommit: gitCommit, 29 | GitDescription: gitDescription, 30 | }, 31 | ) 32 | 33 | app.Run() 34 | } 35 | -------------------------------------------------------------------------------- /tool/githubrelease/gh_release.go: -------------------------------------------------------------------------------- 1 | package githubrelease 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type ghRelease struct { 10 | Tag string 11 | Date *time.Time 12 | IsLatest *bool 13 | IsDraft *bool 14 | Assets []ghAsset 15 | } 16 | 17 | type ghAsset struct { 18 | Name string 19 | ContentType string 20 | URL string 21 | Checksum string 22 | } 23 | 24 | func (a *ghAsset) addChecksum(value string) { 25 | if strings.Contains(value, ":") { 26 | a.Checksum = value 27 | return 28 | } 29 | 30 | // note: assume this is a hex digest 31 | var method string 32 | switch len(value) { 33 | case 32: 34 | method = "md5" 35 | case 40: 36 | method = "sha1" 37 | case 64: 38 | method = "sha256" 39 | case 128: 40 | method = "sha512" 41 | default: 42 | // dunno, just capture the value 43 | a.Checksum = value 44 | return 45 | } 46 | 47 | a.Checksum = fmt.Sprintf("%s:%s", method, value) 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/validate-github-actions.yaml: -------------------------------------------------------------------------------- 1 | name: "Validate GitHub Actions" 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/**' 7 | - '.github/actions/**' 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - '.github/workflows/**' 13 | - '.github/actions/**' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | zizmor: 20 | name: "Lint" 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | security-events: write # for uploading SARIF results 25 | steps: 26 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 27 | with: 28 | persist-credentials: false 29 | 30 | - name: "Run zizmor" 31 | uses: zizmorcore/zizmor-action@e639db99335bc9038abc0e066dfcd72e23d26fb4 # v0.3.0 32 | with: 33 | config-file: .github/zizmor.yml 34 | sarif-upload: true 35 | inputs: .github 36 | -------------------------------------------------------------------------------- /.github/scripts/coverage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | import shlex 5 | 6 | 7 | class bcolors: 8 | HEADER = '\033[95m' 9 | OKBLUE = '\033[94m' 10 | OKCYAN = '\033[96m' 11 | OKGREEN = '\033[92m' 12 | WARNING = '\033[93m' 13 | FAIL = '\033[91m' 14 | ENDC = '\033[0m' 15 | BOLD = '\033[1m' 16 | UNDERLINE = '\033[4m' 17 | 18 | 19 | if len(sys.argv) < 3: 20 | print("Usage: coverage.py [threshold] [go-coverage-report]") 21 | sys.exit(1) 22 | 23 | 24 | threshold = float(sys.argv[1]) 25 | report = sys.argv[2] 26 | 27 | 28 | args = shlex.split(f"go tool cover -func {report}") 29 | p = subprocess.run(args, capture_output=True, text=True) 30 | 31 | percent_coverage = float(p.stdout.splitlines()[-1].split()[-1].replace("%", "")) 32 | print(f"{bcolors.BOLD}Coverage: {percent_coverage}%{bcolors.ENDC}") 33 | 34 | if percent_coverage < threshold: 35 | print(f"{bcolors.BOLD}{bcolors.FAIL}Coverage below threshold of {threshold}%{bcolors.ENDC}") 36 | sys.exit(1) 37 | -------------------------------------------------------------------------------- /cmd/binny/cli/internal/ui/no_ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/wagoodman/go-partybus" 7 | 8 | "github.com/anchore/binny/event" 9 | "github.com/anchore/clio" 10 | ) 11 | 12 | var _ clio.UI = (*NoUI)(nil) 13 | 14 | type NoUI struct { 15 | finalizeEvents []partybus.Event 16 | subscription partybus.Unsubscribable 17 | quiet bool 18 | } 19 | 20 | func None(quiet bool) *NoUI { 21 | return &NoUI{ 22 | quiet: quiet, 23 | } 24 | } 25 | 26 | func (n *NoUI) Setup(subscription partybus.Unsubscribable) error { 27 | n.subscription = subscription 28 | return nil 29 | } 30 | 31 | func (n *NoUI) Handle(e partybus.Event) error { 32 | switch e.Type { 33 | case event.CLIReport, event.CLINotification: 34 | // keep these for when the UI is terminated to show to the screen (or perform other events) 35 | n.finalizeEvents = append(n.finalizeEvents, e) 36 | } 37 | return nil 38 | } 39 | 40 | func (n NoUI) Teardown(_ bool) error { 41 | return newPostUIEventWriter(os.Stdout, os.Stderr).write(n.quiet, n.finalizeEvents...) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/binny/cli/ui/handle_task_started.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/wagoodman/go-partybus" 6 | 7 | "github.com/anchore/binny/event" 8 | "github.com/anchore/binny/internal/log" 9 | "github.com/anchore/bubbly/bubbles/taskprogress" 10 | ) 11 | 12 | func (m *Handler) handleTaskStarted(e partybus.Event) []tea.Model { 13 | cmd, prog, err := event.ParseTaskStarted(e) 14 | if err != nil { 15 | log.Warnf("unable to parse event: %+v", err) 16 | return nil 17 | } 18 | 19 | tsk := taskprogress.New( 20 | m.Running, 21 | taskprogress.WithStagedProgressable(prog), 22 | ) 23 | 24 | tsk.HideProgressOnSuccess = true 25 | tsk.TitleWidth = len(cmd.Title.WhileRunning) 26 | tsk.HintEndCaps = nil 27 | tsk.TitleOptions = taskprogress.Title{ 28 | Default: cmd.Title.Default, 29 | Running: cmd.Title.WhileRunning, 30 | Success: cmd.Title.OnSuccess, 31 | Failed: cmd.Title.OnFail, 32 | } 33 | tsk.Context = []string{cmd.Context} 34 | tsk.WindowSize = m.WindowSize 35 | 36 | return []tea.Model{tsk} 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/validations.yaml: -------------------------------------------------------------------------------- 1 | name: "Validations" 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | env: 14 | FORCE_COLOR: true 15 | 16 | jobs: 17 | 18 | Validations: 19 | # Note: changing this job name requires making the same update in the .github/workflows/release.yaml pipeline 20 | name: "Validations" 21 | runs-on: ubuntu-22.04 22 | steps: 23 | - uses: actions/checkout@v6.0.1 24 | with: 25 | persist-credentials: false 26 | 27 | - name: Bootstrap environment 28 | uses: ./.github/actions/bootstrap 29 | 30 | - name: Run all validations 31 | run: make pr-validations 32 | 33 | WindowsValidations: 34 | name: "Windows units" 35 | runs-on: windows-2022 36 | steps: 37 | - uses: actions/checkout@v6.0.1 38 | with: 39 | persist-credentials: false 40 | 41 | - name: install make 42 | run: "choco install make" 43 | 44 | - name: run units 45 | run: "make unit" 46 | -------------------------------------------------------------------------------- /cmd/binny/cli/ui/utils_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "reflect" 5 | "sync" 6 | "testing" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func runModel(t testing.TB, m tea.Model, iterations int, cmd tea.Cmd, h ...*sync.WaitGroup) string { 12 | t.Helper() 13 | if iterations == 0 { 14 | iterations = 1 15 | } 16 | m.Init() 17 | 18 | for _, each := range h { 19 | if each != nil { 20 | each.Wait() 21 | } 22 | } 23 | 24 | for i := 0; cmd != nil && i < iterations; i++ { 25 | msgs := flatten(cmd()) 26 | var nextCmds []tea.Cmd 27 | var next tea.Cmd 28 | for _, msg := range msgs { 29 | t.Logf("Message: %+v %+v\n", reflect.TypeOf(msg), msg) 30 | m, next = m.Update(msg) 31 | nextCmds = append(nextCmds, next) 32 | } 33 | cmd = tea.Batch(nextCmds...) 34 | } 35 | 36 | return m.View() 37 | } 38 | 39 | func flatten(p tea.Msg) (msgs []tea.Msg) { 40 | switch v := p.(type) { 41 | case tea.BatchMsg: 42 | for _, cmd := range v { 43 | msgs = append(msgs, flatten(cmd())...) 44 | } 45 | default: 46 | msgs = []tea.Msg{p} 47 | } 48 | 49 | return msgs 50 | } 51 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/go_install.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import "github.com/anchore/clio" 4 | 5 | type GoInstall struct { 6 | Module string `json:"module" yaml:"module" mapstructure:"module"` 7 | Entrypoint string `json:"entrypoint" yaml:"entrypoint" mapstructure:"entrypoint"` 8 | LDFlags string `json:"ld-flags" yaml:"ld-flags" mapstructure:"ld-flags"` 9 | Args []string `json:"args" yaml:"args" mapstructure:"args"` 10 | Env []string `json:"env" yaml:"env" mapstructure:"env"` 11 | } 12 | 13 | func (o *GoInstall) AddFlags(flags clio.FlagSet) { 14 | flags.StringVarP(&o.Module, "module", "m", "Go module (e.g. github.com/anchore/syft)") 15 | flags.StringVarP(&o.Entrypoint, "entrypoint", "e", "Entrypoint within the go module (e.g. cmd/syft)") 16 | flags.StringVarP(&o.LDFlags, "ld-flags", "l", "LD flags to pass to the go install command (e.g. -ldflags \"-X main.version=1.0.0\")") 17 | flags.StringArrayVarP(&o.Args, "args", "a", "Additional arguments to pass to the go install command") 18 | flags.StringArrayVarP(&o.Env, "env", "", "Environment variables to pass to the go install command") 19 | } 20 | -------------------------------------------------------------------------------- /.github/scripts/go-mod-tidy-check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | ORIGINAL_STATE_DIR=$(mktemp -d "TEMP-original-state-XXXXXXXXX") 5 | TIDY_STATE_DIR=$(mktemp -d "TEMP-tidy-state-XXXXXXXXX") 6 | 7 | trap "cp -p ${ORIGINAL_STATE_DIR}/* ./ && git update-index -q --refresh && rm -fR ${ORIGINAL_STATE_DIR} ${TIDY_STATE_DIR}" EXIT 8 | 9 | # capturing original state of files... 10 | cp go.mod go.sum "${ORIGINAL_STATE_DIR}" 11 | 12 | # capturing state of go.mod and go.sum after running go mod tidy... 13 | go mod tidy 14 | cp go.mod go.sum "${TIDY_STATE_DIR}" 15 | 16 | set +e 17 | 18 | # detect difference between the git HEAD state and the go mod tidy state 19 | DIFF_MOD=$(diff -u "${ORIGINAL_STATE_DIR}/go.mod" "${TIDY_STATE_DIR}/go.mod") 20 | DIFF_SUM=$(diff -u "${ORIGINAL_STATE_DIR}/go.sum" "${TIDY_STATE_DIR}/go.sum") 21 | 22 | if [[ -n "${DIFF_MOD}" || -n "${DIFF_SUM}" ]]; then 23 | echo "go.mod diff:" 24 | echo "${DIFF_MOD}" 25 | echo "go.sum diff:" 26 | echo "${DIFF_SUM}" 27 | echo "" 28 | printf "FAILED! go.mod and/or go.sum are NOT tidy; please run 'go mod tidy'.\n\n" 29 | exit 1 30 | fi 31 | -------------------------------------------------------------------------------- /tool/goinstall/methods.go: -------------------------------------------------------------------------------- 1 | package goinstall 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/anchore/binny/tool/git" 8 | "github.com/anchore/binny/tool/goproxy" 9 | ) 10 | 11 | const InstallMethod = "go-install" 12 | 13 | func IsInstallMethod(method string) bool { 14 | switch strings.ToLower(method) { 15 | case "go", "go install", "goinstall", "golang", InstallMethod: 16 | return true 17 | } 18 | return false 19 | } 20 | 21 | func DefaultVersionResolverConfig(installParams any) (string, any, error) { 22 | params, ok := installParams.(InstallerParameters) 23 | if !ok { 24 | return "", nil, fmt.Errorf("invalid go install parameters") 25 | } 26 | 27 | if strings.HasPrefix(params.Module, ".") || strings.HasPrefix(params.Module, "/") { 28 | // this is a path to a local repo, version updating should be disabled and the version should be 29 | // set to the current vcs value 30 | return git.ResolveMethod, git.VersionResolutionParameters{ 31 | Path: params.Module, 32 | }, nil 33 | } 34 | 35 | return goproxy.ResolveMethod, goproxy.VersionResolutionParameters{ 36 | Module: params.Module, 37 | }, nil 38 | } 39 | -------------------------------------------------------------------------------- /tool/githubrelease/retry_test.go: -------------------------------------------------------------------------------- 1 | package githubrelease 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNewRetryableGitHubClient_RetriesWithAuthHeader(t *testing.T) { 13 | var requests int 14 | var authHeaders []string 15 | 16 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | requests++ 18 | authHeaders = append(authHeaders, r.Header.Get("Authorization")) 19 | 20 | if requests < 3 { 21 | w.WriteHeader(http.StatusServiceUnavailable) 22 | return 23 | } 24 | w.WriteHeader(http.StatusOK) 25 | })) 26 | defer server.Close() 27 | 28 | token := "test-token-12345" 29 | client := newRetryableGitHubClient(token) 30 | 31 | resp, err := client.Get(server.URL) 32 | require.NoError(t, err) 33 | defer resp.Body.Close() 34 | 35 | assert.Equal(t, 3, requests, "expected 3 requests (2 retries)") 36 | expectedAuth := "Bearer " + token 37 | for i, auth := range authHeaders { 38 | assert.Equal(t, expectedAuth, auth, "request %d missing auth header", i+1) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/bus/helpers.go: -------------------------------------------------------------------------------- 1 | package bus 2 | 3 | import ( 4 | "github.com/wagoodman/go-partybus" 5 | "github.com/wagoodman/go-progress" 6 | 7 | "github.com/anchore/binny/event" 8 | "github.com/anchore/binny/internal/redact" 9 | "github.com/anchore/clio" 10 | ) 11 | 12 | func Exit() { 13 | Publish(clio.ExitEvent(false)) 14 | } 15 | 16 | func ExitWithInterrupt() { 17 | Publish(clio.ExitEvent(true)) 18 | } 19 | 20 | func Report(report string) { 21 | if len(report) == 0 { 22 | return 23 | } 24 | report = redact.Apply(report) 25 | Publish(partybus.Event{ 26 | Type: event.CLIReport, 27 | Value: report, 28 | }) 29 | } 30 | 31 | func Notify(message string) { 32 | Publish(partybus.Event{ 33 | Type: event.CLINotification, 34 | Value: message, 35 | }) 36 | } 37 | 38 | func PublishTask(titles event.Title, context string, total int) *event.ManualStagedProgress { 39 | prog := &event.ManualStagedProgress{ 40 | Manual: progress.NewManual(int64(total)), 41 | AtomicStage: progress.NewAtomicStage(""), 42 | } 43 | 44 | Publish(partybus.Event{ 45 | Type: event.TaskStartedEvent, 46 | Source: event.Task{ 47 | Title: titles, 48 | Context: context, 49 | }, 50 | Value: progress.StagedProgressable(prog), 51 | }) 52 | 53 | return prog 54 | } 55 | -------------------------------------------------------------------------------- /internal/redact/redact.go: -------------------------------------------------------------------------------- 1 | package redact 2 | 3 | import "github.com/anchore/go-logger/adapter/redact" 4 | 5 | var store redact.Store 6 | 7 | func Set(s redact.Store) { 8 | if store != nil { 9 | // if someone is trying to set a redaction store and we already have one then something is wrong. The store 10 | // that we're replacing might already have values in it, so we should never replace it. 11 | panic("replace existing redaction store (probably unintentional)") 12 | } 13 | store = s 14 | } 15 | 16 | func Get() redact.Store { 17 | return store 18 | } 19 | 20 | func Add(vs ...string) { 21 | if store == nil { 22 | // if someone is trying to add values that should never be output and we don't have a store then something is wrong. 23 | // we should never accidentally output values that should be redacted, thus we panic here. 24 | panic("cannot add redactions without a store") 25 | } 26 | store.Add(vs...) 27 | } 28 | 29 | func Apply(value string) string { 30 | if store == nil { 31 | // if someone is trying to add values that should never be output and we don't have a store then something is wrong. 32 | // we should never accidentally output values that should be redacted, thus we panic here. 33 | panic("cannot apply redactions without a store") 34 | } 35 | return store.RedactString(value) 36 | } 37 | -------------------------------------------------------------------------------- /internal/semver.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/Masterminds/semver/v3" 8 | 9 | "github.com/anchore/binny/internal/log" 10 | ) 11 | 12 | func FilterToLatestVersion(versions []string, versionConstraint string) (string, error) { 13 | var parsed []*semver.Version 14 | for _, v := range versions { 15 | v = strings.TrimSpace(v) 16 | if v == "" { 17 | continue 18 | } 19 | ver, err := semver.NewVersion(v) 20 | if err != nil { 21 | log.Tracef("failed to parse version %q: %v", v, err) 22 | continue 23 | } 24 | parsed = append(parsed, ver) 25 | } 26 | 27 | var constraint *semver.Constraints 28 | var err error 29 | if versionConstraint != "" { 30 | constraint, err = semver.NewConstraint(versionConstraint) 31 | if err != nil { 32 | return "", fmt.Errorf("unable to parse version constraint %q: %v", versionConstraint, err) 33 | } 34 | } 35 | 36 | var maxVal *semver.Version 37 | for _, v := range parsed { 38 | if constraint != nil && !constraint.Check(v) { 39 | continue 40 | } 41 | if maxVal == nil || v.GreaterThan(maxVal) { 42 | maxVal = v 43 | } 44 | } 45 | 46 | if maxVal == nil { 47 | return "", nil 48 | } 49 | return maxVal.Original(), nil 50 | } 51 | 52 | func IsSemver(v string) bool { 53 | ver, err := semver.NewVersion(v) 54 | if err != nil { 55 | return false 56 | } 57 | return ver != nil 58 | } 59 | -------------------------------------------------------------------------------- /tool/resolve_version.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Masterminds/semver/v3" 7 | 8 | "github.com/anchore/binny" 9 | "github.com/anchore/binny/tool/git" 10 | "github.com/anchore/binny/tool/githubrelease" 11 | "github.com/anchore/binny/tool/goproxy" 12 | ) 13 | 14 | func VersionResolverMethods() []string { 15 | return []string{ 16 | githubrelease.ResolveMethod, 17 | goproxy.ResolveMethod, 18 | git.ResolveMethod, 19 | } 20 | } 21 | 22 | func ResolveVersion(tool binny.VersionResolver, intent binny.VersionIntent) (string, error) { 23 | want := intent.Want 24 | constraint := intent.Constraint 25 | 26 | var resolvedVersion string 27 | 28 | resolvedVersion, err := tool.ResolveVersion(want, constraint) 29 | if err != nil { 30 | return "", fmt.Errorf("failed to resolve version: %w", err) 31 | } 32 | 33 | if constraint != "" { 34 | ver, err := semver.NewVersion(resolvedVersion) 35 | if err == nil { 36 | constraintObj, err := semver.NewConstraint(constraint) 37 | if err != nil { 38 | return resolvedVersion, fmt.Errorf("invalid version constraint: %v", err) 39 | } 40 | 41 | if !constraintObj.Check(ver) { 42 | return resolvedVersion, fmt.Errorf("resolved version %q is unsatisfied by constraint %q. Remove the constraint or run 'update' to re-pin a valid version", resolvedVersion, constraint) 43 | } 44 | } 45 | } 46 | return resolvedVersion, nil 47 | } 48 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/run_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package command 4 | 5 | import ( 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "os/signal" 11 | "syscall" 12 | 13 | "github.com/creack/pty" 14 | "golang.org/x/term" 15 | ) 16 | 17 | func run(path string, args []string) error { 18 | c := exec.Command(path, args...) 19 | 20 | ptmx, err := pty.Start(c) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | // make sure to close the pty at the end 26 | defer func() { _ = ptmx.Close() }() // best effort 27 | 28 | // handle pty size 29 | ch := make(chan os.Signal, 1) 30 | signal.Notify(ch, syscall.SIGWINCH) 31 | go func() { 32 | for range ch { 33 | if err := pty.InheritSize(os.Stdin, ptmx); err != nil { 34 | log.Printf("error resizing pty: %s", err) 35 | } 36 | } 37 | }() 38 | ch <- syscall.SIGWINCH // initial resize 39 | defer func() { signal.Stop(ch); close(ch) }() // cleanup signals when done 40 | 41 | // Set stdin in raw mode. 42 | oldState, err := term.MakeRaw(int(os.Stdin.Fd())) 43 | if err != nil { 44 | panic(err) 45 | } 46 | defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() // best effort 47 | 48 | // copy stdin to the pty and the pty to stdout. The goroutine will keep reading until the next keystroke before returning. 49 | go func() { _, _ = io.Copy(ptmx, os.Stdin) }() 50 | _, _ = io.Copy(os.Stdout, ptmx) 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /tool/hostedshell/methods.go: -------------------------------------------------------------------------------- 1 | package hostedshell 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/anchore/binny/tool/githubrelease" 9 | ) 10 | 11 | const InstallMethod = "hosted-shell" 12 | 13 | func IsInstallMethod(method string) bool { 14 | switch strings.ToLower(method) { 15 | case InstallMethod, "hostedshell", "hosted shell", "hostedscript", "hosted script", "hosted-script": 16 | return true 17 | } 18 | return false 19 | } 20 | 21 | func DefaultVersionResolverConfig(installParams any) (string, any, error) { 22 | params, ok := installParams.(InstallerParameters) 23 | if !ok { 24 | return "", nil, fmt.Errorf("invalid hosted shell parameters") 25 | } 26 | 27 | if strings.Contains(params.URL, "github.com") || strings.Contains(params.URL, "raw.githubusercontent.com") { 28 | u, err := url.Parse(params.URL) 29 | if err != nil { 30 | return "", nil, fmt.Errorf("failed to github release parse url %q: %v", params.URL, err) 31 | } 32 | 33 | fields := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") 34 | if len(fields) < 2 { 35 | return "", nil, fmt.Errorf("invalid github release url %q", params.URL) 36 | } 37 | 38 | repo := fmt.Sprintf("%s/%s", fields[0], fields[1]) 39 | 40 | return githubrelease.ResolveMethod, githubrelease.VersionResolutionParameters{ 41 | Repo: repo, 42 | }, nil 43 | } 44 | 45 | return "", nil, fmt.Errorf("no default version resolver for hosted shell with the current configuration") 46 | } 47 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | OWNER = anchore 2 | PROJECT = binny 3 | 4 | TOOL_DIR = .tool 5 | BINNY = $(TOOL_DIR)/binny 6 | TASK = $(TOOL_DIR)/task 7 | 8 | .DEFAULT_GOAL := make-default 9 | 10 | ## Bootstrapping targets ################################# 11 | 12 | $(BINNY): 13 | @-mkdir $(TOOL_DIR) 14 | # we don't have a release of binny yet, so build off of the current branch 15 | # curl -sSfL https://raw.githubusercontent.com/$(OWNER)/$(PROJECT)/main/install.sh | sh -s -- -b $(TOOL_DIR) 16 | go build -o $(TOOL_DIR)/$(PROJECT) ./cmd/$(PROJECT) 17 | 18 | .PHONY: task 19 | $(TASK) task: $(BINNY) 20 | $(BINNY) install task 21 | 22 | .PHONY: ci-bootstrap-go 23 | ci-bootstrap-go: 24 | go mod download 25 | 26 | .PHONY: ci-bootstrap-tools 27 | ci-bootstrap-tools: $(BINNY) 28 | $(BINNY) install -vvv 29 | 30 | # this is a bootstrapping catch-all, where if the target doesn't exist, we'll ensure the tools are installed and then try again 31 | %: 32 | make $(TASK) 33 | $(TASK) $@ 34 | 35 | ## Shim targets ################################# 36 | 37 | .PHONY: make-default 38 | make-default: $(TASK) 39 | @# run the default task in the taskfile 40 | @$(TASK) 41 | 42 | # for those of us that can't seem to kick the habit of typing `make ...` lets wrap the superior `task` tool 43 | TASKS := $(shell bash -c "$(TASK) -l | grep '^\* ' | cut -d' ' -f2 | tr -d ':' | tr '\n' ' '" ) $(shell bash -c "$(TASK) -l | grep 'aliases:' | cut -d ':' -f 3 | tr '\n' ' ' | tr -d ','") 44 | 45 | .PHONY: $(TASKS) 46 | $(TASKS): $(TASK) 47 | @$(TASK) $@ 48 | 49 | help: $(TASK) 50 | @$(TASK) -l 51 | -------------------------------------------------------------------------------- /cmd/binny/cli/ui/handler.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "sync" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/wagoodman/go-partybus" 8 | 9 | "github.com/anchore/binny/event" 10 | "github.com/anchore/bubbly" 11 | "github.com/anchore/bubbly/bubbles/taskprogress" 12 | ) 13 | 14 | var _ interface { 15 | bubbly.EventHandler 16 | bubbly.MessageListener 17 | bubbly.HandleWaiter 18 | } = (*Handler)(nil) 19 | 20 | type HandlerConfig struct { 21 | TitleWidth int 22 | AdjustDefaultTask func(taskprogress.Model) taskprogress.Model 23 | } 24 | 25 | type Handler struct { 26 | WindowSize tea.WindowSizeMsg 27 | Running *sync.WaitGroup 28 | Config HandlerConfig 29 | 30 | bubbly.EventHandler 31 | } 32 | 33 | func DefaultHandlerConfig() HandlerConfig { 34 | return HandlerConfig{ 35 | TitleWidth: 30, 36 | } 37 | } 38 | 39 | func New(cfg HandlerConfig) *Handler { 40 | d := bubbly.NewEventDispatcher() 41 | 42 | h := &Handler{ 43 | EventHandler: d, 44 | Running: &sync.WaitGroup{}, 45 | Config: cfg, 46 | } 47 | 48 | // register all supported event types with the respective handler functions 49 | d.AddHandlers(map[partybus.EventType]bubbly.EventHandlerFn{ 50 | event.CLIInstallCmdStarted: h.handleCLIInstallCmdStarted, 51 | event.CLIUpdateCmdStarted: h.handleCLIUpdateLockCmdStarted, 52 | event.TaskStartedEvent: h.handleTaskStarted, 53 | }) 54 | 55 | return h 56 | } 57 | 58 | func (m *Handler) OnMessage(msg tea.Msg) { 59 | if msg, ok := msg.(tea.WindowSizeMsg); ok { 60 | m.WindowSize = msg 61 | } 62 | } 63 | 64 | func (m *Handler) Wait() { 65 | m.Running.Wait() 66 | } 67 | -------------------------------------------------------------------------------- /event/events.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "github.com/wagoodman/go-partybus" 5 | ) 6 | 7 | const ( 8 | typePrefix = "binny" 9 | cliTypePrefix = typePrefix + "-cli" 10 | 11 | // Events from the binny library 12 | 13 | // ToolInstallationStartedEvent is a partybus event that occurs when a single tool installation has begun 14 | ToolInstallationStartedEvent partybus.EventType = typePrefix + "-tool-installation-started" 15 | 16 | // ToolUpdateVersionStartedEvent is a partybus event that occurs when a single tool version update has begun (for the update command) 17 | ToolUpdateVersionStartedEvent partybus.EventType = typePrefix + "-tool-update-version-started" 18 | 19 | // TaskStartedEvent is a generic, monitorable partybus event that occurs when a task has begun 20 | TaskStartedEvent partybus.EventType = typePrefix + "-task" 21 | 22 | // Events exclusively for the CLI 23 | 24 | // CLIInstallCmdStarted is a partybus event that occurs when the install CLI command has begun 25 | CLIInstallCmdStarted partybus.EventType = cliTypePrefix + "-install-cmd-started" 26 | 27 | // CLIUpdateCmdStarted is a partybus event that occurs when the install CLI command has begun 28 | CLIUpdateCmdStarted partybus.EventType = cliTypePrefix + "-update-cmd-started" 29 | 30 | // CLIReport is a partybus event that occurs when an analysis result is ready for final presentation to stdout 31 | CLIReport partybus.EventType = cliTypePrefix + "-report" 32 | 33 | // CLINotification is a partybus event that occurs when auxiliary information is ready for presentation to stderr 34 | CLINotification partybus.EventType = cliTypePrefix + "-notification" 35 | ) 36 | -------------------------------------------------------------------------------- /tool/testdata/store/stale/.binny.state.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": [ 3 | { 4 | "name": "quill", 5 | "version": "v0.4.1", 6 | "sha256": "7777777777777777777777777777777777777777777777777777777777777777", 7 | "path": "quill" 8 | }, 9 | { 10 | "name": "chronicle", 11 | "version": "v0.7.0", 12 | "sha256": "e011590e5d55188e03a2fd58524853ddacd23ec2e5d58535e061339777c4043f", 13 | "path": "chronicle" 14 | }, 15 | { 16 | "name": "gosimports", 17 | "version": "v0.3.8", 18 | "sha256": "9e5837236320efadb7a94675866cbd95e7a9716d635f3863603859698a37591a", 19 | "path": "gosimports" 20 | }, 21 | { 22 | "name": "glow", 23 | "version": "v1.5.1", 24 | "sha256": "c6f05b9383f97fbb6fb2bb84b87b3b99ed7a1708d8a1634ff66d5bff8180f3b0", 25 | "path": "glow" 26 | }, 27 | { 28 | "name": "goreleaser", 29 | "version": "v1.20.0", 30 | "sha256": "307dd15253ab292a57dff221671659f3133593df485cc08fdd8158d63222bb16", 31 | "path": "goreleaser" 32 | }, 33 | { 34 | "name": "golangci-lint", 35 | "version": "v1.54.2", 36 | "sha256": "06c3715b43f4e92d0e9ec98ba8aa0f0c08c8963b2862ec130ec8e1c1ad9e1d1d", 37 | "path": "golangci-lint" 38 | }, 39 | { 40 | "name": "bouncer", 41 | "version": "v0.4.0", 42 | "sha256": "de42a2453c8e9b2587358c1f244a5cc0091c71385126f0fa3c0b3aec0feeaa4d", 43 | "path": "bouncer" 44 | }, 45 | { 46 | "name": "task", 47 | "version": "v3.29.1", 48 | "sha256": "8d92c81f07960c5363a1f424e88dd4b64a1dd4251378d53873fa65ea1aab271b", 49 | "path": "task" 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /internal/download_file_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/anchore/go-logger/adapter/discard" 14 | ) 15 | 16 | func Test_DownloadFile(t *testing.T) { 17 | contents := `this is the file!` 18 | expectedDigest := "fd979f1e39618058000d02793baa4694afb1a1ba1a463b1a543806992be5b5b2" 19 | tests := []struct { 20 | name string 21 | checksum string 22 | wantErr require.ErrorAssertionFunc 23 | }{ 24 | { 25 | name: "happy path", 26 | checksum: expectedDigest, 27 | }, 28 | { 29 | name: "mismatched checksum", 30 | checksum: "805694affd979f1438069800e3961b1a1ba50d02793baa492be5b5b2a1a463b1", 31 | wantErr: require.Error, 32 | }, 33 | { 34 | name: "missing checksum", 35 | checksum: "", 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | if tt.wantErr == nil { 41 | tt.wantErr = require.NoError 42 | } 43 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | require.Equal(t, "GET", r.Method) 45 | _, err := w.Write([]byte(contents)) 46 | require.NoError(t, err) 47 | return 48 | })) 49 | t.Cleanup(s.Close) 50 | 51 | dir := t.TempDir() 52 | dlPath := filepath.Join(dir, "the-file-path.txt") 53 | 54 | tt.wantErr(t, DownloadFile(discard.New(), s.URL, dlPath, tt.checksum)) 55 | 56 | gotContents, err := os.ReadFile(dlPath) 57 | require.NoError(t, err) 58 | 59 | assert.Equal(t, contents, string(gotContents)) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /internal/semver_test.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func Test_filterLatestVersion(t *testing.T) { 11 | 12 | tests := []struct { 13 | name string 14 | versions []string 15 | versionConstraint string 16 | want string 17 | wantErr require.ErrorAssertionFunc 18 | }{ 19 | { 20 | name: "no versions", 21 | }, 22 | { 23 | name: "simple version", 24 | versions: []string{"v0.2.0", "v1.2.0", "v1.0.0", "v1.1.0"}, 25 | want: "v1.2.0", 26 | }, 27 | { 28 | name: "pre-release version", 29 | versions: []string{"v0.2.0", "v1.2.0", "v1.2.0-rc0", "v1.0.0", "v1.1.0"}, 30 | want: "v1.2.0", 31 | }, 32 | { 33 | name: "with version constraint", 34 | versions: []string{"v0.2.0", "v1.2.0", "v1.0.0", "v1.1.0"}, 35 | versionConstraint: "<= v1.1.0", 36 | want: "v1.1.0", 37 | }, 38 | { 39 | name: "with version constraint outside range", 40 | versions: []string{"v0.2.0", "v1.2.0", "v1.0.0", "v1.1.0"}, 41 | versionConstraint: "<= v5, >= v2", 42 | want: "", 43 | }, 44 | { 45 | name: "bad constraint", 46 | versions: []string{"v0.2.0", "v1.2.0", "v1.0.0", "v1.1.0"}, 47 | versionConstraint: "<=! v1.1.0", 48 | wantErr: require.Error, 49 | }, 50 | } 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | if tt.wantErr == nil { 54 | tt.wantErr = require.NoError 55 | } 56 | got, err := FilterToLatestVersion(tt.versions, tt.versionConstraint) 57 | tt.wantErr(t, err) 58 | assert.Equal(t, got, tt.want) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/scripts/trigger-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | TOOL_DIR=.tool 5 | GH=$TOOL_DIR/gh 6 | 7 | bold=$(tput bold) 8 | normal=$(tput sgr0) 9 | 10 | if ! [ -x "$(command -v $GH)" ]; then 11 | echo "The GitHub CLI could not be found." 12 | exit 1 13 | fi 14 | 15 | $GH auth status 16 | 17 | # we need all of the git state to determine the next version. Since tagging is done by 18 | # the release pipeline it is possible to not have all of the tags from previous releases. 19 | git fetch --tags 20 | 21 | # populates the CHANGELOG.md and VERSION files 22 | echo "${bold}Generating changelog...${normal}" 23 | make changelog 2> /dev/null 24 | 25 | NEXT_VERSION=$(cat VERSION) 26 | 27 | if [[ "$NEXT_VERSION" == "" || "${NEXT_VERSION}" == "(Unreleased)" ]]; then 28 | echo "Could not determine the next version to release. Exiting..." 29 | exit 1 30 | fi 31 | 32 | while true; do 33 | read -p "${bold}Do you want to trigger a release for version '${NEXT_VERSION}'?${normal} [y/n] " yn 34 | case $yn in 35 | [Yy]* ) echo; break;; 36 | [Nn]* ) echo; echo "Cancelling release..."; exit;; 37 | * ) echo "Please answer yes or no.";; 38 | esac 39 | done 40 | 41 | echo "${bold}Kicking off release for ${NEXT_VERSION}${normal}..." 42 | echo 43 | $GH workflow run release.yaml -f version=${NEXT_VERSION} 44 | 45 | echo 46 | echo "${bold}Waiting for release to start...${normal}" 47 | sleep 10 48 | 49 | set +e 50 | 51 | echo "${bold}Head to the release workflow to monitor the release:${normal} $($GH run list --workflow=release.yaml --limit=1 --json url --jq '.[].url')" 52 | id=$($GH run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[].databaseId') 53 | $GH run watch $id --exit-status || (echo ; echo "${bold}Logs of failed step:${normal}" && GH_PAGER="" $GH run view $id --log-failed) 54 | -------------------------------------------------------------------------------- /test/cli/install_cmd_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestInstallCmd(t *testing.T) { 8 | 9 | type step struct { 10 | name string 11 | args []string 12 | env map[string]string 13 | assertions []traitAssertion 14 | } 15 | 16 | tests := []struct { 17 | name string 18 | steps []step 19 | }{ 20 | { 21 | name: "use go-install method", 22 | steps: []step{ 23 | { 24 | name: "install", 25 | args: []string{"install", "-c", "testdata/go-install-method.yaml"}, 26 | assertions: []traitAssertion{ 27 | assertSuccessfulReturnCode, 28 | assertFileInStoreExists(".binny.state.json"), 29 | assertFileInStoreExists("binny"), 30 | assertManagedToolOutput("binny", []string{"--version"}, "binny v0.7.0\n"), 31 | }, 32 | }, 33 | { 34 | name: "list", 35 | args: []string{"list", "-c", "testdata/go-install-method.yaml", "-o", "json"}, 36 | assertions: []traitAssertion{ 37 | assertSuccessfulReturnCode, 38 | assertJson, 39 | assertInOutput(`"installedVersion": "v0.7.0"`), 40 | }, 41 | }, 42 | }, 43 | }, 44 | } 45 | 46 | for _, test := range tests { 47 | t.Run(test.name, func(t *testing.T) { 48 | // we always have a clean slate for every test, but a shared state for each step 49 | d := t.TempDir() 50 | 51 | for _, s := range test.steps { 52 | t.Run(s.name, func(t *testing.T) { 53 | if s.env == nil { 54 | s.env = make(map[string]string) 55 | } 56 | s.env["BINNY_ROOT"] = d 57 | 58 | cmd, stdout, stderr := runBinny(t, s.env, s.args...) 59 | for _, traitFn := range s.assertions { 60 | traitFn(t, d, stdout, stderr, cmd.ProcessState.ExitCode()) 61 | } 62 | 63 | logOutputOnFailure(t, cmd, stdout, stderr) 64 | }) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.github/actions/bootstrap/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Bootstrap" 2 | description: "Bootstrap all tools and dependencies" 3 | inputs: 4 | go-version: 5 | description: "Go version to install" 6 | required: true 7 | default: "1.24.x" 8 | cache-key-prefix: 9 | description: "Prefix all cache keys with this value" 10 | required: true 11 | default: "831180ac25" 12 | bootstrap-apt-packages: 13 | description: "Space delimited list of tools to install via apt" 14 | default: "" 15 | 16 | runs: 17 | using: "composite" 18 | steps: 19 | - uses: actions/setup-go@v4 20 | with: 21 | go-version: ${{ inputs.go-version }} 22 | 23 | - name: Restore tool cache 24 | id: tool-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: ${{ github.workspace }}/.tool 28 | key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('Taskfile.yaml') }}-${{ hashFiles('**/go.sum') }} 29 | restore-keys: | 30 | ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('Taskfile.yaml') }} 31 | ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool 32 | 33 | - name: (cache-miss) Bootstrap project tools 34 | shell: bash 35 | run: make ci-bootstrap-tools 36 | 37 | - name: Bootstrap go dependencies 38 | shell: bash 39 | run: make ci-bootstrap-go 40 | 41 | - name: Install apt packages 42 | if: inputs.bootstrap-apt-packages != '' 43 | shell: bash 44 | env: 45 | APT_PACKAGES: ${{ inputs.bootstrap-apt-packages }} 46 | run: | 47 | # Convert space-separated string to bash array for safe handling 48 | read -ra packages <<< "$APT_PACKAGES" 49 | if [ ${#packages[@]} -gt 0 ]; then 50 | DEBIAN_FRONTEND=noninteractive sudo apt update && sudo -E apt install -y "${packages[@]}" 51 | fi 52 | -------------------------------------------------------------------------------- /tool/check_test.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/anchore/binny" 9 | ) 10 | 11 | func Test_check_sha256(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | storeRoot string 15 | resolvedVersion string 16 | toolName string 17 | verifyDigest bool 18 | wantErr require.ErrorAssertionFunc 19 | }{ 20 | { 21 | name: "valid (sha256)", 22 | storeRoot: "testdata/store/valid-sha256-only", 23 | resolvedVersion: "v1.54.2", 24 | verifyDigest: true, 25 | toolName: "golangci-lint", 26 | }, 27 | { 28 | name: "different version resolver config", 29 | wantErr: require.Error, 30 | storeRoot: "testdata/store/valid", 31 | resolvedVersion: "v1.54.3", 32 | verifyDigest: true, 33 | toolName: "golangci-lint", 34 | }, 35 | { 36 | name: "different stored sha", 37 | wantErr: require.Error, 38 | storeRoot: "testdata/store/stale", 39 | resolvedVersion: "v0.4.1", 40 | verifyDigest: true, 41 | toolName: "quill", 42 | }, 43 | { 44 | name: "ignore different stored sha", 45 | wantErr: require.NoError, 46 | storeRoot: "testdata/store/stale", 47 | resolvedVersion: "v0.4.1", 48 | verifyDigest: false, 49 | toolName: "quill", 50 | }, 51 | } 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | if tt.wantErr == nil { 55 | tt.wantErr = require.NoError 56 | } 57 | store, err := binny.NewStore(tt.storeRoot) 58 | require.NoError(t, err) 59 | 60 | tt.wantErr(t, Check(store, tt.toolName, tt.resolvedVersion, VerifyConfig{ 61 | VerifyXXH64Digest: false, 62 | VerifySHA256Digest: tt.verifyDigest, 63 | })) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | release: 2 | prerelease: auto 3 | draft: false 4 | 5 | env: 6 | - CGO_ENABLED=0 7 | 8 | builds: 9 | - id: linux-build 10 | dir: ./cmd/binny 11 | binary: binny 12 | goos: 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | # set the modified timestamp on the output binary to the git timestamp to ensure a reproducible build 18 | mod_timestamp: &build-timestamp '{{ .CommitTimestamp }}' 19 | ldflags: &build-ldflags | 20 | -w 21 | -s 22 | -extldflags '-static' 23 | -X main.version={{.Version}} 24 | -X main.gitCommit={{.Commit}} 25 | -X main.buildDate={{.Date}} 26 | -X main.gitDescription={{.Summary}} 27 | 28 | - id: darwin-build 29 | dir: ./cmd/binny 30 | binary: binny 31 | goos: 32 | - darwin 33 | goarch: 34 | - amd64 35 | - arm64 36 | mod_timestamp: *build-timestamp 37 | ldflags: *build-ldflags 38 | hooks: 39 | post: 40 | - cmd: .tool/quill sign-and-notarize "{{ .Path }}" --dry-run={{ .IsSnapshot }} --ad-hoc={{ .IsSnapshot }} -vv 41 | env: 42 | - QUILL_LOG_FILE=/tmp/quill-{{ .Target }}.log 43 | 44 | # not supported yet 45 | # - id: windows-build 46 | # dir: ./cmd/binny 47 | # binary: binny 48 | # goos: 49 | # - windows 50 | # goarch: 51 | # - amd64 52 | # mod_timestamp: *build-timestamp 53 | # ldflags: *build-ldflags 54 | 55 | archives: 56 | - id: linux-archives 57 | builds: 58 | - linux-build 59 | 60 | - id: darwin-archives 61 | builds: 62 | - darwin-build 63 | 64 | # not supported yet 65 | # - id: windows-archives 66 | # format: zip 67 | # builds: 68 | # - windows-build 69 | 70 | sboms: 71 | - artifacts: binary 72 | documents: 73 | - "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.spdx.json" 74 | cmd: ../.tool/syft 75 | args: ["$artifact", "--file", "$document", "--output", "spdx-json"] 76 | -------------------------------------------------------------------------------- /testdata/store/valid-sha256-only/.binny.state.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": [ 3 | { 4 | "name": "quill", 5 | "version": "v0.4.1", 6 | "digests": { 7 | "sha256": "56656877b8b0e0c06a96e83df12157565b91bb8f6b55c4051c0466edf0f08b85" 8 | }, 9 | "path": "quill" 10 | }, 11 | { 12 | "name": "chronicle", 13 | "version": "v0.7.0", 14 | "digests": { 15 | "sha256": "e011590e5d55188e03a2fd58524853ddacd23ec2e5d58535e061339777c4043f" 16 | }, 17 | "path": "chronicle" 18 | }, 19 | { 20 | "name": "gosimports", 21 | "version": "v0.3.8", 22 | "digests": { 23 | "sha256": "9e5837236320efadb7a94675866cbd95e7a9716d635f3863603859698a37591a" 24 | }, 25 | "path": "gosimports" 26 | }, 27 | { 28 | "name": "glow", 29 | "version": "v1.5.1", 30 | "digests": { 31 | "sha256": "c6f05b9383f97fbb6fb2bb84b87b3b99ed7a1708d8a1634ff66d5bff8180f3b0" 32 | }, 33 | "path": "glow" 34 | }, 35 | { 36 | "name": "goreleaser", 37 | "version": "v1.20.0", 38 | "digests": { 39 | "sha256": "307dd15253ab292a57dff221671659f3133593df485cc08fdd8158d63222bb16" 40 | }, 41 | "path": "goreleaser" 42 | }, 43 | { 44 | "name": "golangci-lint", 45 | "version": "v1.54.2", 46 | "digests": { 47 | "sha256": "06c3715b43f4e92d0e9ec98ba8aa0f0c08c8963b2862ec130ec8e1c1ad9e1d1d" 48 | }, 49 | "path": "golangci-lint" 50 | }, 51 | { 52 | "name": "bouncer", 53 | "version": "v0.4.0", 54 | "digests": { 55 | "sha256": "de42a2453c8e9b2587358c1f244a5cc0091c71385126f0fa3c0b3aec0feeaa4d" 56 | }, 57 | "path": "bouncer" 58 | }, 59 | { 60 | "name": "task", 61 | "version": "v3.29.1", 62 | "digests": { 63 | "sha256": "8d92c81f07960c5363a1f424e88dd4b64a1dd4251378d53873fa65ea1aab271b" 64 | }, 65 | "path": "task" 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /tool/testdata/store/valid-sha256-only/.binny.state.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": [ 3 | { 4 | "name": "quill", 5 | "version": "v0.4.1", 6 | "digests": { 7 | "sha256": "56656877b8b0e0c06a96e83df12157565b91bb8f6b55c4051c0466edf0f08b85" 8 | }, 9 | "path": "quill" 10 | }, 11 | { 12 | "name": "chronicle", 13 | "version": "v0.7.0", 14 | "digests": { 15 | "sha256": "e011590e5d55188e03a2fd58524853ddacd23ec2e5d58535e061339777c4043f" 16 | }, 17 | "path": "chronicle" 18 | }, 19 | { 20 | "name": "gosimports", 21 | "version": "v0.3.8", 22 | "digests": { 23 | "sha256": "9e5837236320efadb7a94675866cbd95e7a9716d635f3863603859698a37591a" 24 | }, 25 | "path": "gosimports" 26 | }, 27 | { 28 | "name": "glow", 29 | "version": "v1.5.1", 30 | "digests": { 31 | "sha256": "c6f05b9383f97fbb6fb2bb84b87b3b99ed7a1708d8a1634ff66d5bff8180f3b0" 32 | }, 33 | "path": "glow" 34 | }, 35 | { 36 | "name": "goreleaser", 37 | "version": "v1.20.0", 38 | "digests": { 39 | "sha256": "307dd15253ab292a57dff221671659f3133593df485cc08fdd8158d63222bb16" 40 | }, 41 | "path": "goreleaser" 42 | }, 43 | { 44 | "name": "golangci-lint", 45 | "version": "v1.54.2", 46 | "digests": { 47 | "sha256": "06c3715b43f4e92d0e9ec98ba8aa0f0c08c8963b2862ec130ec8e1c1ad9e1d1d" 48 | }, 49 | "path": "golangci-lint" 50 | }, 51 | { 52 | "name": "bouncer", 53 | "version": "v0.4.0", 54 | "digests": { 55 | "sha256": "de42a2453c8e9b2587358c1f244a5cc0091c71385126f0fa3c0b3aec0feeaa4d" 56 | }, 57 | "path": "bouncer" 58 | }, 59 | { 60 | "name": "task", 61 | "version": "v3.29.1", 62 | "digests": { 63 | "sha256": "8d92c81f07960c5363a1f424e88dd4b64a1dd4251378d53873fa65ea1aab271b" 64 | }, 65 | "path": "task" 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /cmd/binny/cli/internal/ui/post_ui_event_writer_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/gkampitakis/go-snaps/snaps" 8 | "github.com/stretchr/testify/require" 9 | "github.com/wagoodman/go-partybus" 10 | 11 | "github.com/anchore/binny/event" 12 | ) 13 | 14 | func Test_postUIEventWriter_write(t *testing.T) { 15 | 16 | tests := []struct { 17 | name string 18 | quiet bool 19 | events []partybus.Event 20 | wantErr require.ErrorAssertionFunc 21 | }{ 22 | { 23 | name: "no events", 24 | }, 25 | { 26 | name: "all events", 27 | events: []partybus.Event{ 28 | { 29 | Type: event.CLINotification, 30 | Value: "\n\n\n\n", 31 | }, 32 | { 33 | Type: event.CLINotification, 34 | Value: "", 35 | }, 36 | { 37 | Type: event.CLINotification, 38 | Value: "", 39 | }, 40 | { 41 | Type: event.CLIReport, 42 | Value: "\n\n\n\n", 43 | }, 44 | { 45 | Type: event.CLIReport, 46 | Value: "", 47 | }, 48 | }, 49 | }, 50 | { 51 | name: "quiet only shows report", 52 | quiet: true, 53 | events: []partybus.Event{ 54 | 55 | { 56 | Type: event.CLINotification, 57 | Value: "", 58 | }, 59 | { 60 | Type: event.CLIReport, 61 | Value: "", 62 | }, 63 | }, 64 | }, 65 | } 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | if tt.wantErr == nil { 69 | tt.wantErr = require.NoError 70 | } 71 | 72 | stdout := &bytes.Buffer{} 73 | stderr := &bytes.Buffer{} 74 | w := newPostUIEventWriter(stdout, stderr) 75 | 76 | tt.wantErr(t, w.write(tt.quiet, tt.events...)) 77 | 78 | t.Run("stdout", func(t *testing.T) { 79 | snaps.MatchSnapshot(t, stdout.String()) 80 | }) 81 | 82 | t.Run("stderr", func(t *testing.T) { 83 | snaps.MatchSnapshot(t, stderr.String()) 84 | }) 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tool/githubrelease/methods_test.go: -------------------------------------------------------------------------------- 1 | package githubrelease 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMethods(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | methods []string 13 | want bool 14 | }{ 15 | { 16 | name: "valid", 17 | methods: []string{"github-release", "github release", "github", "githubrelease"}, 18 | want: true, 19 | }, 20 | { 21 | name: "invalid", 22 | methods: []string{"made up"}, 23 | want: false, 24 | }, 25 | } 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | for _, method := range tt.methods { 29 | t.Run(method, func(t *testing.T) { 30 | t.Run("IsInstallMethod", func(t *testing.T) { 31 | assert.Equal(t, tt.want, IsInstallMethod(method)) 32 | }) 33 | t.Run("IsResolveMethod", func(t *testing.T) { 34 | assert.Equal(t, tt.want, IsResolveMethod(method)) 35 | }) 36 | }) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestDefaultVersionResolverConfig(t *testing.T) { 43 | tests := []struct { 44 | name string 45 | installParams any 46 | wantMethod string 47 | wantParams any 48 | wantErr assert.ErrorAssertionFunc 49 | }{ 50 | { 51 | name: "valid", 52 | installParams: InstallerParameters{ 53 | Repo: "anchore/binny", 54 | }, 55 | wantMethod: ResolveMethod, 56 | wantParams: VersionResolutionParameters{ 57 | Repo: "anchore/binny", 58 | }, 59 | }, 60 | { 61 | name: "invalid", 62 | installParams: map[string]string{ 63 | "repo": "anchore/binny", 64 | }, 65 | wantErr: assert.Error, 66 | }, 67 | } 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | if tt.wantErr == nil { 71 | tt.wantErr = assert.NoError 72 | } 73 | method, params, err := DefaultVersionResolverConfig(tt.installParams) 74 | if !tt.wantErr(t, err) { 75 | return 76 | } 77 | assert.Equal(t, tt.wantMethod, method) 78 | assert.Equal(t, tt.wantParams, params) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /llms.txt: -------------------------------------------------------------------------------- 1 | # Anchore binny 2 | 3 | Manage a directory of binaries without a package manager. 4 | 5 | ## Project Overview 6 | 7 | binny is a CLI tool that helps manage binary dependencies for projects without relying on traditional package managers. It allows you to: 8 | 9 | - Install and manage binary tools from various sources (GitHub releases, Go modules, shell scripts) 10 | - Pin specific versions with constraint support 11 | - Keep all your binary dependencies configured in a single YAML file 12 | - Update to latest versions within constraints 13 | - Check for missing or inconsistent installations 14 | 15 | ## Architecture 16 | 17 | The project is structured as a Go CLI application with the following key components: 18 | 19 | - **CLI Interface** (`cmd/binny/`): Main command-line interface with subcommands (install, check, update, list, add, run) 20 | - **Tool Management** (`tool/`): Core logic for different installation methods: 21 | - `githubrelease/`: Install from GitHub releases 22 | - `goinstall/`: Install via `go install` 23 | - `hostedshell/`: Install via hosted shell scripts 24 | - **Storage** (`store.go`): Manages installed binary storage and metadata 25 | - **Configuration**: YAML-based configuration in `.binny.yaml` files 26 | - **Event System** (`event/`): Internal event handling for UI updates 27 | 28 | ## Key Files 29 | 30 | - `cmd/binny/main.go`: Entry point 31 | - `store.go`: Binary storage management 32 | - `tool/`: Installation method implementations 33 | - `cmd/binny/cli/command/`: CLI command implementations 34 | 35 | ## Installation Methods 36 | 37 | 1. **github-release**: Downloads binaries from GitHub releases 38 | 2. **go-install**: Uses `go install` to build and install Go tools 39 | 3. **hosted-shell**: Executes installation shell scripts from URLs 40 | 41 | ## Version Resolution 42 | 43 | Supports multiple strategies for determining available versions: 44 | - GitHub releases API 45 | - Go module proxy 46 | - Git repository tags 47 | - Direct version specification 48 | 49 | The tool is designed to be simple, reliable, and focused on binary dependency management for development workflows. -------------------------------------------------------------------------------- /tool/goinstall/methods_test.go: -------------------------------------------------------------------------------- 1 | package goinstall 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/anchore/binny/tool/goproxy" 9 | ) 10 | 11 | func TestMethods(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | methods []string 15 | want bool 16 | }{ 17 | { 18 | name: "valid", 19 | methods: []string{"go-install", "go", "go install", "goinstall", "golang"}, 20 | want: true, 21 | }, 22 | { 23 | name: "invalid", 24 | methods: []string{"made up"}, 25 | want: false, 26 | }, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | for _, method := range tt.methods { 31 | t.Run(method, func(t *testing.T) { 32 | t.Run("IsInstallMethod", func(t *testing.T) { 33 | assert.Equal(t, tt.want, IsInstallMethod(method)) 34 | }) 35 | }) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestDefaultVersionResolverConfig(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | installParams any 45 | wantMethod string 46 | wantParams any 47 | wantErr assert.ErrorAssertionFunc 48 | }{ 49 | { 50 | name: "valid", 51 | installParams: InstallerParameters{ 52 | Module: "github.com/anchore/binny", 53 | Entrypoint: "cmd/binny", 54 | LDFlags: []string{"-X main.version=1.0.0"}, 55 | }, 56 | wantMethod: goproxy.ResolveMethod, 57 | wantParams: goproxy.VersionResolutionParameters{ 58 | Module: "github.com/anchore/binny", 59 | }, 60 | }, 61 | { 62 | name: "invalid", 63 | installParams: map[string]string{ 64 | "module": "github.com/anchore/binny", 65 | }, 66 | wantErr: assert.Error, 67 | }, 68 | } 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | if tt.wantErr == nil { 72 | tt.wantErr = assert.NoError 73 | } 74 | method, params, err := DefaultVersionResolverConfig(tt.installParams) 75 | if !tt.wantErr(t, err) { 76 | return 77 | } 78 | assert.Equal(t, tt.wantMethod, method) 79 | assert.Equal(t, tt.wantParams, params) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.binny.yaml: -------------------------------------------------------------------------------- 1 | tools: 2 | - name: binny 3 | version: 4 | # since the module is . then "current" means whatever is checked out 5 | want: current 6 | method: go-install 7 | with: 8 | # aka: github.com/anchore/binny, without going through github / goproxy (stay local) 9 | module: . 10 | entrypoint: cmd/binny 11 | ldflags: 12 | - -X main.version={{ .Version }} 13 | - -X main.gitCommit={{ .Version }} 14 | - -X main.gitDescription={{ .Version }} 15 | # note: sprig functions are available: http://masterminds.github.io/sprig/ 16 | - -X main.buildDate={{ now | date "2006-01-02T15:04:05Z07:00" }} 17 | 18 | - name: gh 19 | version: 20 | want: v2.67.0 21 | method: github-release 22 | with: 23 | repo: cli/cli 24 | 25 | - name: quill 26 | version: 27 | want: v0.5.1 28 | method: github-release 29 | with: 30 | repo: anchore/quill 31 | 32 | - name: chronicle 33 | version: 34 | want: v0.8.0 35 | method: github-release 36 | with: 37 | repo: anchore/chronicle 38 | 39 | - name: gosimports 40 | version: 41 | want: v0.3.8 42 | method: github-release 43 | with: 44 | repo: rinchsan/gosimports 45 | 46 | - name: glow 47 | version: 48 | want: v2.1.0 49 | method: github-release 50 | with: 51 | repo: charmbracelet/glow 52 | 53 | - name: goreleaser 54 | version: 55 | want: v2.7.0 56 | method: github-release 57 | with: 58 | repo: goreleaser/goreleaser 59 | 60 | - name: golangci-lint 61 | version: 62 | want: v1.64.6 63 | method: github-release 64 | with: 65 | repo: golangci/golangci-lint 66 | 67 | - name: bouncer 68 | version: 69 | want: v0.4.0 70 | method: github-release 71 | with: 72 | repo: wagoodman/go-bouncer 73 | 74 | - name: task 75 | version: 76 | want: v3.41.0 77 | method: github-release 78 | with: 79 | repo: go-task/task 80 | 81 | - name: syft 82 | version: 83 | want: v1.20.0 84 | method: github-release 85 | with: 86 | repo: anchore/syft 87 | -------------------------------------------------------------------------------- /cmd/binny/cli/option/tool_test.go: -------------------------------------------------------------------------------- 1 | package option 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/anchore/binny/tool/githubrelease" 9 | ) 10 | 11 | func TestDeriveInstallParameters_GithubRelease(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | params map[string]any 15 | goos string 16 | expected githubrelease.InstallerParameters 17 | expectErr require.ErrorAssertionFunc 18 | }{ 19 | { 20 | name: "valid parameters with binary name", 21 | params: map[string]any{ 22 | "binary": "mytool", 23 | "repo": "owner/repo", 24 | }, 25 | goos: "linux", 26 | expected: githubrelease.InstallerParameters{Binary: "mytool", Repo: "owner/repo"}, 27 | expectErr: require.NoError, 28 | }, 29 | { 30 | name: "missing binary name, should default to tool name on Linux", 31 | params: map[string]any{ 32 | "repo": "owner/repo", 33 | }, 34 | goos: "linux", 35 | expected: githubrelease.InstallerParameters{Binary: "mytool", Repo: "owner/repo"}, 36 | expectErr: require.NoError, 37 | }, 38 | { 39 | name: "missing binary name, should default to tool name with .exe on Windows", 40 | params: map[string]any{ 41 | "repo": "owner/repo", 42 | }, 43 | goos: "windows", 44 | expected: githubrelease.InstallerParameters{Binary: "mytool.exe", Repo: "owner/repo"}, 45 | expectErr: require.NoError, 46 | }, 47 | { 48 | name: "bad data shape should return an error", 49 | params: map[string]any{ 50 | "binary": map[string]string{"bogus": "BogOsiTy"}, 51 | }, 52 | goos: "linux", 53 | expectErr: require.Error, 54 | }, 55 | } 56 | 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | result, err := deriveInstallParameters("mytool", "githubrelease", tt.params, tt.goos) 60 | if tt.expectErr == nil { 61 | tt.expectErr = require.NoError 62 | } 63 | tt.expectErr(t, err) 64 | if err == nil { 65 | instParams, ok := result.(githubrelease.InstallerParameters) 66 | require.True(t, ok) 67 | require.Equal(t, tt.expected, instParams) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /cmd/binny/cli/internal/yamlpatch/helpers.go: -------------------------------------------------------------------------------- 1 | package yamlpatch 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | ) 6 | 7 | func FindToolVersionWantNode(toolsNode *yaml.Node, toolName string) *yaml.Node { 8 | toolNode := FindToolNode(toolsNode, toolName) 9 | toolVersionNode := findToolVersionNode(toolNode) 10 | toolVersionWantNode := findToolVersionWantNode(toolVersionNode) 11 | return toolVersionWantNode 12 | } 13 | 14 | func FindToolsSequenceNode(node *yaml.Node) *yaml.Node { 15 | for idx, v := range node.Content { 16 | var next *yaml.Node 17 | if idx+1 < len(node.Content) { 18 | next = node.Content[idx+1] 19 | } else { 20 | break 21 | } 22 | if v.Value == "tools" { 23 | if next.Tag == "!!seq" { 24 | return next 25 | } 26 | if next == nil { 27 | return node.Content[idx] 28 | } 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | func FindToolNode(toolSequenceNode *yaml.Node, name string) *yaml.Node { 35 | for _, v := range toolSequenceNode.Content { 36 | if v.Tag != "!!map" { 37 | continue 38 | } 39 | var candidateName string 40 | // each element in the sequence is a map 41 | for idx, v2 := range v.Content { 42 | if idx%2 == 0 && v2.Value == "name" { 43 | candidateName = v.Content[idx+1].Value 44 | break 45 | } 46 | } 47 | 48 | if candidateName == name { 49 | return v 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func findToolVersionNode(toolNode *yaml.Node) *yaml.Node { 56 | // each element is the k=v pair in a map 57 | for idx, v := range toolNode.Content { 58 | if idx%2 == 0 && v.Value == "version" { 59 | return toolNode.Content[idx+1] 60 | } 61 | } 62 | return nil 63 | } 64 | 65 | func findToolVersionWantNode(toolVersionNode *yaml.Node) *yaml.Node { 66 | // each element is the k=v pair in a map 67 | for idx, v := range toolVersionNode.Content { 68 | if idx%2 == 0 && v.Value == "want" { 69 | return toolVersionNode.Content[idx+1] 70 | } 71 | } 72 | return nil 73 | } 74 | 75 | func GetYamlNode(s any) (*yaml.Node, error) { 76 | var n yaml.Node 77 | 78 | by, err := yaml.Marshal(s) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | err = yaml.Unmarshal(by, &n) 84 | if err != nil { 85 | return nil, err 86 | } 87 | return &n, nil 88 | } 89 | -------------------------------------------------------------------------------- /tool/hostedshell/installer_test.go: -------------------------------------------------------------------------------- 1 | package hostedshell 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestInstaller_InstallTo(t *testing.T) { 17 | if runtime.GOOS == "windows" { 18 | t.Skip("script based installer is not supported on windows") 19 | } 20 | 21 | type fields struct { 22 | config InstallerParameters 23 | scriptRunner func(scriptPath string, argStr string) error 24 | } 25 | type args struct { 26 | version string 27 | destDir string 28 | } 29 | tests := []struct { 30 | name string 31 | fields fields 32 | args args 33 | wantErr assert.ErrorAssertionFunc 34 | }{ 35 | { 36 | name: "happy path", 37 | fields: fields{ 38 | config: InstallerParameters{ 39 | URL: func() string { 40 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 | require.Equal(t, "GET", r.Method) 42 | _, err := w.Write([]byte("set -eu; echo 'hello world'; touch $1/syft")) 43 | require.NoError(t, err) 44 | 45 | return 46 | })) 47 | t.Cleanup(s.Close) 48 | return s.URL 49 | }(), 50 | Args: "{{ .Destination }} {{ .Version }} ", 51 | }, 52 | scriptRunner: func(scriptPath string, argStr string) error { 53 | contents, err := os.ReadFile(scriptPath) 54 | require.NoError(t, err) 55 | require.Equal(t, "set -eu; echo 'hello world'; touch $1/syft", string(contents)) 56 | require.NotEmpty(t, argStr) 57 | require.Contains(t, argStr, "1.2.3") 58 | require.NoError(t, runScript(scriptPath, argStr)) 59 | return nil 60 | }, 61 | }, 62 | args: args{ 63 | version: "1.2.3", 64 | destDir: t.TempDir(), 65 | }, 66 | wantErr: assert.NoError, 67 | }, 68 | } 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | i := NewInstaller(tt.fields.config) 72 | i.scriptRunner = tt.fields.scriptRunner 73 | want := filepath.Join(tt.args.destDir, "syft") 74 | got, err := i.InstallTo(tt.args.version, tt.args.destDir) 75 | if !tt.wantErr(t, err, fmt.Sprintf("InstallTo(%v, %v)", tt.args.version, tt.args.destDir)) { 76 | return 77 | } 78 | assert.Equalf(t, want, got, "InstallTo(%v, %v)", tt.args.version, tt.args.destDir) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/run.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/scylladb/go-set/strset" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/anchore/binny" 11 | "github.com/anchore/binny/cmd/binny/cli/option" 12 | "github.com/anchore/clio" 13 | ) 14 | 15 | type RunConfig struct { 16 | Config string `json:"config" yaml:"config" mapstructure:"config"` 17 | option.Core `json:"" yaml:",inline" mapstructure:",squash"` 18 | } 19 | 20 | func Run(app clio.Application) *cobra.Command { 21 | cfg := &RunConfig{ 22 | Core: option.DefaultCore(), 23 | } 24 | 25 | var isHelpFlag bool 26 | 27 | return app.SetupCommand(&cobra.Command{ 28 | Use: "run NAME [flags] [args]", 29 | Short: "run a specific tool", 30 | DisableFlagParsing: true, // pass these as arguments to the tool 31 | Args: cobra.ArbitraryArgs, 32 | PreRunE: func(_ *cobra.Command, args []string) error { 33 | if len(args) == 0 { 34 | return fmt.Errorf("no tool name provided") 35 | } 36 | 37 | name := args[0] 38 | 39 | if name == "--help" || name == "-h" { 40 | isHelpFlag = true 41 | } 42 | 43 | // note: this implies that the application configuration needs to be up to date with the tool names 44 | // installed. 45 | if !isHelpFlag && !strset.New(cfg.Tools.Names()...).Has(name) { 46 | return fmt.Errorf("no tool configured with name: %s", name) 47 | } 48 | 49 | return nil 50 | }, 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | var toolArgs []string 53 | if len(args) > 1 { 54 | toolArgs = args[1:] 55 | } 56 | 57 | if isHelpFlag { 58 | return cmd.Help() 59 | } 60 | 61 | return runRunRUN(*cfg, args[0], toolArgs) 62 | }, 63 | }, cfg) 64 | } 65 | 66 | func runRunRUN(cfg RunConfig, name string, args []string) error { 67 | store, err := binny.NewStore(cfg.Root) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | entries := store.GetByName(name) 73 | switch len(entries) { 74 | case 0: 75 | return fmt.Errorf("no tool installed with name: %s", name) 76 | case 1: 77 | // pass 78 | default: 79 | return fmt.Errorf("multiple tools installed with name: %s", name) 80 | } 81 | 82 | entry := entries[0] 83 | 84 | fullPath, err := filepath.Abs(entry.Path()) 85 | if err != nil { 86 | return fmt.Errorf("unable to resolve path to tool: %w", err) 87 | } 88 | 89 | return run(fullPath, args) 90 | } 91 | -------------------------------------------------------------------------------- /cmd/binny/cli/ui/handle_task_started_test.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/gkampitakis/go-snaps/snaps" 8 | "github.com/stretchr/testify/require" 9 | "github.com/wagoodman/go-partybus" 10 | "github.com/wagoodman/go-progress" 11 | 12 | "github.com/anchore/binny/event" 13 | "github.com/anchore/bubbly/bubbles/taskprogress" 14 | ) 15 | 16 | func TestHandler_taskStarted(t *testing.T) { 17 | 18 | tests := []struct { 19 | name string 20 | eventFn func(*testing.T) partybus.Event 21 | iterations int 22 | }{ 23 | { 24 | name: "task in progress", 25 | eventFn: func(t *testing.T) partybus.Event { 26 | prog := &event.ManualStagedProgress{ 27 | AtomicStage: progress.NewAtomicStage("current"), 28 | Manual: progress.NewManual(100), 29 | } 30 | prog.Manual.Set(50) 31 | 32 | return partybus.Event{ 33 | Type: event.TaskStartedEvent, 34 | Source: event.Task{ 35 | Title: event.Title{ 36 | Default: "do something", 37 | WhileRunning: "doing something", 38 | OnSuccess: "done something", 39 | }, 40 | Context: "ctx", 41 | }, 42 | Value: prog, 43 | } 44 | }, 45 | }, 46 | { 47 | name: "task complete", 48 | eventFn: func(t *testing.T) partybus.Event { 49 | prog := &event.ManualStagedProgress{ 50 | AtomicStage: progress.NewAtomicStage("current"), 51 | Manual: progress.NewManual(100), 52 | } 53 | prog.Manual.Set(100) 54 | prog.SetCompleted() 55 | 56 | return partybus.Event{ 57 | Type: event.TaskStartedEvent, 58 | Source: event.Task{ 59 | Title: event.Title{ 60 | Default: "do something", 61 | WhileRunning: "doing something", 62 | OnSuccess: "done something", 63 | }, 64 | Context: "ctx", 65 | }, 66 | Value: prog, 67 | } 68 | }, 69 | }, 70 | } 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | // the specific color and formatting matters for the snapshot 74 | t.Setenv("CLICOLOR_FORCE", "1") 75 | 76 | e := tt.eventFn(t) 77 | handler := New(DefaultHandlerConfig()) 78 | handler.WindowSize = tea.WindowSizeMsg{ 79 | Width: 100, 80 | Height: 80, 81 | } 82 | 83 | models := handler.Handle(e) 84 | require.Len(t, models, 1) 85 | model := models[0] 86 | 87 | tsk, ok := model.(taskprogress.Model) 88 | require.True(t, ok) 89 | 90 | got := runModel(t, tsk, tt.iterations, model.Init()) 91 | t.Log(got) 92 | snaps.MatchSnapshot(t, got) 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /cmd/binny/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/anchore/binny/cmd/binny/cli/command" 7 | "github.com/anchore/binny/cmd/binny/cli/internal/ui" 8 | handler "github.com/anchore/binny/cmd/binny/cli/ui" 9 | "github.com/anchore/binny/internal/bus" 10 | "github.com/anchore/binny/internal/log" 11 | "github.com/anchore/binny/internal/redact" 12 | "github.com/anchore/clio" 13 | "github.com/anchore/go-logger" 14 | ) 15 | 16 | // New constructs the `syft packages` command, aliases the root command to `syft packages`, 17 | // and constructs the `syft power-user` command. It is also responsible for 18 | // organizing flag usage and injecting the application config for each command. 19 | // It also constructs the syft attest command and the syft version command. 20 | // `RunE` is the earliest that the complete application configuration can be loaded. 21 | func New(id clio.Identification) clio.Application { 22 | clioCfg := clio.NewSetupConfig(id). 23 | WithGlobalConfigFlag(). // add persistent -c for reading an application config from 24 | WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config 25 | WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text 26 | WithUIConstructor( 27 | // select a UI based on the logging configuration and state of stdin (if stdin is a tty) 28 | func(cfg clio.Config) ([]clio.UI, error) { 29 | noUI := ui.None(cfg.Log.Quiet) 30 | if !cfg.Log.AllowUI(os.Stdin) || cfg.Log.Quiet { 31 | return []clio.UI{noUI}, nil 32 | } 33 | 34 | return []clio.UI{ 35 | ui.New(cfg.Log.Quiet, 36 | handler.New(handler.DefaultHandlerConfig()), 37 | ), 38 | noUI, 39 | }, nil 40 | }, 41 | ). 42 | WithLoggingConfig(clio.LoggingConfig{ 43 | // TODO: this should really be logger.DisabledLevel, but that does not appear to be working as expected 44 | Level: logger.ErrorLevel, 45 | }). 46 | WithInitializers( 47 | func(state *clio.State) error { 48 | // clio is setting up and providing the bus, redact store, and logger to the application. Once loaded, 49 | // we can hoist them into the internal packages for global use. 50 | bus.Set(state.Bus) 51 | redact.Set(state.RedactStore) 52 | log.Set(state.Logger) 53 | 54 | return nil 55 | }, 56 | ) 57 | 58 | app := clio.New(*clioCfg) 59 | 60 | root := command.Root(app) 61 | 62 | root.AddCommand( 63 | clio.VersionCommand(id), 64 | command.Add(app), 65 | command.Install(app), 66 | command.Check(app), 67 | command.Run(app), 68 | command.Update(app), 69 | command.List(app), 70 | ) 71 | 72 | return app 73 | } 74 | -------------------------------------------------------------------------------- /tool/git/version_resolver.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/go-git/go-git/v5" 8 | "github.com/go-git/go-git/v5/plumbing" 9 | 10 | "github.com/anchore/binny" 11 | "github.com/anchore/binny/internal/log" 12 | ) 13 | 14 | var _ binny.VersionResolver = (*VersionResolver)(nil) 15 | 16 | type VersionResolver struct { 17 | config VersionResolutionParameters 18 | } 19 | 20 | type VersionResolutionParameters struct { 21 | Path string `json:"path" yaml:"path" mapstructure:"path"` 22 | } 23 | 24 | func NewVersionResolver(cfg VersionResolutionParameters) *VersionResolver { 25 | return &VersionResolver{ 26 | config: cfg, 27 | } 28 | } 29 | 30 | func (v VersionResolver) UpdateVersion(want, constraint string) (string, error) { 31 | if want == "current" { 32 | // always use the same reference 33 | return want, nil 34 | } 35 | return v.ResolveVersion(want, constraint) 36 | } 37 | 38 | func (v VersionResolver) ResolveVersion(want, _ string) (string, error) { 39 | log.WithFields("path", v.config.Path, "version", want).Trace("resolving version from git") 40 | 41 | if want == "current" { 42 | commit, err := headCommit(v.config.Path) 43 | if err != nil { 44 | return "", fmt.Errorf("unable to get current commit: %w", err) 45 | } 46 | return commit, nil 47 | } 48 | 49 | ref, err := byReference(v.config.Path, want) 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | if ref != "" { 55 | // found it! 56 | return ref, nil 57 | } 58 | 59 | // assume is a branch 60 | return want, nil 61 | } 62 | 63 | func headCommit(repoPath string) (string, error) { 64 | r, err := git.PlainOpen(repoPath) 65 | if err != nil { 66 | return "", fmt.Errorf("unable to open repo: %w", err) 67 | } 68 | ref, err := r.Head() 69 | if err != nil { 70 | return "", fmt.Errorf("unable to fetch head for %q: %w", repoPath, err) 71 | } 72 | return ref.Hash().String(), nil 73 | } 74 | 75 | func byReference(repoPath, ref string) (string, error) { 76 | r, err := git.PlainOpen(repoPath) 77 | if err != nil { 78 | return "", fmt.Errorf("unable to open repo: %w", err) 79 | } 80 | 81 | // try by tag first... 82 | plumbRef, err := r.Tag(ref) 83 | if err != nil { 84 | if !errors.Is(err, git.ErrTagNotFound) { 85 | return "", fmt.Errorf("unable to fetch tag for %q: %w", ref, err) 86 | } 87 | } 88 | 89 | if plumbRef != nil { 90 | return plumbRef.Name().String(), nil 91 | } 92 | 93 | // then by hash... 94 | commit, err := r.CommitObject(plumbing.NewHash(ref)) 95 | if err != nil { 96 | if !errors.Is(err, plumbing.ErrReferenceNotFound) { 97 | return "", fmt.Errorf("unable to fetch hash for %q: %w", ref, err) 98 | } 99 | } 100 | 101 | if commit != nil { 102 | return commit.Hash.String(), nil 103 | } 104 | 105 | return "", nil 106 | } 107 | -------------------------------------------------------------------------------- /tool/hostedshell/methods_test.go: -------------------------------------------------------------------------------- 1 | package hostedshell 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/anchore/binny/tool/githubrelease" 9 | ) 10 | 11 | func TestMethods(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | methods []string 15 | want bool 16 | }{ 17 | { 18 | name: "valid", 19 | methods: []string{"hostedshell", "hosted shell", "hostedscript", "hosted script", "hosted-script", "hosted-shell"}, 20 | want: true, 21 | }, 22 | { 23 | name: "invalid", 24 | methods: []string{"made up"}, 25 | want: false, 26 | }, 27 | } 28 | for _, tt := range tests { 29 | t.Run(tt.name, func(t *testing.T) { 30 | for _, method := range tt.methods { 31 | t.Run(method, func(t *testing.T) { 32 | t.Run("IsInstallMethod", func(t *testing.T) { 33 | assert.Equal(t, tt.want, IsInstallMethod(method)) 34 | }) 35 | }) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestDefaultVersionResolverConfig(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | installParams any 45 | wantMethod string 46 | wantParams any 47 | wantErr assert.ErrorAssertionFunc 48 | }{ 49 | { 50 | name: "valid githubusercontent arguments", 51 | installParams: InstallerParameters{ 52 | URL: "https://raw.githubusercontent.com/anchore/binny/main/install.sh", 53 | Args: "-b /usr/local/bin", 54 | }, 55 | wantMethod: githubrelease.ResolveMethod, 56 | wantParams: githubrelease.VersionResolutionParameters{ 57 | Repo: "anchore/binny", 58 | }, 59 | }, 60 | { 61 | name: "valid github.com arguments", 62 | installParams: InstallerParameters{ 63 | URL: "https://github.com/anchore/binny/main/install.sh", 64 | Args: "-b /usr/local/bin", 65 | }, 66 | wantMethod: githubrelease.ResolveMethod, 67 | wantParams: githubrelease.VersionResolutionParameters{ 68 | Repo: "anchore/binny", 69 | }, 70 | }, 71 | { 72 | name: "valid but not github arguments", 73 | installParams: InstallerParameters{ 74 | URL: "https://raw.somewhere.com/anchore/binny/main/install.sh", 75 | Args: "-b /usr/local/bin", 76 | }, 77 | wantErr: assert.Error, 78 | }, 79 | { 80 | name: "invalid", 81 | installParams: map[string]string{ 82 | "repo": "github.com/anchore/binny", 83 | }, 84 | wantErr: assert.Error, 85 | }, 86 | } 87 | for _, tt := range tests { 88 | t.Run(tt.name, func(t *testing.T) { 89 | if tt.wantErr == nil { 90 | tt.wantErr = assert.NoError 91 | } 92 | method, params, err := DefaultVersionResolverConfig(tt.installParams) 93 | if !tt.wantErr(t, err) { 94 | return 95 | } 96 | assert.Equal(t, tt.wantMethod, method) 97 | assert.Equal(t, tt.wantParams, params) 98 | }) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tool/githubrelease/extract.go: -------------------------------------------------------------------------------- 1 | package githubrelease 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | securejoin "github.com/cyphar/filepath-securejoin" 11 | "github.com/mholt/archives" 12 | ) 13 | 14 | // extractToDir extracts an archive file to the destination directory 15 | func extractToDir(ctx context.Context, archivePath, destDir string) error { 16 | file, err := os.Open(archivePath) 17 | if err != nil { 18 | return fmt.Errorf("unable to open archive: %w", err) 19 | } 20 | defer file.Close() 21 | 22 | // Identify the archive format 23 | format, reader, err := archives.Identify(ctx, archivePath, file) 24 | if err != nil { 25 | return fmt.Errorf("unable to identify archive format: %w", err) 26 | } 27 | 28 | // Check if format supports extraction 29 | extractor, ok := format.(archives.Extractor) 30 | if !ok { 31 | return fmt.Errorf("format %T does not support extraction", format) 32 | } 33 | 34 | // Extract files 35 | return extractor.Extract(ctx, reader, func(_ context.Context, f archives.FileInfo) error { 36 | // SecureJoin resolves the path safely, preventing traversal outside destDir 37 | destPath, err := securejoin.SecureJoin(destDir, f.NameInArchive) 38 | if err != nil { 39 | return fmt.Errorf("invalid path in archive %q: %w", f.NameInArchive, err) 40 | } 41 | 42 | // Handle directories 43 | if f.IsDir() { 44 | return os.MkdirAll(destPath, f.Mode()) 45 | } 46 | 47 | // Handle symlinks 48 | if f.LinkTarget != "" { 49 | // Validate symlink target using securejoin to prevent directory traversal. 50 | // SecureJoin ensures the target path resolves to somewhere inside destDir. 51 | validatedTarget, err := securejoin.SecureJoin(destDir, f.LinkTarget) 52 | if err != nil { 53 | return fmt.Errorf("invalid symlink target %q: %w", f.LinkTarget, err) 54 | } 55 | 56 | // Ensure parent directory exists 57 | if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { 58 | return err 59 | } 60 | 61 | // Calculate relative path from symlink location to validated target 62 | relTarget, err := filepath.Rel(filepath.Dir(destPath), validatedTarget) 63 | if err != nil { 64 | return fmt.Errorf("unable to create relative symlink path: %w", err) 65 | } 66 | 67 | // Create symlink with the validated relative target 68 | return os.Symlink(relTarget, destPath) 69 | } 70 | 71 | // Create parent directories 72 | if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { 73 | return err 74 | } 75 | 76 | // Extract file 77 | srcFile, err := f.Open() 78 | if err != nil { 79 | return err 80 | } 81 | defer srcFile.Close() 82 | 83 | destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) 84 | if err != nil { 85 | return err 86 | } 87 | defer destFile.Close() 88 | 89 | _, err = io.Copy(destFile, srcFile) 90 | return err 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "github.com/anchore/binny/internal/redact" 5 | "github.com/anchore/go-logger" 6 | "github.com/anchore/go-logger/adapter/discard" 7 | redactLogger "github.com/anchore/go-logger/adapter/redact" 8 | ) 9 | 10 | // log is the singleton used to facilitate logging internally within 11 | var log = discard.New() 12 | 13 | func Set(l logger.Logger) { 14 | // though the application will automatically have a redaction logger, library consumers may not be doing this. 15 | // for this reason we additionally ensure there is a redaction logger configured for any logger passed. The 16 | // source of truth for redaction values is still in the internal redact package. If the passed logger is already 17 | // redacted, then this is a no-op. 18 | store := redact.Get() 19 | if store != nil { 20 | l = redactLogger.New(l, store) 21 | } 22 | log = l 23 | } 24 | 25 | func Get() logger.Logger { 26 | return log 27 | } 28 | 29 | // Errorf takes a formatted template string and template arguments for the error logging level. 30 | func Errorf(format string, args ...interface{}) { 31 | log.Errorf(format, args...) 32 | } 33 | 34 | // Error logs the given arguments at the error logging level. 35 | func Error(args ...interface{}) { 36 | log.Error(args...) 37 | } 38 | 39 | // Warnf takes a formatted template string and template arguments for the warning logging level. 40 | func Warnf(format string, args ...interface{}) { 41 | log.Warnf(format, args...) 42 | } 43 | 44 | // Warn logs the given arguments at the warning logging level. 45 | func Warn(args ...interface{}) { 46 | log.Warn(args...) 47 | } 48 | 49 | // Infof takes a formatted template string and template arguments for the info logging level. 50 | func Infof(format string, args ...interface{}) { 51 | log.Infof(format, args...) 52 | } 53 | 54 | // Info logs the given arguments at the info logging level. 55 | func Info(args ...interface{}) { 56 | log.Info(args...) 57 | } 58 | 59 | // Debugf takes a formatted template string and template arguments for the debug logging level. 60 | func Debugf(format string, args ...interface{}) { 61 | log.Debugf(format, args...) 62 | } 63 | 64 | // Debug logs the given arguments at the debug logging level. 65 | func Debug(args ...interface{}) { 66 | log.Debug(args...) 67 | } 68 | 69 | // Tracef takes a formatted template string and template arguments for the trace logging level. 70 | func Tracef(format string, args ...interface{}) { 71 | log.Tracef(format, args...) 72 | } 73 | 74 | // Trace logs the given arguments at the trace logging level. 75 | func Trace(args ...interface{}) { 76 | log.Trace(args...) 77 | } 78 | 79 | // WithFields returns a message logger with multiple key-value fields. 80 | func WithFields(fields ...interface{}) logger.MessageLogger { 81 | return log.WithFields(fields...) 82 | } 83 | 84 | // Nested returns a new logger with hard coded key-value pairs 85 | func Nested(fields ...interface{}) logger.Logger { 86 | return log.Nested(fields...) 87 | } 88 | -------------------------------------------------------------------------------- /internal/download_file.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "crypto/md5" //nolint:gosec // MD5 is used for legacy compatibility 5 | "crypto/sha1" //nolint:gosec // SHA1 is used for legacy compatibility 6 | "crypto/sha256" 7 | "crypto/sha512" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "strings" 13 | 14 | "github.com/go-git/go-git/v5/plumbing/hash" 15 | "github.com/hashicorp/go-retryablehttp" 16 | 17 | "github.com/anchore/go-logger" 18 | ) 19 | 20 | func DownloadFile(lgr logger.Logger, url string, filepath string, checksum string) (err error) { 21 | reader, err := DownloadURL(lgr, url) 22 | if err != nil { 23 | return err 24 | } 25 | defer reader.Close() 26 | 27 | out, err := os.Create(filepath) 28 | if err != nil { 29 | return err 30 | } 31 | defer out.Close() 32 | 33 | // hash the file and compare with checksum while copying to disk 34 | h := getHasher(checksum) 35 | tee := io.TeeReader(reader, h) 36 | 37 | if _, err := io.Copy(out, tee); err != nil { 38 | return err 39 | } 40 | 41 | if checksum != "" { 42 | expectedChecksum := cleanChecksum(checksum) 43 | actualChecksum := fmt.Sprintf("%x", h.Sum(nil)) 44 | 45 | if expectedChecksum != actualChecksum { 46 | lgr.WithFields("url", url, "expected", expectedChecksum, "actual", actualChecksum).Warn("checksum mismatch") 47 | return fmt.Errorf("checksum mismatch for %q", filepath) 48 | } 49 | 50 | lgr.WithFields("checksum", expectedChecksum, "asset", filepath, "url", url).Trace("checksum verified") 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func DownloadURL(lgr logger.Logger, url string) (io.ReadCloser, error) { 57 | resp, err := retryablehttp.Get(url) 58 | if err != nil { 59 | return nil, fmt.Errorf("unable to download %q: %w", url, err) 60 | } 61 | 62 | lgr.WithFields("http-status", resp.StatusCode).Tracef("http get %q", url) 63 | 64 | if resp.StatusCode != http.StatusOK { 65 | if resp.Body != nil { 66 | resp.Body.Close() 67 | } 68 | return nil, fmt.Errorf("unexpected status code %d for %q", resp.StatusCode, url) 69 | } 70 | return resp.Body, nil 71 | } 72 | 73 | func cleanChecksum(checksum string) string { 74 | parts := strings.SplitN(checksum, ":", 2) 75 | if len(parts) < 2 { 76 | return checksum 77 | } 78 | 79 | return parts[1] 80 | } 81 | 82 | func getHasher(checksum string) hash.Hash { 83 | // Default to SHA-256 if no prefix or unsupported prefix 84 | defaultHash := sha256.New() 85 | 86 | parts := strings.SplitN(checksum, ":", 2) 87 | if len(parts) < 2 { 88 | return defaultHash 89 | } 90 | 91 | algorithm := strings.ToLower(parts[0]) 92 | 93 | switch algorithm { 94 | case "sha256": 95 | return sha256.New() 96 | case "sha1": 97 | return sha1.New() //nolint:gosec // SHA1 is used for legacy compatibility 98 | case "sha512": 99 | return sha512.New() 100 | case "md5": 101 | return md5.New() //nolint:gosec // MD5 is used for legacy compatibility 102 | default: 103 | return defaultHash 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /cmd/binny/cli/internal/ui/post_ui_event_writer.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/hashicorp/go-multierror" 10 | "github.com/wagoodman/go-partybus" 11 | 12 | "github.com/anchore/binny/event" 13 | "github.com/anchore/binny/internal/log" 14 | ) 15 | 16 | type postUIEventWriter struct { 17 | handles []postUIHandle 18 | } 19 | 20 | type postUIHandle struct { 21 | respectQuiet bool 22 | event partybus.EventType 23 | writer io.Writer 24 | dispatch eventWriter 25 | } 26 | 27 | type eventWriter func(io.Writer, ...partybus.Event) error 28 | 29 | func newPostUIEventWriter(stdout, stderr io.Writer) *postUIEventWriter { 30 | return &postUIEventWriter{ 31 | handles: []postUIHandle{ 32 | { 33 | event: event.CLIReport, 34 | respectQuiet: false, 35 | writer: stdout, 36 | dispatch: writeReports, 37 | }, 38 | { 39 | event: event.CLINotification, 40 | respectQuiet: true, 41 | writer: stderr, 42 | dispatch: writeNotifications, 43 | }, 44 | }, 45 | } 46 | } 47 | 48 | func (w postUIEventWriter) write(quiet bool, events ...partybus.Event) error { 49 | var errs error 50 | for _, h := range w.handles { 51 | if quiet && h.respectQuiet { 52 | continue 53 | } 54 | 55 | for _, e := range events { 56 | if e.Type != h.event { 57 | continue 58 | } 59 | 60 | if err := h.dispatch(h.writer, e); err != nil { 61 | errs = multierror.Append(errs, err) 62 | } 63 | } 64 | } 65 | return errs 66 | } 67 | 68 | func writeReports(writer io.Writer, events ...partybus.Event) error { 69 | var reports []string 70 | for _, e := range events { 71 | _, report, err := event.ParseCLIReport(e) 72 | if err != nil { 73 | log.WithFields("error", err).Warn("failed to gather final report") 74 | continue 75 | } 76 | 77 | // remove all whitespace padding from the end of the report 78 | reports = append(reports, strings.TrimRight(report, "\n ")+"\n") 79 | } 80 | 81 | // prevent the double new-line at the end of the report 82 | report := strings.Join(reports, "\n") 83 | 84 | if _, err := fmt.Fprint(writer, report); err != nil { 85 | return fmt.Errorf("failed to write final report to stdout: %w", err) 86 | } 87 | return nil 88 | } 89 | 90 | func writeNotifications(writer io.Writer, events ...partybus.Event) error { 91 | // 13 = high intensity magenta (ANSI 16 bit code) 92 | style := lipgloss.NewStyle().Foreground(lipgloss.Color("13")) 93 | 94 | for _, e := range events { 95 | _, notification, err := event.ParseCLINotification(e) 96 | if err != nil { 97 | log.WithFields("error", err).Warn("failed to parse notification") 98 | continue 99 | } 100 | 101 | if _, err := fmt.Fprintln(writer, style.Render(notification)); err != nil { 102 | // don't let this be fatal 103 | log.WithFields("error", err).Warn("failed to write final notifications") 104 | } 105 | } 106 | return nil 107 | } 108 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/add_github_release.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/scylladb/go-set/strset" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/anchore/binny/cmd/binny/cli/option" 11 | "github.com/anchore/binny/internal/bus" 12 | "github.com/anchore/binny/internal/log" 13 | "github.com/anchore/binny/tool/githubrelease" 14 | "github.com/anchore/clio" 15 | ) 16 | 17 | type AddGithubReleaseConfig struct { 18 | Config string `json:"config" yaml:"config" mapstructure:"config"` 19 | option.Core `json:"" yaml:",inline" mapstructure:",squash"` 20 | 21 | VersionResolution option.VersionResolution `json:"version-resolver" yaml:"version-resolver" mapstructure:"version-resolver"` 22 | } 23 | 24 | func AddGithubRelease(app clio.Application) *cobra.Command { 25 | cfg := &AddGithubReleaseConfig{ 26 | Core: option.DefaultCore(), 27 | } 28 | 29 | return app.SetupCommand(&cobra.Command{ 30 | Use: "github-release OWNER/REPO@VERSION", 31 | Short: "Add a new tool configuration that sources binaries from GitHub releases", 32 | Args: cobra.ExactArgs(1), 33 | PreRunE: func(_ *cobra.Command, args []string) error { 34 | if !strings.Contains(args[0], "/") { 35 | return fmt.Errorf("invalid 'owner/project@version' format: %q", args[0]) 36 | } 37 | return nil 38 | }, 39 | RunE: func(_ *cobra.Command, args []string) error { 40 | return runGithubReleaseConfig(*cfg, args[0]) 41 | }, 42 | }, cfg) 43 | } 44 | 45 | func runGithubReleaseConfig(cmdCfg AddGithubReleaseConfig, repoVersion string) error { 46 | fields := strings.Split(repoVersion, "@") 47 | var repo, name, version string 48 | 49 | switch len(fields) { 50 | case 1: 51 | repo = repoVersion 52 | version = "latest" 53 | case 2: 54 | repo = fields[0] 55 | version = fields[1] 56 | default: 57 | return fmt.Errorf("invalid owner/project@version format: %s", repoVersion) 58 | } 59 | 60 | fields = strings.Split(repo, "/") 61 | if len(fields) != 2 { 62 | return fmt.Errorf("invalid owner/project format: %s", repo) 63 | } 64 | 65 | name = fields[1] 66 | 67 | if strset.New(cmdCfg.Tools.Names()...).Has(name) { 68 | message := fmt.Sprintf("tool %q already configured", name) 69 | bus.Report(message) 70 | log.Warn(message) 71 | return nil 72 | } 73 | 74 | vCfg := cmdCfg.VersionResolution 75 | 76 | coreInstallParams := githubrelease.InstallerParameters{ 77 | Repo: repo, 78 | } 79 | 80 | installParamMap, err := toMap(coreInstallParams) 81 | if err != nil { 82 | return fmt.Errorf("unable to encode install params: %w", err) 83 | } 84 | 85 | installMethod := githubrelease.InstallMethod 86 | 87 | log.WithFields("name", name, "version", version, "method", installMethod).Info("adding tool") 88 | 89 | toolCfg := option.Tool{ 90 | Name: name, 91 | Version: option.ToolVersionConfig{ 92 | Want: version, 93 | Constraint: vCfg.Constraint, 94 | ResolveMethod: vCfg.Method, 95 | }, 96 | InstallMethod: installMethod, 97 | Parameters: installParamMap, 98 | } 99 | 100 | return updateConfiguration(cmdCfg.Config, toolCfg) 101 | } 102 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/utils.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/mitchellh/mapstructure" 9 | "gopkg.in/yaml.v3" 10 | 11 | "github.com/anchore/binny/cmd/binny/cli/internal/yamlpatch" 12 | "github.com/anchore/binny/cmd/binny/cli/option" 13 | "github.com/anchore/binny/internal/bus" 14 | "github.com/anchore/binny/internal/log" 15 | ) 16 | 17 | func toMap(s any) (map[string]any, error) { 18 | var m map[string]any 19 | err := mapstructure.Decode(s, &m) 20 | if err != nil { 21 | return nil, fmt.Errorf("unable to create map from struct: %w", err) 22 | } 23 | 24 | for k, v := range m { 25 | switch vv := v.(type) { 26 | case string: 27 | if vv == "" { 28 | delete(m, k) 29 | } 30 | case []string: 31 | if len(vv) == 0 { 32 | delete(m, k) 33 | } 34 | default: 35 | if vv == nil { 36 | delete(m, k) 37 | } 38 | } 39 | } 40 | 41 | return m, nil 42 | } 43 | 44 | var _ yamlpatch.Patcher = (*yamlToolAppender)(nil) 45 | 46 | type yamlToolAppender struct { 47 | toolCfg option.Tool 48 | } 49 | 50 | func (p yamlToolAppender) PatchYaml(node *yaml.Node) error { 51 | patchNode, err := yamlpatch.GetYamlNode(p.toolCfg) 52 | if err != nil { 53 | return fmt.Errorf("unable to create new tool yaml config: %w", err) 54 | } 55 | 56 | toolsNode := yamlpatch.FindToolsSequenceNode(node) 57 | 58 | if toolsNode == nil { 59 | return fmt.Errorf("unable to find tools sequence node") 60 | } 61 | 62 | toolsNode.Content = append(toolsNode.Content, patchNode.Content[0]) 63 | 64 | return nil 65 | } 66 | 67 | func updateConfiguration(path string, cfg option.Tool) error { 68 | if path == "" { 69 | path = ".binny.yaml" 70 | } 71 | 72 | // if does not exist, create a new file 73 | if info, err := os.Stat(path); os.IsNotExist(err) || info != nil && info.Size() == 0 { 74 | newCfg := struct { 75 | Tools []option.Tool `yaml:"tools"` 76 | }{ 77 | Tools: []option.Tool{cfg}, 78 | } 79 | by, err := yaml.Marshal(&newCfg) 80 | if err != nil { 81 | return fmt.Errorf("unable to encode new tool configuration: %w", err) 82 | } 83 | 84 | fh, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 85 | if err != nil { 86 | return fmt.Errorf("unable to create config file: %w", err) 87 | } 88 | 89 | if _, err := fh.Write(by); err != nil { 90 | return fmt.Errorf("unable to write config: %w", err) 91 | } 92 | } else if err != nil { 93 | return fmt.Errorf("unable to stat config file: %w", err) 94 | } else { 95 | // otherwise append to the existing file 96 | if err := yamlpatch.Write(path, yamlToolAppender{toolCfg: cfg}); err != nil { 97 | return fmt.Errorf("unable to write config: %w", err) 98 | } 99 | } 100 | 101 | var buff bytes.Buffer 102 | enc := yaml.NewEncoder(&buff) 103 | enc.SetIndent(2) 104 | 105 | if err := enc.Encode(&cfg); err != nil { 106 | log.WithFields("error", err).Warn("unable to encode new tool configuration") 107 | } else { 108 | bus.Report(buff.String()) 109 | } 110 | 111 | bus.Notify(fmt.Sprintf("Added tool configuration for %q", cfg.Name)) 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | issues: 2 | max-same-issues: 25 3 | uniq-by-line: false 4 | 5 | # TODO: enable this when we have coverage on docstring comments 6 | # # The list of ids of default excludes to include or disable. 7 | # include: 8 | # - EXC0002 # disable excluding of issues about comments from golint 9 | 10 | linters: 11 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 12 | disable-all: true 13 | enable: 14 | - asciicheck 15 | - bodyclose 16 | - copyloopvar 17 | - dogsled 18 | - dupl 19 | - errcheck 20 | - funlen 21 | - gocognit 22 | - goconst 23 | - gocritic 24 | - gocyclo 25 | - gofmt 26 | - goimports 27 | - goprintffuncname 28 | - gosec 29 | - gosimple 30 | - govet 31 | - ineffassign 32 | - misspell 33 | - nakedret 34 | - nolintlint 35 | - revive 36 | - staticcheck 37 | - stylecheck 38 | - typecheck 39 | - unconvert 40 | - unparam 41 | - unused 42 | - whitespace 43 | 44 | linters-settings: 45 | funlen: 46 | # Checks the number of lines in a function. 47 | # If lower than 0, disable the check. 48 | # Default: 60 49 | lines: 70 50 | # Checks the number of statements in a function. 51 | # If lower than 0, disable the check. 52 | # Default: 40 53 | statements: 50 54 | gosec: 55 | excludes: 56 | - G115 57 | run: 58 | timeout: 10m 59 | tests: false 60 | 61 | # do not enable... 62 | # - deadcode # The owner seems to have abandoned the linter. Replaced by "unused". 63 | # - depguard # We don't have a configuration for this yet 64 | # - goprintffuncname # does not catch all cases and there are exceptions 65 | # - nakedret # does not catch all cases and should not fail a build 66 | # - gochecknoglobals 67 | # - gochecknoinits # this is too aggressive 68 | # - rowserrcheck disabled per generics https://github.com/golangci/golangci-lint/issues/2649 69 | # - godot 70 | # - godox 71 | # - goerr113 72 | # - goimports # we're using gosimports now instead to account for extra whitespaces (see https://github.com/golang/go/issues/20818) 73 | # - golint # deprecated 74 | # - gomnd # this is too aggressive 75 | # - interfacer # this is a good idea, but is no longer supported and is prone to false positives 76 | # - lll # without a way to specify per-line exception cases, this is not usable 77 | # - maligned # this is an excellent linter, but tricky to optimize and we are not sensitive to memory layout optimizations 78 | # - nestif 79 | # - prealloc # following this rule isn't consistently a good idea, as it sometimes forces unnecessary allocations that result in less idiomatic code 80 | # - rowserrcheck # not in a repo with sql, so this is not useful 81 | # - scopelint # deprecated 82 | # - structcheck # The owner seems to have abandoned the linter. Replaced by "unused". 83 | # - testpackage 84 | # - varcheck # The owner seems to have abandoned the linter. Replaced by "unused". 85 | # - wsl # this doens't have an auto-fixer yet and is pretty noisy (https://github.com/bombsimon/wsl/issues/90) 86 | -------------------------------------------------------------------------------- /cmd/binny/cli/internal/yamlpatch/patcher.go: -------------------------------------------------------------------------------- 1 | package yamlpatch 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "runtime" 9 | 10 | "github.com/chainguard-dev/yam/pkg/yam/formatted" 11 | "github.com/google/yamlfmt" 12 | "github.com/google/yamlfmt/engine" 13 | "github.com/google/yamlfmt/formatters/basic" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | type Patcher interface { 18 | PatchYaml(node *yaml.Node) error 19 | } 20 | 21 | func Write(path string, patcher Patcher) error { 22 | fh, err := os.Open(path) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | contents, err := io.ReadAll(fh) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | if err := fh.Close(); err != nil { 33 | return err 34 | } 35 | 36 | var n yaml.Node 37 | err = yaml.Unmarshal(contents, &n) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | switch len(n.Content) { 43 | case 0: 44 | return fmt.Errorf("no documents found in config file") 45 | case 1: 46 | // continue 47 | default: 48 | return fmt.Errorf("multiple documents found in config file (expected 1)") 49 | } 50 | 51 | // take the first document 52 | doc := n.Content[0] 53 | 54 | if err := patcher.PatchYaml(doc); err != nil { 55 | return fmt.Errorf("unabl to patch yaml: %w", err) 56 | } 57 | 58 | out, err := yaml.Marshal(doc) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | document := n.HeadComment + "\n" + string(out) + "\n" + n.FootComment + "\n" 64 | 65 | document, err = formatYaml(document) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | fh, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | defer fh.Close() 76 | 77 | _, err = fh.WriteString(document) 78 | 79 | if err != nil { 80 | return err 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func formatYaml(contents string) (string, error) { 87 | registry := yamlfmt.NewFormatterRegistry(&basic.BasicFormatterFactory{}) 88 | 89 | factory, err := registry.GetDefaultFactory() 90 | if err != nil { 91 | return "", fmt.Errorf("unable to get default YAML formatter factory: %w", err) 92 | } 93 | 94 | formatter, err := factory.NewFormatter(nil) 95 | if err != nil { 96 | return "", fmt.Errorf("unable to create YAML formatter: %w", err) 97 | } 98 | 99 | breakStyle := yamlfmt.LineBreakStyleLF 100 | if runtime.GOOS == "windows" { 101 | breakStyle = yamlfmt.LineBreakStyleCRLF 102 | } 103 | 104 | lineSepChar, err := breakStyle.Separator() 105 | if err != nil { 106 | return "", err 107 | } 108 | 109 | eng := &engine.ConsecutiveEngine{ 110 | LineSepCharacter: lineSepChar, 111 | Formatter: formatter, 112 | Quiet: true, 113 | ContinueOnError: false, 114 | } 115 | 116 | out, err := eng.FormatContent([]byte(contents)) 117 | if err != nil { 118 | return "", fmt.Errorf("unable to format YAML: %w", err) 119 | } 120 | 121 | var node yaml.Node 122 | if err = yaml.Unmarshal(out, &node); err != nil { 123 | return "", fmt.Errorf("unable to unmarshal formatted YAML: %w", err) 124 | } 125 | 126 | var buf bytes.Buffer 127 | enc := formatted.NewEncoder(&buf) 128 | enc, err = enc.SetGapExpressions(".tools") 129 | if err != nil { 130 | return "", fmt.Errorf("unable to set gap expressions: %w", err) 131 | } 132 | 133 | err = enc.Encode(&node) 134 | if err != nil { 135 | return "", fmt.Errorf("unable to format YAML: %w", err) 136 | } 137 | 138 | return buf.String(), nil 139 | } 140 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/check.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/hashicorp/go-multierror" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/anchore/binny" 11 | "github.com/anchore/binny/cmd/binny/cli/option" 12 | "github.com/anchore/binny/event" 13 | "github.com/anchore/binny/internal/bus" 14 | "github.com/anchore/binny/internal/log" 15 | "github.com/anchore/binny/tool" 16 | "github.com/anchore/clio" 17 | ) 18 | 19 | type CheckConfig struct { 20 | Config string `json:"config" yaml:"config" mapstructure:"config"` 21 | option.Check `json:"" yaml:",inline" mapstructure:",squash"` 22 | option.Core `json:"" yaml:",inline" mapstructure:",squash"` 23 | } 24 | 25 | func Check(app clio.Application) *cobra.Command { 26 | cfg := &CheckConfig{ 27 | Core: option.DefaultCore(), 28 | } 29 | 30 | var names []string 31 | 32 | return app.SetupCommand(&cobra.Command{ 33 | Use: "check", 34 | Short: "Verify tool are installed at the configured version", 35 | Args: cobra.ArbitraryArgs, 36 | PreRunE: func(_ *cobra.Command, args []string) error { 37 | names = args 38 | return nil 39 | }, 40 | RunE: func(_ *cobra.Command, _ []string) error { 41 | return runCheck(*cfg, names) 42 | }, 43 | }, cfg) 44 | } 45 | 46 | func runCheck(cmdCfg CheckConfig, names []string) (errs error) { 47 | names, toolOpts := selectNamesAndConfigs(cmdCfg.Core, names) 48 | 49 | if len(toolOpts) == 0 { 50 | bus.Report("no tools to verify") 51 | log.Warn("no tools to verify") 52 | return nil 53 | } 54 | 55 | // get the current store state 56 | store, err := binny.NewStore(cmdCfg.Store.Root) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | monitor := bus.PublishTask( 62 | event.Title{ 63 | Default: "Verify installed tools", 64 | WhileRunning: "Verifying installed tools", 65 | OnSuccess: "Verified installed tools", 66 | }, 67 | "", 68 | len(toolOpts), 69 | ) 70 | 71 | defer func() { 72 | if errs != nil { 73 | monitor.SetError(errs) 74 | } else { 75 | monitor.AtomicStage.Set(strings.Join(names, ", ")) 76 | monitor.SetCompleted() 77 | } 78 | }() 79 | 80 | var failedTools []string 81 | for _, opt := range toolOpts { 82 | monitor.Increment() 83 | monitor.AtomicStage.Set(opt.Name) 84 | 85 | resolvedVersion, err := checkTool(store, opt, cmdCfg.VerifySHA256Digest) 86 | if err != nil { 87 | failedTools = append(failedTools, opt.Name) 88 | errs = multierror.Append(errs, fmt.Errorf("failed to check tool %q: %w", opt.Name, err)) 89 | continue 90 | } 91 | 92 | log.WithFields("tool", opt.Name, "version", resolvedVersion).Debug("installation verified") 93 | } 94 | 95 | monitor.AtomicStage.Set(strings.Join(names, ", ")) 96 | 97 | if errs != nil { 98 | log.WithFields("tools", failedTools).Warn("verification failed") 99 | return errs 100 | } 101 | 102 | log.Info("all tools verified") 103 | 104 | return nil 105 | } 106 | 107 | func checkTool(store *binny.Store, opt option.Tool, verifySha256Digest bool) (string, error) { 108 | t, intent, err := opt.ToTool() 109 | if err != nil { 110 | return "", err 111 | } 112 | 113 | resolvedVersion, err := tool.ResolveVersion(t, *intent) 114 | if err != nil { 115 | return "", err 116 | } 117 | 118 | // otherwise continue to install the tool 119 | err = tool.Check(store, t.Name(), resolvedVersion, tool.VerifyConfig{ 120 | VerifyXXH64Digest: true, 121 | VerifySHA256Digest: verifySha256Digest, 122 | }) 123 | if err != nil { 124 | return resolvedVersion, err 125 | } 126 | 127 | return resolvedVersion, nil 128 | } 129 | -------------------------------------------------------------------------------- /cmd/binny/cli/command/add_go_install.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/google/shlex" 8 | "github.com/scylladb/go-set/strset" 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/anchore/binny/cmd/binny/cli/option" 12 | "github.com/anchore/binny/internal/log" 13 | "github.com/anchore/binny/tool/goinstall" 14 | "github.com/anchore/clio" 15 | ) 16 | 17 | type AddGoInstallConfig struct { 18 | Config string `json:"config" yaml:"config" mapstructure:"config"` 19 | option.Core `json:"" yaml:",inline" mapstructure:",squash"` 20 | 21 | // CLI options 22 | Install struct { 23 | GoInstall option.GoInstall `json:"go-install" yaml:"go-install" mapstructure:"go-install"` 24 | } `json:"install" yaml:"install" mapstructure:"install"` 25 | 26 | VersionResolution option.VersionResolution `json:"version-resolver" yaml:"version-resolver" mapstructure:"version-resolver"` 27 | } 28 | 29 | func AddGoInstall(app clio.Application) *cobra.Command { 30 | cfg := &AddGoInstallConfig{ 31 | Core: option.DefaultCore(), 32 | } 33 | 34 | return app.SetupCommand(&cobra.Command{ 35 | Use: "go-install NAME@VERSION --module GOMODULE [--entrypoint PATH] [--ldflags FLAGS]", 36 | Short: "Add a new tool configuration from 'go install ...' invocations", 37 | Args: cobra.ExactArgs(1), 38 | PreRunE: func(_ *cobra.Command, _ []string) error { 39 | if cfg.Install.GoInstall.Module == "" { 40 | return fmt.Errorf("go-install configuration requires '--module' option") 41 | } 42 | return nil 43 | }, 44 | RunE: func(_ *cobra.Command, args []string) error { 45 | return runAddGoInstallConfig(*cfg, args[0]) 46 | }, 47 | }, cfg) 48 | } 49 | 50 | func runAddGoInstallConfig(cmdCfg AddGoInstallConfig, nameVersion string) error { 51 | fields := strings.Split(nameVersion, "@") 52 | var name, version string 53 | 54 | switch len(fields) { 55 | case 1: 56 | name = nameVersion 57 | case 2: 58 | name = fields[0] 59 | version = fields[1] 60 | default: 61 | return fmt.Errorf("invalid name@version format: %s", nameVersion) 62 | } 63 | 64 | if strset.New(cmdCfg.Tools.Names()...).Has(name) { 65 | // TODO: should this be an error? 66 | log.Warnf("tool %q already configured", name) 67 | return nil 68 | } 69 | 70 | iCfg := cmdCfg.Install.GoInstall 71 | vCfg := cmdCfg.VersionResolution 72 | 73 | ldFlagsList, err := shlex.Split(iCfg.LDFlags) 74 | if err != nil { 75 | return fmt.Errorf("invalid ldflags: %w", err) 76 | } 77 | 78 | if err := validateEnvSlice(iCfg.Env); err != nil { 79 | return err 80 | } 81 | 82 | coreInstallParams := goinstall.InstallerParameters{ 83 | Module: iCfg.Module, 84 | Entrypoint: iCfg.Entrypoint, 85 | LDFlags: ldFlagsList, 86 | Args: iCfg.Args, 87 | Env: iCfg.Env, 88 | } 89 | 90 | installParamMap, err := toMap(coreInstallParams) 91 | if err != nil { 92 | return fmt.Errorf("unable to encode install params: %w", err) 93 | } 94 | 95 | installMethod := goinstall.InstallMethod 96 | 97 | log.WithFields("name", name, "version", version, "method", installMethod).Info("adding tool") 98 | 99 | toolCfg := option.Tool{ 100 | Name: name, 101 | Version: option.ToolVersionConfig{ 102 | Want: version, 103 | Constraint: vCfg.Constraint, 104 | ResolveMethod: vCfg.Method, 105 | }, 106 | InstallMethod: installMethod, 107 | Parameters: installParamMap, 108 | } 109 | 110 | return updateConfiguration(cmdCfg.Config, toolCfg) 111 | } 112 | 113 | func validateEnvSlice(env []string) error { 114 | for _, e := range env { 115 | if !strings.Contains(e, "=") { 116 | return fmt.Errorf("invalid env format: %q", e) 117 | } 118 | } 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /tool/hostedshell/installer.go: -------------------------------------------------------------------------------- 1 | package hostedshell 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | "text/template" 12 | 13 | "github.com/Masterminds/sprig/v3" 14 | "github.com/google/shlex" 15 | 16 | "github.com/anchore/binny" 17 | "github.com/anchore/binny/internal" 18 | "github.com/anchore/binny/internal/log" 19 | ) 20 | 21 | var _ binny.Installer = (*Installer)(nil) 22 | 23 | type InstallerParameters struct { 24 | URL string `json:"url" yaml:"url" mapstructure:"url"` 25 | Args string `json:"args" yaml:"args" mapstructure:"args"` 26 | } 27 | 28 | type Installer struct { 29 | config InstallerParameters 30 | scriptRunner func(scriptPath string, argStr string) error 31 | } 32 | 33 | func NewInstaller(cfg InstallerParameters) Installer { 34 | return Installer{ 35 | config: cfg, 36 | scriptRunner: runScript, 37 | } 38 | } 39 | 40 | func (i Installer) InstallTo(version, destDir string) (string, error) { 41 | lgr := log.Nested("tool", fmt.Sprintf("%s@%s", i.config.URL, version)) 42 | 43 | lgr.Debug("installing from hosted shell script") 44 | 45 | const scriptName = "install.sh" 46 | 47 | scriptPath := filepath.Join(destDir, scriptName) 48 | if err := internal.DownloadFile(lgr, i.config.URL, scriptPath, ""); err != nil { 49 | return "", fmt.Errorf("failed to download script: %v", err) 50 | } 51 | 52 | argStr, err := templateFlags(i.config.Args, version, destDir) 53 | if err != nil { 54 | return "", fmt.Errorf("failed to template args: %v", err) 55 | } 56 | 57 | if err = i.scriptRunner(scriptPath, argStr); err != nil { 58 | return "", fmt.Errorf("failed to run script: %v", err) 59 | } 60 | 61 | lsResult, err := os.ReadDir(destDir) 62 | if err != nil { 63 | return "", fmt.Errorf("failed to read directory: %v", err) 64 | } 65 | 66 | var files []string 67 | for _, file := range lsResult { 68 | name := file.Name() 69 | if !strings.EqualFold(name, scriptName) { 70 | files = append(files, name) 71 | } 72 | } 73 | 74 | var binPath string 75 | switch len(files) { 76 | case 0: 77 | return "", fmt.Errorf("no files found in destination directory") 78 | case 1: 79 | binPath = filepath.Join(destDir, files[0]) 80 | default: 81 | return "", fmt.Errorf("multiple files found in destination directory: %s", strings.Join(files, ", ")) 82 | } 83 | 84 | return binPath, nil 85 | } 86 | 87 | func templateFlags(args string, version, destination string) (string, error) { 88 | tmpl, err := template.New("args").Funcs(sprig.FuncMap()).Parse(args) 89 | if err != nil { 90 | return "", err 91 | } 92 | 93 | buf := bytes.Buffer{} 94 | err = tmpl.Execute(&buf, map[string]string{ 95 | "Version": version, 96 | "Destination": destination, 97 | }) 98 | 99 | if err != nil { 100 | return "", err 101 | } 102 | 103 | result := buf.String() 104 | 105 | if !strings.Contains(result, version) { 106 | return "", fmt.Errorf("version not found in args template") 107 | } 108 | 109 | if !strings.Contains(result, destination) { 110 | return "", fmt.Errorf("destination not found in args template") 111 | } 112 | 113 | return result, nil 114 | } 115 | 116 | func runScript(scriptPath, argStr string) error { 117 | if runtime.GOOS == "windows" { 118 | return fmt.Errorf("script based installers are not supported on %s", runtime.GOOS) 119 | } 120 | 121 | userArgs, err := shlex.Split(argStr) 122 | if err != nil { 123 | return fmt.Errorf("failed to parse args: %v", err) 124 | } 125 | 126 | args := []string{scriptPath} 127 | args = append(args, userArgs...) 128 | 129 | log.Trace("running: