Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions internal/cli/common/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ func newInstallCommand(tool string, repoConf github.RepoConfDef, installType Ins
installCmd.Flags().BoolVarP(&useOnInstall, "use", "u", false, "Immediately use the version once installed (best effort)")
if err := viper.BindPFlag(fmt.Sprintf("%s.install.use", tool), installCmd.Flags().Lookup("use")); err != nil {
installCmd.PrintErr(err)
panic(err)
cobra.CheckErr(err)
}
return installCmd
}

// install downloads and installs the specified version of the tool from GitHub releases
func install(cmd *cobra.Command, vrs, tool string, repoConf github.RepoConfDef, installType InstallCmdType, skipMsg bool) error {
ghc := github.New(nil)
if strings.ToLower(vrs) == "latest" {
ghc := github.New(nil)
latestVrs, err := getLatestVersion(tool, repoConf, ghc)
if err != nil {
return fmt.Errorf("failed to get latest version: %w", err)
Expand Down Expand Up @@ -76,7 +76,6 @@ func install(cmd *cobra.Command, vrs, tool string, repoConf github.RepoConfDef,
// depending on the install type we use the appropriate install method
switch installType {
case InstallGitHubCmd:
ghc := github.New(nil)
if err := ghc.DownloadRelease(tool, vrs, vrsPath, repoConf); err != nil {
return err
}
Expand Down
33 changes: 18 additions & 15 deletions internal/cli/common/listRemote.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,6 @@ import (
"github.com/stepbeta/vrsr/internal/utils"
)

var (
includeDevel bool
limit int
forceRefresh bool
)

// newGithubListRemoteCommand creates a new 'list-remote' command for the specified tool
func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra.Command {
listRemoteCmd := &cobra.Command{
Expand All @@ -32,29 +26,38 @@ func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra
},
}
// Bind flags to Viper keys so config file / env / flags work together.
listRemoteCmd.Flags().BoolVar(&includeDevel, "devel", false, "Include pre-release versions (alpha, beta, rc)")
listRemoteCmd.Flags().Bool("devel", false, "Include pre-release versions (alpha, beta, rc)")
if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.devel", tool), listRemoteCmd.Flags().Lookup("devel")); err != nil {
listRemoteCmd.PrintErr(err)
panic(err)
cobra.CheckErr(err)
}
listRemoteCmd.Flags().IntVarP(&limit, "limit", "l", 0, "Limit number of versions displayed")
listRemoteCmd.Flags().IntP("limit", "l", 0, "Limit number of versions displayed")
if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.limit", tool), listRemoteCmd.Flags().Lookup("limit")); err != nil {
listRemoteCmd.PrintErr(err)
panic(err)
cobra.CheckErr(err)
}
listRemoteCmd.Flags().BoolVarP(&forceRefresh, "force", "f", false, "Force refresh of remote versions cache")
listRemoteCmd.Flags().BoolP("force", "f", false, "Force refresh of remote versions cache")
if err := viper.BindPFlag(fmt.Sprintf("%s.list-remote.force", tool), listRemoteCmd.Flags().Lookup("force")); err != nil {
listRemoteCmd.PrintErr(err)
panic(err)
cobra.CheckErr(err)
}
return listRemoteCmd
}

// listRemoteGithub lists all remote versions of the specified tool available as GitHub releases (sorted by semver)
func listRemoteGithub(cmd *cobra.Command, tool string, repoConf github.RepoConfDef) error {
includeDevel = viper.GetBool(tool + ".list-remote.devel")
limit = viper.GetInt(tool + ".list-remote.limit")
forceRefresh = viper.GetBool(tool + ".list-remote.force")
includeDevel, err := cmd.Flags().GetBool("devel")
if err != nil {
return fmt.Errorf("failed to read devel flag: %w", err)
}
limit, err := cmd.Flags().GetInt("limit")
if err != nil {
return fmt.Errorf("failed to read limit flag: %w", err)
}
forceRefresh, err := cmd.Flags().GetBool("force")
if err != nil {
return fmt.Errorf("failed to read force flag: %w", err)
}
ghc := github.New(nil)
releasesData, err := ghc.FetchAllReleases(tool, github.FetchOptions{
IncludeDevel: includeDevel,
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/common/use.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func newUseCommand(tool string) *cobra.Command {
useCmd.Flags().BoolVarP(&installOnUse, "install", "i", false, "Install the version if not yet present (best effort)")
if err := viper.BindPFlag(fmt.Sprintf("%s.use.install", tool), useCmd.Flags().Lookup("install")); err != nil {
useCmd.PrintErr(err)
panic(err)
cobra.CheckErr(err)
}
return useCmd
}
Expand Down Expand Up @@ -65,7 +65,10 @@ func use(cmd *cobra.Command, vrs, tool string) error {
cmd.Println("Skipping action")
return err
}
// here we should have installed the version, we assume it succeeded
// Re-verify the file was actually created by the install
if _, err := os.Stat(fileName); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("install completed but version file not found: %s", fileName)
}
}
target := filepath.Join(binPath, tool)
// Check if the symlink already exists
Expand Down
156 changes: 153 additions & 3 deletions internal/github/helper.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package github

import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -70,8 +74,7 @@ func (gh *GithubHelper) FetchAllReleases(tool string, opts FetchOptions) (utils.
}
}

totPages := 1
bar := progressbar.NewOptions(totPages,
bar := progressbar.NewOptions(-1,
progressbar.OptionSetWidth(30),
progressbar.OptionSetDescription("Downloading releases metadata..."),
progressbar.OptionClearOnFinish(),
Expand All @@ -98,7 +101,7 @@ pages:
if err != nil {
return utils.ReleasesData{}, err
}
if resp.LastPage > 1 && totPages != resp.LastPage {
if resp.LastPage > 1 {
bar.ChangeMax(resp.LastPage)
}

Expand Down Expand Up @@ -234,5 +237,152 @@ func (gh *GithubHelper) DownloadRelease(tool, version, vrsPath string, repo Repo
if err := os.Chmod(destPath, 0755); err != nil {
return fmt.Errorf("failed to set executable permission: %w", err)
}

// verify checksum if available
if err := verifyDownloadChecksum(gh, ctx, rel, asset.GetName(), destPath); err != nil {
_ = os.Remove(destPath)
return fmt.Errorf("checksum verification failed: %w", err)
}
return nil
}

// isChecksumAsset returns true if the asset name looks like a checksum file.
func isChecksumAsset(name string) bool {
ln := strings.ToLower(name)
return strings.HasSuffix(ln, ".sha256") ||
strings.HasSuffix(ln, ".sha256sum") ||
strings.HasSuffix(ln, ".sha256.sig") ||
strings.Contains(ln, "checksums") ||
ln == "sha256sums" ||
ln == "sha256sums.txt" ||
ln == "sha256sum.txt"
}

// parseChecksums parses a checksum file in standard sha256sum format.
// Each line is: "<hex-hash> <filename>" or "<hex-hash> *<filename>".
// Returns a map of filename → hash.
func parseChecksums(r io.Reader) (map[string]string, error) {
scanner := bufio.NewScanner(r)
checksums := make(map[string]string)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
var hash, name string
if len(fields) >= 2 {
hash = fields[0]
name = strings.TrimLeft(fields[1], "*")
} else if len(fields) == 1 {
hash = fields[0]
}
if len(hash) != 64 || name == "" {
continue
}
checksums[name] = hash
}
return checksums, scanner.Err()
}

// verifyDownloadChecksum searches release assets for a checksum file, downloads
// it, and verifies the downloaded binary against the matching hash.
// Returns nil if no checksum file is found (best-effort).
func verifyDownloadChecksum(gh *GithubHelper, ctx context.Context, rel *github.RepositoryRelease, assetName, destPath string) error {
// Find a checksum asset
var checksumAsset *github.ReleaseAsset
for _, a := range rel.Assets {
if a == nil {
continue
}
if isChecksumAsset(a.GetName()) || a.GetName() == assetName+".sha256" {
checksumAsset = a
break
}
}

// If the explicit .sha256 asset exists but wasn't caught above, try matching
if checksumAsset == nil {
for _, a := range rel.Assets {
if a == nil {
continue
}
if a.GetName() == assetName+".sha256" {
checksumAsset = a
break
}
}
}

if checksumAsset == nil {
fmt.Fprintf(os.Stderr, "warning: no checksum file found for %s, skipping verification\n", assetName)
return nil
}

// We don't have the repo owner/name here, derive from any known context.
// For now use the asset download URL directly.
u := checksumAsset.GetBrowserDownloadURL()
if u == "" {
fmt.Fprintf(os.Stderr, "warning: checksum file %s has no download URL\n", checksumAsset.GetName())
return nil
}
resp, err := http.Get(u)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to download checksum file %s: %v\n", checksumAsset.GetName(), err)
return nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "warning: bad status downloading checksum file %s: %s\n", checksumAsset.GetName(), resp.Status)
return nil
}

body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to read checksum file: %v\n", err)
return nil
}

// Try parsing as standard sha256sum format first
checksums, parseErr := parseChecksums(bytes.NewReader(body))
if parseErr == nil && len(checksums) > 0 {
hash, ok := checksums[assetName]
if !ok {
// Try without the archive extension
hash, ok = checksums[strings.TrimSuffix(assetName, ".tar.gz")]
}
if ok {
return verifyFileSHA256(destPath, hash)
}
}

// Fallback: treat the entire file as just the hex hash
hash := strings.TrimSpace(string(body))
hash = strings.Fields(hash)[0]
if len(hash) == 64 {
return verifyFileSHA256(destPath, hash)
}

fmt.Fprintf(os.Stderr, "warning: could not find hash for %s in checksum file %s\n", assetName, checksumAsset.GetName())
return nil
}

// verifyFileSHA256 computes the SHA256 of the file at path and compares it to expectedHex.
func verifyFileSHA256(path, expectedHex string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file for checksum verification: %w", err)
}
defer func() { _ = f.Close() }()

h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return fmt.Errorf("failed to compute SHA256: %w", err)
}
got := hex.EncodeToString(h.Sum(nil))
if !strings.EqualFold(got, expectedHex) {
return fmt.Errorf("SHA256 mismatch for %s: expected %s, got %s", path, expectedHex, got)
}
fmt.Fprintf(os.Stderr, "info: checksum verification passed for %s\n", path)
return nil
}
Loading
Loading