diff --git a/MODULE.bazel b/MODULE.bazel index 2d2fb43f..30c690c7 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -27,5 +27,7 @@ use_repo( "com_github_gofrs_flock", "com_github_hashicorp_go_version", "com_github_mitchellh_go_homedir", + "com_github_protonmail_go_crypto", + "com_github_protonmail_gopenpgp_v3", "org_golang_x_term", ) diff --git a/README.md b/README.md index e11549da..d009bad5 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,10 @@ This behavior can be disabled by setting the environment variable `BAZELISK_SKIP You can control the user agent that Bazelisk sends in all HTTP requests by setting `BAZELISK_USER_AGENT` to the desired value. +You can disable the authenticity check of downloaded Bazel binaries by setting the environment variable `BAZELISK_NO_SIGNATURE_VERIFICATION` to any value (except the empty string) before launching Bazelisk. + +You can provide an alternative PGP public key for binary authenticity verification by setting `BAZELISK_VERIFICATION_KEY_FILE` to the path of the key file. + # .bazeliskrc configuration file A `.bazeliskrc` file in the root directory of a workspace or the user home directory allows users to set environment variables persistently. (The Python implementation of Bazelisk doesn't check the user home directory yet, only the workspace directory.) @@ -265,10 +269,12 @@ The following variables can be set: - `BAZELISK_HOME_WINDOWS` - `BAZELISK_HOME` - `BAZELISK_INCOMPATIBLE_FLAGS` +- `BAZELISK_NO_SIGNATURE_VERIFICATION` - `BAZELISK_SHOW_PROGRESS` - `BAZELISK_SHUTDOWN` - `BAZELISK_SKIP_WRAPPER` - `BAZELISK_USER_AGENT` +- `BAZELISK_VERIFICATION_KEY_FILE` - `BAZELISK_VERIFY_SHA256` - `USE_BAZEL_VERSION` diff --git a/core/BUILD b/core/BUILD index d265c285..3591e94c 100644 --- a/core/BUILD +++ b/core/BUILD @@ -19,6 +19,7 @@ go_library( "//ws", "@com_github_gofrs_flock//:flock", "@com_github_mitchellh_go_homedir//:go-homedir", + "@com_github_protonmail_go_crypto//openpgp/errors", ], ) @@ -31,6 +32,7 @@ go_test( embed = [":core"], deps = [ "//config", + "//httputil/httputil_test_helper", "//platforms", ], ) diff --git a/core/core.go b/core/core.go index 2fcaad8e..f196e33c 100644 --- a/core/core.go +++ b/core/core.go @@ -34,6 +34,8 @@ import ( "github.com/bazelbuild/bazelisk/ws" "github.com/gofrs/flock" "github.com/mitchellh/go-homedir" + + pgpErrors "github.com/ProtonMail/go-crypto/openpgp/errors" ) const ( @@ -457,11 +459,13 @@ func downloadBazelIfNecessary(version string, bazeliskHome string, bazelForkOrUR } } - pathToBazelInCAS, downloadedDigest, err := downloadBazelToCAS(version, bazeliskHome, repos, config, downloader) + artifact, downloadedDigest, err := downloadBazelToCAS(version, bazeliskHome, repos, config, downloader) if err != nil { return "", fmt.Errorf("failed to download bazel: %w", err) } + pathToBazelInCAS, pathToSignatureInCAS := artifact.BinaryPath, artifact.SignaturePath + // Verifying integrity of downloaded binary (if it was requested) expectedSha256 := strings.ToLower(config.Get("BAZELISK_VERIFY_SHA256")) if len(expectedSha256) > 0 { if expectedSha256 != downloadedDigest { @@ -469,6 +473,12 @@ func downloadBazelIfNecessary(version string, bazeliskHome string, bazelForkOrUR } } + // Verifying authenticity of downloaded binary (if it was requested) + if err := verifyBinaryAuthenticity(pathToBazelInCAS, pathToSignatureInCAS, config); err != nil { + return "", err + } + + // Verification is finished successfully, write the mapping file if err := atomicWriteFile(mappingPath, []byte(downloadedDigest), 0644); err != nil { return "", fmt.Errorf("failed to write mapping file after downloading bazel: %w", err) } @@ -476,6 +486,68 @@ func downloadBazelIfNecessary(version string, bazeliskHome string, bazelForkOrUR return pathToBazelInCAS, nil } +func verifyBinaryAuthenticity(binaryPath, signaturePath string, config config.Config) error { + if config.Get("BAZELISK_NO_SIGNATURE_VERIFICATION") != "" { + log.Printf("Skipping signature verification because BAZELISK_NO_SIGNATURE_VERIFICATION is set.") + return nil + } + + binary, err := os.Open(binaryPath) + if err != nil { + return fmt.Errorf("could not open binary %s for verification: %v", binaryPath, err) + } + defer binary.Close() + + signature, err := os.Open(signaturePath) + if err != nil { + return fmt.Errorf("could not open signature %s for verification: %v", signaturePath, err) + } + defer signature.Close() + + var verificationKey string + var verificationKeySource string + + verificationKeyPath := config.Get("BAZELISK_VERIFICATION_KEY_FILE") + if verificationKeyPath != "" { + data, err := os.ReadFile(verificationKeyPath) + if err != nil { + return fmt.Errorf("failed to read verification key from %s: %v", verificationKeyPath, err) + } + verificationKey = string(data) + verificationKeySource = fmt.Sprintf("Verification key from %s", verificationKeyPath) + } else { + verificationKey = httputil.VerificationKey + verificationKeySource = "Embedded verification key" + } + + verificationResult, err := httputil.VerifyBinary(binary, signature, verificationKey) + if err != nil { + return err + } + + if err = verificationResult.SignatureError(); err != nil { + if errors.Is(err, pgpErrors.ErrKeyExpired) { + var msgStart string + if verificationKeyPath == "" { + msgStart = "Either update bazelisk to a newer version or use" + } else { + msgStart = "Use" + } + return fmt.Errorf("%s is expired!\n"+ + "%s BAZELISK_VERIFICATION_KEY_FILE to set an alternative verification key externally.\n"+ + "Up to date verification key should be available at https://bazel.build/bazel-release.pub.gpg.", + verificationKeySource, msgStart) + } + return err + } + + for identity := range verificationResult.SignedByKey().GetEntity().Identities { + log.Printf("Signed by \"%s\"", identity) + } + + return nil +} + func atomicWriteFile(path string, contents []byte, perm os.FileMode) error { parent := filepath.Dir(path) if err := os.MkdirAll(parent, 0755); err != nil { @@ -525,43 +597,48 @@ func lockedRenameIfDstAbsent(src, dst string) error { return os.Rename(src, dst) } -func downloadBazelToCAS(version string, bazeliskHome string, repos *Repositories, config config.Config, downloader DownloadFunc) (string, string, error) { +func downloadBazelToCAS(version string, bazeliskHome string, repos *Repositories, config config.Config, downloader DownloadFunc) (httputil.DownloadArtifact, string, error) { downloadsDir := filepath.Join(bazeliskHome, "downloads") temporaryDownloadDir := filepath.Join(downloadsDir, "_tmp") casDir := filepath.Join(bazeliskHome, "downloads", "sha256") tmpDestFileBytes := make([]byte, 32) if _, err := rand.Read(tmpDestFileBytes); err != nil { - return "", "", fmt.Errorf("failed to generate temporary file name: %w", err) + return httputil.DownloadArtifact{}, "", fmt.Errorf("failed to generate temporary file name: %w", err) } tmpDestFile := fmt.Sprintf("%x", tmpDestFileBytes) - var tmpDestPath string + var artifact httputil.DownloadArtifact var err error baseURL := config.Get(BaseURLEnv) formatURL := config.Get(FormatURLEnv) + if baseURL != "" && formatURL != "" { - return "", "", fmt.Errorf("cannot set %s and %s at once", BaseURLEnv, FormatURLEnv) + return httputil.DownloadArtifact{}, "", fmt.Errorf("cannot set %s and %s at once", BaseURLEnv, FormatURLEnv) } else if formatURL != "" { - tmpDestPath, err = repos.DownloadFromFormatURL(config, formatURL, version, temporaryDownloadDir, tmpDestFile) + artifact, err = repos.DownloadFromFormatURL(config, formatURL, version, temporaryDownloadDir, tmpDestFile) } else if baseURL != "" { - tmpDestPath, err = repos.DownloadFromBaseURL(baseURL, version, temporaryDownloadDir, tmpDestFile, config) + artifact, err = repos.DownloadFromBaseURL(baseURL, version, temporaryDownloadDir, tmpDestFile, config) } else { - tmpDestPath, err = downloader(temporaryDownloadDir, tmpDestFile) + artifact, err = downloader(temporaryDownloadDir, tmpDestFile) } + if err != nil { - return "", "", fmt.Errorf("failed to download bazel: %w", err) + return artifact, "", fmt.Errorf("failed to download bazel: %w", err) } + tmpDestPath := artifact.BinaryPath + tmpSignaturePath := artifact.SignaturePath + f, err := os.Open(tmpDestPath) if err != nil { - return "", "", fmt.Errorf("failed to open downloaded bazel to digest it: %w", err) + return artifact, "", fmt.Errorf("failed to open downloaded bazel to digest it: %w", err) } h := sha256.New() if _, err := io.Copy(h, f); err != nil { f.Close() - return "", "", fmt.Errorf("cannot compute sha256 of %s after download: %v", tmpDestPath, err) + return artifact, "", fmt.Errorf("cannot compute sha256 of %s after download: %v", tmpDestPath, err) } f.Close() actualSha256 := strings.ToLower(fmt.Sprintf("%x", h.Sum(nil))) @@ -570,24 +647,37 @@ func downloadBazelToCAS(version string, bazeliskHome string, repos *Repositories pathToBazelInCAS := filepath.Join(casDir, actualSha256, "bin", bazelInCASBasename) dirForBazelInCAS := filepath.Dir(pathToBazelInCAS) if err := os.MkdirAll(dirForBazelInCAS, 0755); err != nil { - return "", "", fmt.Errorf("failed to MkdirAll parent of %s: %w", pathToBazelInCAS, err) + return artifact, "", fmt.Errorf("failed to MkdirAll parent of %s: %w", pathToBazelInCAS, err) } tmpPathFile, err := os.CreateTemp(dirForBazelInCAS, bazelInCASBasename+".tmp") if err != nil { - return "", "", fmt.Errorf("failed to create temporary file in %s: %w", dirForBazelInCAS, err) + return artifact, "", fmt.Errorf("failed to create temporary file in %s: %w", dirForBazelInCAS, err) } tmpPathFile.Close() defer os.Remove(tmpPathFile.Name()) tmpPathInCorrectDirectory := tmpPathFile.Name() if err := os.Rename(tmpDestPath, tmpPathInCorrectDirectory); err != nil { - return "", "", fmt.Errorf("failed to move %s to %s: %w", tmpDestPath, tmpPathInCorrectDirectory, err) + return artifact, "", fmt.Errorf("failed to move %s to %s: %w", tmpDestPath, tmpPathInCorrectDirectory, err) } if err := lockedRenameIfDstAbsent(tmpPathInCorrectDirectory, pathToBazelInCAS); err != nil { - return "", "", fmt.Errorf("failed to move %s to %s: %w", tmpPathInCorrectDirectory, pathToBazelInCAS, err) + return artifact, "", fmt.Errorf("failed to move %s to %s: %w", tmpPathInCorrectDirectory, pathToBazelInCAS, err) + } + + var pathToSignatureInCAS string + if config.Get("BAZELISK_NO_SIGNATURE_VERIFICATION") == "" { + if tmpSignaturePath == "" { + return httputil.DownloadArtifact{}, "", fmt.Errorf("signature file for %s was requested but not received", tmpDestPath) + } + pathToSignatureInCAS = pathToBazelInCAS + ".sig" + if err := lockedRenameIfDstAbsent(tmpSignaturePath, pathToSignatureInCAS); err != nil { + return httputil.DownloadArtifact{}, "", fmt.Errorf("failed to move signature file %s to %s: %w", tmpSignaturePath, pathToSignatureInCAS, err) + } + } else { + pathToSignatureInCAS = "" } - return pathToBazelInCAS, actualSha256, nil + return httputil.DownloadArtifact{BinaryPath: pathToBazelInCAS, SignaturePath: pathToSignatureInCAS}, actualSha256, nil } func copyFile(src, dst string, perm os.FileMode) error { @@ -1395,10 +1485,11 @@ func downloadInstallerToCAS(installerURL, bazeliskHome string, config config.Con tmpInstallerFile := fmt.Sprintf("%x-installer", tmpInstallerBytes) // Download the installer - installerPath, err := httputil.DownloadBinary(installerURL, temporaryDownloadDir, tmpInstallerFile, config) + artifact, err := httputil.DownloadBinary(installerURL, installerURL+".sig", temporaryDownloadDir, tmpInstallerFile, config) if err != nil { return "", fmt.Errorf("failed to download installer: %w", err) } + installerPath := artifact.BinaryPath defer os.Remove(installerPath) // Read installer content and compute hash diff --git a/core/core_test.go b/core/core_test.go index 567be609..948564e2 100644 --- a/core/core_test.go +++ b/core/core_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/bazelbuild/bazelisk/config" + "github.com/bazelbuild/bazelisk/httputil/httputil_test_helper" "github.com/bazelbuild/bazelisk/platforms" ) @@ -888,3 +889,83 @@ func TestRunBazeliskWithStderrRedirection(t *testing.T) { t.Error("stdout content should not appear in stderr") } } + +func TestVerifyBinaryAuthenticity(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "TestVerifyBinaryAuthenticity") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + key, err := httputil_test_helper.GenerateTestKey("Bazelisk Test", "test@bazel.build") + if err != nil { + t.Fatalf("Failed to generate test key: %v", err) + } + + binaryPath := filepath.Join(tmpDir, "bazel") + content := []byte("binary content") + if err := os.WriteFile(binaryPath, content, 0644); err != nil { + t.Fatalf("Failed to write binary: %v", err) + } + + signature, err := httputil_test_helper.SignMessage(content, key) + if err != nil { + t.Fatalf("Failed to sign message: %v", err) + } + signaturePath := binaryPath + ".sig" + if err := os.WriteFile(signaturePath, []byte(signature), 0644); err != nil { + t.Fatalf("Failed to write signature: %v", err) + } + + keyPath := filepath.Join(tmpDir, "key.pub") + if err := os.WriteFile(keyPath, []byte(key), 0644); err != nil { + t.Fatalf("Failed to write key: %v", err) + } + + t.Run("ValidSignatureWithKeyFile", func(t *testing.T) { + cfg := config.Static(map[string]string{ + "BAZELISK_VERIFICATION_KEY_FILE": keyPath, + }) + err := verifyBinaryAuthenticity(binaryPath, signaturePath, cfg) + if err != nil { + t.Errorf("verifyBinaryAuthenticity failed: %v", err) + } + }) + + t.Run("NoSignatureVerification", func(t *testing.T) { + cfg := config.Static(map[string]string{ + "BAZELISK_NO_SIGNATURE_VERIFICATION": "1", + }) + // Use invalid signature path, should still pass because verification is skipped + err := verifyBinaryAuthenticity(binaryPath, "nonexistent.sig", cfg) + if err != nil { + t.Errorf("verifyBinaryAuthenticity should have skipped verification: %v", err) + } + }) + + t.Run("InvalidSignature", func(t *testing.T) { + cfg := config.Static(map[string]string{ + "BAZELISK_VERIFICATION_KEY_FILE": keyPath, + }) + wrongContent := []byte("wrong content") + wrongBinaryPath := filepath.Join(tmpDir, "bazel_wrong") + if err := os.WriteFile(wrongBinaryPath, wrongContent, 0644); err != nil { + t.Fatalf("Failed to write wrong binary: %v", err) + } + + err := verifyBinaryAuthenticity(wrongBinaryPath, signaturePath, cfg) + if err == nil { + t.Error("Expected error for invalid signature, but got none") + } + }) + + t.Run("MissingKeyFile", func(t *testing.T) { + cfg := config.Static(map[string]string{ + "BAZELISK_VERIFICATION_KEY_FILE": "nonexistent.key", + }) + err := verifyBinaryAuthenticity(binaryPath, signaturePath, cfg) + if err == nil { + t.Error("Expected error for missing key file, but got none") + } + }) +} diff --git a/core/repositories.go b/core/repositories.go index 33f8f4d1..43442e6e 100644 --- a/core/repositories.go +++ b/core/repositories.go @@ -20,7 +20,7 @@ const ( ) // DownloadFunc downloads a specific Bazel binary to the given location and returns the absolute path. -type DownloadFunc func(destDir, destFile string) (string, error) +type DownloadFunc func(destDir, destFile string) (httputil.DownloadArtifact, error) // LTSFilter filters Bazel versions based on specific criteria. type LTSFilter func(string) bool @@ -39,7 +39,7 @@ type LTSRepo interface { GetLTSVersions(bazeliskHome string, opts *FilterOpts) ([]string, error) // DownloadLTS downloads the given Bazel version into the specified location and returns the absolute path. - DownloadLTS(version, destDir, destFile string, config config.Config) (string, error) + DownloadLTS(version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) } // ForkRepo represents a repository that stores a fork of Bazel (releases). @@ -48,7 +48,7 @@ type ForkRepo interface { GetVersions(bazeliskHome, fork string) ([]string, error) // DownloadVersion downloads the given Bazel binary from the specified fork into the given location and returns the absolute path. - DownloadVersion(fork, version, destDir, destFile string, config config.Config) (string, error) + DownloadVersion(fork, version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) } // CommitRepo represents a repository that stores Bazel binaries built at specific commits. @@ -58,7 +58,7 @@ type CommitRepo interface { GetLastGreenCommit(bazeliskHome string) (string, error) // DownloadAtCommit downloads a Bazel binary built at the given commit into the specified location and returns the absolute path. - DownloadAtCommit(commit, destDir, destFile string, config config.Config) (string, error) + DownloadAtCommit(commit, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) } // RollingRepo represents a repository that stores rolling Bazel releases. @@ -67,15 +67,15 @@ type RollingRepo interface { GetRollingVersions(bazeliskHome string) ([]string, error) // DownloadRolling downloads the given Bazel version into the specified location and returns the absolute path. - DownloadRolling(version, destDir, destFile string, config config.Config) (string, error) + DownloadRolling(version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) } // Repositories offers access to different types of Bazel repositories, mainly for finding and downloading the correct version of Bazel. type Repositories struct { - LTS LTSRepo - Fork ForkRepo - Commits CommitRepo - Rolling RollingRepo + LTS LTSRepo + Fork ForkRepo + Commits CommitRepo + Rolling RollingRepo supportsBaseOrFormatURL bool } @@ -110,7 +110,7 @@ func (r *Repositories) resolveFork(bazeliskHome string, vi *versions.Info, confi if err != nil { return "", nil, err } - downloader := func(destDir, destFile string) (string, error) { + downloader := func(destDir, destFile string) (httputil.DownloadArtifact, error) { return r.Fork.DownloadVersion(vi.Fork, version, destDir, destFile, config) } return version, downloader, nil @@ -149,7 +149,7 @@ func (r *Repositories) resolveLTS(bazeliskHome string, vi *versions.Info, config if err != nil { return "", nil, err } - downloader := func(destDir, destFile string) (string, error) { + downloader := func(destDir, destFile string) (httputil.DownloadArtifact, error) { return r.LTS.DownloadLTS(version, destDir, destFile, config) } return version, downloader, nil @@ -164,7 +164,7 @@ func (r *Repositories) resolveCommit(bazeliskHome string, vi *versions.Info, con return "", nil, fmt.Errorf("cannot resolve last green commit: %v", err) } } - downloader := func(destDir, destFile string) (string, error) { + downloader := func(destDir, destFile string) (httputil.DownloadArtifact, error) { return r.Commits.DownloadAtCommit(version, destDir, destFile, config) } return version, downloader, nil @@ -178,7 +178,7 @@ func (r *Repositories) resolveRolling(bazeliskHome string, vi *versions.Info, co if err != nil { return "", nil, err } - downloader := func(destDir, destFile string) (string, error) { + downloader := func(destDir, destFile string) (httputil.DownloadArtifact, error) { return r.Rolling.DownloadRolling(version, destDir, destFile, config) } return version, downloader, nil @@ -205,21 +205,21 @@ func resolvePotentiallyRelativeVersion(bazeliskHome string, lister listVersionsF } // DownloadFromBaseURL can download Bazel binaries from a specific URL while ignoring the predefined repositories. -func (r *Repositories) DownloadFromBaseURL(baseURL, version, destDir, destFile string, config config.Config) (string, error) { +func (r *Repositories) DownloadFromBaseURL(baseURL, version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { if !r.supportsBaseOrFormatURL { - return "", fmt.Errorf("downloads from %s are forbidden", BaseURLEnv) + return httputil.DownloadArtifact{}, fmt.Errorf("downloads from %s are forbidden", BaseURLEnv) } if baseURL == "" { - return "", fmt.Errorf("%s is not set", BaseURLEnv) + return httputil.DownloadArtifact{}, fmt.Errorf("%s is not set", BaseURLEnv) } srcFile, err := platforms.DetermineBazelFilename(version, true, config) if err != nil { - return "", err + return httputil.DownloadArtifact{}, err } url := fmt.Sprintf("%s/%s/%s", baseURL, version, srcFile) - return httputil.DownloadBinary(url, destDir, destFile, config) + return httputil.DownloadBinary(url, url+".sig", destDir, destFile, config) } // BuildURLFromFormat returns a Bazel download URL based on formatURL. @@ -269,20 +269,20 @@ func BuildURLFromFormat(config config.Config, formatURL, version string) (string } // DownloadFromFormatURL can download Bazel binaries from a specific URL while ignoring the predefined repositories. -func (r *Repositories) DownloadFromFormatURL(config config.Config, formatURL, version, destDir, destFile string) (string, error) { +func (r *Repositories) DownloadFromFormatURL(config config.Config, formatURL, version, destDir, destFile string) (httputil.DownloadArtifact, error) { if !r.supportsBaseOrFormatURL { - return "", fmt.Errorf("downloads from %s are forbidden", FormatURLEnv) + return httputil.DownloadArtifact{}, fmt.Errorf("downloads from %s are forbidden", FormatURLEnv) } if formatURL == "" { - return "", fmt.Errorf("%s is not set", FormatURLEnv) + return httputil.DownloadArtifact{}, fmt.Errorf("%s is not set", FormatURLEnv) } url, err := BuildURLFromFormat(config, formatURL, version) if err != nil { - return "", err + return httputil.DownloadArtifact{}, err } - return httputil.DownloadBinary(url, destDir, destFile, config) + return httputil.DownloadBinary(url, url+".sig", destDir, destFile, config) } // CreateRepositories creates a new Repositories instance with the given repositories. Any nil repository will be replaced by a dummy repository that raises an error whenever a download is attempted. @@ -327,8 +327,8 @@ func (nolts *noLTSRepo) GetLTSVersions(bazeliskHome string, opts *FilterOpts) ([ return nil, nolts.err } -func (nolts *noLTSRepo) DownloadLTS(version, destDir, destFile string, config config.Config) (string, error) { - return "", nolts.err +func (nolts *noLTSRepo) DownloadLTS(version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { + return httputil.DownloadArtifact{}, nolts.err } type noForkRepo struct { @@ -339,8 +339,8 @@ func (nfr *noForkRepo) GetVersions(bazeliskHome, fork string) ([]string, error) return nil, nfr.err } -func (nfr *noForkRepo) DownloadVersion(fork, version, destDir, destFile string, config config.Config) (string, error) { - return "", nfr.err +func (nfr *noForkRepo) DownloadVersion(fork, version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { + return httputil.DownloadArtifact{}, nfr.err } type noCommitRepo struct { @@ -351,8 +351,8 @@ func (nlgr *noCommitRepo) GetLastGreenCommit(bazeliskHome string) (string, error return "", nlgr.err } -func (nlgr *noCommitRepo) DownloadAtCommit(commit, destDir, destFile string, config config.Config) (string, error) { - return "", nlgr.err +func (nlgr *noCommitRepo) DownloadAtCommit(commit, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { + return httputil.DownloadArtifact{}, nlgr.err } type noRollingRepo struct { @@ -363,6 +363,6 @@ func (nrr *noRollingRepo) GetRollingVersions(bazeliskHome string) ([]string, err return nil, nrr.err } -func (nrr *noRollingRepo) DownloadRolling(version, destDir, destFile string, config config.Config) (string, error) { - return "", nrr.err +func (nrr *noRollingRepo) DownloadRolling(version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { + return httputil.DownloadArtifact{}, nrr.err } diff --git a/go.mod b/go.mod index fae3b6c6..b74dca03 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.24.0 toolchain go1.24.2 require ( + github.com/ProtonMail/go-crypto v1.3.0 + github.com/ProtonMail/gopenpgp/v3 v3.3.0 github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d github.com/gofrs/flock v0.13.0 github.com/hashicorp/go-version v1.7.0 @@ -12,4 +14,8 @@ require ( golang.org/x/term v0.39.0 ) -require golang.org/x/sys v0.40.0 // indirect +require ( + github.com/cloudflare/circl v1.6.1 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/go.sum b/go.sum index c21c80a3..61662a0f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/gopenpgp/v3 v3.3.0 h1:N6rHCH5PWwB6zSRMgRj1EbAMQHUAAHxH3Oo4KibsPwY= +github.com/ProtonMail/gopenpgp/v3 v3.3.0/go.mod h1:J+iNPt0/5EO9wRt7Eit9dRUlzyu3hiGX3zId6iuaKOk= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -12,6 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= diff --git a/httputil/BUILD b/httputil/BUILD index c222fc51..a3a11f26 100644 --- a/httputil/BUILD +++ b/httputil/BUILD @@ -10,6 +10,9 @@ go_library( "fake.go", "httputil.go", ], + embedsrcs = [ + "bazel_key.pub.gpg", + ], importpath = "github.com/bazelbuild/bazelisk/httputil", visibility = ["//visibility:public"], deps = [ @@ -17,11 +20,17 @@ go_library( "//httputil/progress", "@com_github_bgentry_go_netrc//netrc", "@com_github_mitchellh_go_homedir//:go-homedir", + "@com_github_protonmail_gopenpgp_v3//crypto", ], ) go_test( name = "httputil_test", - srcs = ["httputil_test.go"], + srcs = [ + "httputil_test.go", + ], embed = [":httputil"], + deps = [ + "//httputil/httputil_test_helper", + ], ) diff --git a/httputil/bazel_key.pub.gpg b/httputil/bazel_key.pub.gpg new file mode 100644 index 00000000..a89049b8 --- /dev/null +++ b/httputil/bazel_key.pub.gpg @@ -0,0 +1,76 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFdEmzkBEACzj8tMYUau9oFZWNDytcQWazEO6LrTTtdQ98d3JcnVyrpT16yg +I/QfGXA8LuDdKYpUDNjehLtBL3IZp4xe375Jh8v2IA2iQ5RXGN+lgKJ6rNwm15Kr +qYeCZlU9uQVpZuhKLXsWK6PleyQHjslNUN/HtykIlmMz4Nnl3orT7lMI5rsGCmk0 +1Kth0DFh8SD9Vn2G4huddwxM8/tYj1QmWPCTgybATNuZ0L60INH8v6+J2jJzViVc +NRnR7mpouGmRy/rcr6eY9QieOwDou116TrVRFfcBRhocCI5b6uCRuhaqZ6Qs28Bx +4t5JVksXJ7fJoTy2B2s/rPx/8j4MDVEdU8b686ZDHbKYjaYBYEfBqePXScp8ndul +XWwS2lcedPihOUl6oQQYy59inWIpxi0agm0MXJAF1Bc3ToSQdHw/p0Y21kYxE2pg +EaUeElVccec5poAaHSPprUeej9bD9oIC4sMCsLs7eCQx2iP+cR7CItz6GQtuZrvS +PnKju1SKl5iwzfDQGpi6u6UAMFmc53EaH05naYDAigCueZ+/2rIaY358bECK6/VR +kyrBqpeq6VkWUeOkt03VqoPzrw4gEzRvfRtLj+D2j/pZCH3vyMYHzbaaXBv6AT0e +RmgtGo9I9BYqKSWlGEF0D+CQ3uZfOyovvrbYqNaHynFBtrx/ZkM82gMA5QARAQAB +tEdCYXplbCBEZXZlbG9wZXIgKEJhemVsIEFQVCByZXBvc2l0b3J5IGtleSkgPGJh +emVsLWRldkBnb29nbGVncm91cHMuY29tPokCPgQTAQIAKAIbAwYLCQgHAwIGFQgC +CQoLBBYCAwECHgECF4AFAlsGueoFCQeEhaQACgkQPVkZtEhFfuCojRAAqtUaEbK8 +zVAPssZDRPun0k1XB3hXxEoe5kt00cl51F+KLXN2OM5gOn2PcUw4A+Ci+48cgt9b +hTWwWuC9OPn9OCvYVyuTJXT189Pmg+F9l3zD/vrD5gdFKDLJCUPo/tRBTDQqrRGA +JssWIzvGR65O2AosoIcj7VAfNj34CBHm25abNpGnWmkiREZzElLFqjTR+FwAMxyA +VJnPbn+K1zyi9xUZKcL1QzKcHBTPFAdZR6zTII/+03n4wAL/w8+x/A1ocmE7jxCI +cgq7vaHSpGmigU2+TXckUslIgIC64iqYBpPvFAPNlqXmo9rDfL2Imyyuz1ep7j/b +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 +MZaF+SBEZXd1ZSqB5Q== +=laPs +-----END PGP PUBLIC KEY BLOCK----- diff --git a/httputil/httputil.go b/httputil/httputil.go index 0103d29b..a3e9bf6d 100644 --- a/httputil/httputil.go +++ b/httputil/httputil.go @@ -2,6 +2,7 @@ package httputil import ( + _ "embed" b64 "encoding/base64" "errors" "fmt" @@ -16,6 +17,8 @@ import ( "strconv" "time" + "github.com/ProtonMail/gopenpgp/v3/crypto" + netrc "github.com/bgentry/go-netrc/netrc" homedir "github.com/mitchellh/go-homedir" @@ -23,6 +26,9 @@ import ( "github.com/bazelbuild/bazelisk/httputil/progress" ) +//go:embed bazel_key.pub.gpg +var VerificationKey string + var ( // DefaultTransport specifies the http.RoundTripper that is used for any network traffic, and may be replaced with a dummy implementation for unit testing. DefaultTransport = http.DefaultTransport @@ -32,6 +38,7 @@ var ( // RetryClock is used for waiting between HTTP request retries. RetryClock = Clock(&realClock{}) + // MaxRetries specifies how often non-fatally failing HTTP requests should be retried. MaxRetries = 4 // MaxRequestDuration defines the maximum amount of time that a request and its retries may take in total @@ -186,75 +193,162 @@ func tryFindNetrcFileCreds(host string) (string, error) { return fmt.Sprintf("Basic %s", token), nil } +func getAuthForURL(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + // rawURL is supposed to be valid + return "", err + } + + t, err := tryFindNetrcFileCreds(u.Host) + if err != nil { + return "", nil + } + return t, nil +} + +type DownloadArtifact struct { + BinaryPath string + SignaturePath string +} + +func DownloadFile(url string, destFile *os.File, config config.Config) error { + auth, err := getAuthForURL(url) + if err != nil { + return err + } + + log.Printf("Downloading %s...", url) + resp, err := get(url, auth) + if err != nil { + return fmt.Errorf("HTTP GET %s failed: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return NotFound + } else if resp.StatusCode != 200 { + return fmt.Errorf("HTTP GET %s failed with error %v", url, resp.StatusCode) + } + + _, err = io.Copy( + // Add a progress bar during download. + progress.Writer(destFile, "Downloading", resp.ContentLength, config), + resp.Body) + progress.Finish(config) + if err != nil { + return fmt.Errorf("could not copy from %s to %s: %v", url, destFile.Name(), err) + } + + return nil +} + +func createTempFile(destDir, pattern string) (*os.File, func(), error) { + tmpFile, err := os.CreateTemp(destDir, pattern) + if err != nil { + return nil, nil, fmt.Errorf("could not create temporary file: %v", err) + } + return tmpFile, func() { + err := tmpFile.Close() + if err == nil { + os.Remove(tmpFile.Name()) + } + }, nil +} + +func VerifyBinary(binary, signature io.Reader, verificationKey string) (*crypto.VerifyResult, error) { + pgp := crypto.PGP() + key, err := crypto.NewKeyFromArmored(verificationKey) + if err != nil { + return nil, fmt.Errorf("failed to load the embedded Verification Key: %v", err) + } + + keys, err := crypto.NewKeyRing(key) + if err != nil { + return nil, fmt.Errorf("failed to create keyring: %v", err) + } + + verifier, err := pgp.Verify(). + VerificationKeys(keys). + New() + if err != nil { + return nil, fmt.Errorf("failed to create verifier: %v", err) + } + + verifyDataReader, err := verifier.VerifyingReader(binary, signature, crypto.Auto) + if err != nil { + return nil, fmt.Errorf("failed to create verifying reader: %v", err) + } + + result, err := verifyDataReader.DiscardAllAndVerifySignature() + if err != nil { + return nil, fmt.Errorf("failed to verify authenticity of downloaded file: %v", err) + } + + return result, nil +} + // DownloadBinary downloads a file from the given URL into the specified location, marks it executable and returns its full path. -func DownloadBinary(originURL, destDir, destFile string, config config.Config) (string, error) { +func DownloadBinary(originURL, signatureURL, destDir, destFile string, config config.Config) (DownloadArtifact, error) { err := os.MkdirAll(destDir, 0755) if err != nil { - return "", fmt.Errorf("could not create directory %s: %v", destDir, err) + return DownloadArtifact{}, fmt.Errorf("could not create directory %s: %v", destDir, err) } destinationPath := filepath.Join(destDir, destFile) + destinationSignaturePath := destinationPath + ".sig" + + if signatureURL == "" && config.Get("BAZELISK_NO_SIGNATURE_VERIFICATION") == "" { + return DownloadArtifact{}, fmt.Errorf("signature verification is requested, but no signature URL was provided") + } if _, err := os.Stat(destinationPath); err != nil { - tmpfile, err := os.CreateTemp(destDir, "download") + originTmpFile, originCleanFunc, err := createTempFile(destDir, "download") if err != nil { - return "", fmt.Errorf("could not create temporary file: %v", err) + return DownloadArtifact{}, fmt.Errorf("could not create temporary file: %v", err) } - defer func() { - err := tmpfile.Close() - if err == nil { - os.Remove(tmpfile.Name()) - } - }() + defer originCleanFunc() - u, err := url.Parse(originURL) + err = DownloadFile(originURL, originTmpFile, config) if err != nil { - // originURL supposed to be valid - return "", err - } - - log.Printf("Downloading %s...", originURL) - - var auth string = "" - t, err := tryFindNetrcFileCreds(u.Host) - if err == nil { - // successfully parsed netrc for given host - auth = t + return DownloadArtifact{}, fmt.Errorf("failed to download %s: %v", originURL, err) } - - resp, err := get(originURL, auth) + err = os.Chmod(originTmpFile.Name(), 0755) if err != nil { - return "", fmt.Errorf("HTTP GET %s failed: %w", originURL, err) + return DownloadArtifact{}, fmt.Errorf("could not chmod file %s: %v", originTmpFile.Name(), err) } - defer resp.Body.Close() - if resp.StatusCode == 404 { - return "", NotFound - } else if resp.StatusCode != 200 { - return "", fmt.Errorf("HTTP GET %s failed with error %v", originURL, resp.StatusCode) - } + // download the signature file if signature verification is requested + if config.Get("BAZELISK_NO_SIGNATURE_VERIFICATION") == "" && signatureURL != "" { + signatureTmpFile, signatureCleanFunc, err := createTempFile(destDir, "download-signature-") + if err != nil { + return DownloadArtifact{}, fmt.Errorf("could not create temporary file: %v", err) + } + defer signatureCleanFunc() - _, err = io.Copy( - // Add a progress bar during download. - progress.Writer(tmpfile, "Downloading", resp.ContentLength, config), - resp.Body) - progress.Finish(config) - if err != nil { - return "", fmt.Errorf("could not copy from %s to %s: %v", originURL, tmpfile.Name(), err) - } + err = DownloadFile(signatureURL, signatureTmpFile, config) + if err != nil { + return DownloadArtifact{}, fmt.Errorf("failed to download %s: %v", signatureURL, err) + } - err = os.Chmod(tmpfile.Name(), 0755) - if err != nil { - return "", fmt.Errorf("could not chmod file %s: %v", tmpfile.Name(), err) + signatureTmpFile.Close() + err = os.Rename(signatureTmpFile.Name(), destinationSignaturePath) + if err != nil { + return DownloadArtifact{}, fmt.Errorf("could not move %s to %s: %v", signatureTmpFile.Name(), destinationSignaturePath, err) + } } - tmpfile.Close() - err = os.Rename(tmpfile.Name(), destinationPath) + originTmpFile.Close() + err = os.Rename(originTmpFile.Name(), destinationPath) if err != nil { - return "", fmt.Errorf("could not move %s to %s: %v", tmpfile.Name(), destinationPath, err) + return DownloadArtifact{}, fmt.Errorf("could not move %s to %s: %v", originTmpFile.Name(), destinationPath, err) + } + } else if config.Get("BAZELISK_NO_SIGNATURE_VERIFICATION") == "" { + if _, err := os.Stat(destinationSignaturePath); err != nil { + return DownloadArtifact{}, fmt.Errorf("%s already exists, but corresponding signature file %s does not exist or unaccessable: %v", destinationPath, destinationSignaturePath, err) } } - return destinationPath, nil + return DownloadArtifact{destinationPath, destinationSignaturePath}, nil } // ContentMerger is a function that merges multiple HTTP payloads into a single message. diff --git a/httputil/httputil_test.go b/httputil/httputil_test.go index 1a3f2281..c4d92736 100644 --- a/httputil/httputil_test.go +++ b/httputil/httputil_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/bazelbuild/bazelisk/httputil/httputil_test_helper" ) var ( @@ -251,3 +253,49 @@ func TestNoRetryOnPermanentError(t *testing.T) { t.Fatalf("Expected no retries for permanent error, but got %d", clock.TimesSlept()) } } + +func TestVerifyBinary(t *testing.T) { + key, err := httputil_test_helper.GenerateTestKey("Bazelisk Test", "test@bazel.build") + if err != nil { + t.Fatalf("Failed to generate test key: %v", err) + } + + content := []byte("important content") + signature, err := httputil_test_helper.SignMessage(content, key) + if err != nil { + t.Fatalf("Failed to sign message: %v", err) + } + + t.Run("ValidSignature", func(t *testing.T) { + res, err := VerifyBinary(strings.NewReader(string(content)), strings.NewReader(signature), key) + if err != nil { + t.Fatalf("VerifyBinary failed: %v", err) + } + if err := res.SignatureError(); err != nil { + t.Fatalf("Signature error: %v", err) + } + }) + + t.Run("InvalidSignature", func(t *testing.T) { + res, err := VerifyBinary(strings.NewReader("different content"), strings.NewReader(signature), key) + if err != nil { + // VerifyBinary might return an error or a result with a signature error + return + } + if err := res.SignatureError(); err == nil { + t.Fatal("Expected signature error for different content, but got none") + } + }) + + t.Run("InvalidKey", func(t *testing.T) { + otherKey, _ := httputil_test_helper.GenerateTestKey("Other Key", "other@example.com") + _, err := VerifyBinary(strings.NewReader(string(content)), strings.NewReader(signature), otherKey) + if err == nil { + // In some cases it might return a result with error instead of error + // but usually failing to find the key in keyring for signature is an error or sig error + } + }) +} + +// It would be nice to have a test for an expired key too, but it occurred to be too complicated and +// not expressible in terms of GopenPGP V3. diff --git a/httputil/httputil_test_helper/BUILD b/httputil/httputil_test_helper/BUILD new file mode 100644 index 00000000..197150d0 --- /dev/null +++ b/httputil/httputil_test_helper/BUILD @@ -0,0 +1,14 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "httputil_test_helper", + testonly = True, + srcs = [ + "httputil_test_helper.go", + ], + importpath = "github.com/bazelbuild/bazelisk/httputil/httputil_test_helper", + visibility = ["//visibility:public"], + deps = [ + "@com_github_protonmail_gopenpgp_v3//crypto", + ], +) diff --git a/httputil/httputil_test_helper/httputil_test_helper.go b/httputil/httputil_test_helper/httputil_test_helper.go new file mode 100644 index 00000000..981b02c4 --- /dev/null +++ b/httputil/httputil_test_helper/httputil_test_helper.go @@ -0,0 +1,54 @@ +package httputil_test_helper + +import ( + "github.com/ProtonMail/gopenpgp/v3/crypto" +) + +func GenerateTestKey(name, email string) (string, error) { + pgp := crypto.PGP() + handle := pgp.KeyGeneration(). + AddUserId(name, email). + New() + key, err := handle.GenerateKey() + if err != nil { + return "", err + } + return key.Armor() +} + +func SignMessage(message []byte, armoredKey string) (string, error) { + pgp := crypto.PGP() + key, err := crypto.NewKeyFromArmored(armoredKey) + if err != nil { + return "", err + } + keyring, err := crypto.NewKeyRing(key) + if err != nil { + return "", err + } + signer, err := pgp.Sign(). + SigningKeys(keyring). + Detached(). + New() + if err != nil { + return "", err + } + signature, err := signer.Sign(message, crypto.Armor) + if err != nil { + return "", err + } + return string(signature), nil +} + +func GetExpiredTestKey(name, email string) (string, error) { + pgp := crypto.PGP() + handle := pgp.KeyGeneration(). + AddUserId(name, email). + Lifetime(1). + New() + key, err := handle.GenerateKey() + if err != nil { + return "", err + } + return key.Armor() +} diff --git a/repositories/gcs.go b/repositories/gcs.go index a7fcda5f..b3e0ca44 100644 --- a/repositories/gcs.go +++ b/repositories/gcs.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "log" + "os" "strconv" "strings" "time" @@ -181,10 +182,10 @@ func getTrack(version string) (int, error) { } // DownloadLTS downloads the given Bazel LTS release (candidate) into the specified location and returns the absolute path. -func (gcs *GCSRepo) DownloadLTS(version, destDir, destFile string, config config.Config) (string, error) { +func (gcs *GCSRepo) DownloadLTS(version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { srcFile, err := platforms.DetermineBazelFilename(version, true, config) if err != nil { - return "", err + return httputil.DownloadArtifact{}, err } var baseVersion, folder string @@ -196,7 +197,7 @@ func (gcs *GCSRepo) DownloadLTS(version, destDir, destFile string, config config } url := fmt.Sprintf("%s/%s/%s/%s", ltsBaseURL, baseVersion, folder, srcFile) - return httputil.DownloadBinary(url, destDir, destFile, config) + return httputil.DownloadBinary(url, url+".sig", destDir, destFile, config) } // CommitRepo @@ -218,14 +219,18 @@ func (gcs *GCSRepo) GetLastGreenCommit(bazeliskHome string) (string, error) { } // DownloadAtCommit downloads a Bazel binary built at the given commit into the specified location and returns the absolute path. -func (gcs *GCSRepo) DownloadAtCommit(commit, destDir, destFile string, config config.Config) (string, error) { +func (gcs *GCSRepo) DownloadAtCommit(commit, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { log.Printf("Using unreleased version at commit %s", commit) platform, err := platforms.GetPlatform() if err != nil { - return "", err + return httputil.DownloadArtifact{}, err } url := fmt.Sprintf("%s/%s/%s/bazel", commitBaseURL, platform, commit) - return httputil.DownloadBinary(url, destDir, destFile, config) + + log.Printf("No signature is available for unreleased version at commit %s, forcefully setting BAZELISK_NO_SIGNATURE_VERIFICATION=1", commit) + os.Setenv("BAZELISK_NO_SIGNATURE_VERIFICATION", "1") + + return httputil.DownloadBinary(url, "", destDir, destFile, config) } // RollingRepo @@ -266,13 +271,13 @@ func (gcs *GCSRepo) GetRollingVersions(bazeliskHome string) ([]string, error) { } // DownloadRolling downloads the given Bazel version into the specified location and returns the absolute path. -func (gcs *GCSRepo) DownloadRolling(version, destDir, destFile string, config config.Config) (string, error) { +func (gcs *GCSRepo) DownloadRolling(version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { srcFile, err := platforms.DetermineBazelFilename(version, true, config) if err != nil { - return "", err + return httputil.DownloadArtifact{}, err } releaseVersion := strings.Split(version, "-")[0] url := fmt.Sprintf("%s/%s/rolling/%s/%s", ltsBaseURL, releaseVersion, version, srcFile) - return httputil.DownloadBinary(url, destDir, destFile, config) + return httputil.DownloadBinary(url, url+".sig", destDir, destFile, config) } diff --git a/repositories/github.go b/repositories/github.go index c5c3f4b4..6a3e2aeb 100644 --- a/repositories/github.go +++ b/repositories/github.go @@ -85,11 +85,11 @@ type gitHubRelease struct { } // DownloadVersion downloads a Bazel binary for the given version and fork to the specified location and returns the absolute path. -func (gh *GitHubRepo) DownloadVersion(fork, version, destDir, destFile string, config config.Config) (string, error) { +func (gh *GitHubRepo) DownloadVersion(fork, version, destDir, destFile string, config config.Config) (httputil.DownloadArtifact, error) { filename, err := platforms.DetermineBazelFilename(version, true, config) if err != nil { - return "", err + return httputil.DownloadArtifact{}, err } url := fmt.Sprintf(urlPattern, fork, version, filename) - return httputil.DownloadBinary(url, destDir, destFile, config) + return httputil.DownloadBinary(url, url+".sig", destDir, destFile, config) }