diff --git a/.goreleaser.yaml b/.goreleaser.yaml index fbc683b6a5..e5f506fcd3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -103,6 +103,10 @@ archives: - goos: windows format: zip +checksum: + name_template: "checksums.txt" + algorithm: sha256 + changelog: sort: asc filters: diff --git a/SECURITY.md b/SECURITY.md index f816b695c7..7f1f65e462 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,10 @@ Bruin is currently running a single major version, v0, which will continue receiving security updates as we go. +## Binary Integrity + +Bruin verifies downloaded gong helper binaries with SHA256 checksums before installation. If the checksum is missing or does not match the downloaded file, installation stops and the downloaded file is discarded. + ## Reporting a Vulnerability Please report security vulnerabilities via the email address `security@getbruin.com` diff --git a/pkg/gong/checksum.go b/pkg/gong/checksum.go new file mode 100644 index 0000000000..e9df993bac --- /dev/null +++ b/pkg/gong/checksum.go @@ -0,0 +1,75 @@ +package gong + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "strings" +) + +// parseChecksumManifest parses a sha256sum-format checksum file and returns +// the expected SHA256 hex string for the given artifactName. +// +// The expected file format is one entry per line: +// +// +// +// Empty lines and lines starting with '#' are ignored. +func parseChecksumManifest(contents []byte, artifactName string) (string, error) { + lines := strings.Split(string(contents), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + checksum := parts[0] + filename := parts[1] + if filename != artifactName { + continue + } + if !isValidSHA256(checksum) { + return "", fmt.Errorf("invalid checksum for %s", artifactName) + } + return checksum, nil + } + return "", fmt.Errorf("checksum entry not found for %s", artifactName) +} + +// verifySHA256 computes the SHA256 digest of the file at path and compares it +// against the expected hex string. The comparison is case-insensitive so both +// uppercase and lowercase checksums are accepted. +func verifySHA256(path string, expected string) error { + if !isValidSHA256(expected) { + return fmt.Errorf("invalid expected checksum") + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + actual := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(actual, expected) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual) + } + return nil +} + +// isValidSHA256 reports whether value is a valid SHA256 hex digest: +// exactly 64 hexadecimal characters (case-insensitive). +func isValidSHA256(value string) bool { + if len(value) != 64 { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} diff --git a/pkg/gong/checksum_test.go b/pkg/gong/checksum_test.go new file mode 100644 index 0000000000..08031b3f32 --- /dev/null +++ b/pkg/gong/checksum_test.go @@ -0,0 +1,247 @@ +package gong + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── shared test fixtures ──────────────────────────────────────────────────── + +// knownHash is the real SHA256 of the string "test". +// We hardcode it so test expectations are trivially auditable without running +// any hash code in the test setup itself. +const knownHash = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + +// hashOf returns the lowercase hex SHA256 of b. +// Used to build expected values for verifySHA256 tests without +// duplicating the hash algorithm. +func hashOf(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// tempFileWithContent writes content to a new temp file inside t.TempDir() +// and returns the file path. Cleanup is handled automatically by the test runner. +func tempFileWithContent(t *testing.T, content []byte) string { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "verify-sha256-*") + require.NoError(t, err) + _, err = f.Write(content) + require.NoError(t, err) + require.NoError(t, f.Close()) + return f.Name() +} + +// ─── parseChecksumManifest ─────────────────────────────────────────────────── + +func TestParseChecksumManifestReturnsMatchingChecksum(t *testing.T) { + t.Parallel() + + artifactName := "gong_linux_amd64" + manifest := []byte(knownHash + " " + artifactName + "\n") + + got, err := parseChecksumManifest(manifest, artifactName) + + require.NoError(t, err) + assert.Equal(t, knownHash, got) +} + +func TestParseChecksumManifestIgnoresEmptyAndCommentLines(t *testing.T) { + t.Parallel() + + artifactName := "gong_linux_amd64" + // Manifest deliberately padded with every kind of noise the spec calls out: + // blank lines, lines of only whitespace, and comment lines. + manifest := []byte(fmt.Sprintf( + "# generated by goreleaser\n"+ + "\n"+ + " \n"+ + "# sha256\n"+ + "%s %s\n"+ + "\n"+ + "# end\n", + knownHash, artifactName, + )) + + got, err := parseChecksumManifest(manifest, artifactName) + + require.NoError(t, err) + assert.Equal(t, knownHash, got) +} + +// TestParseChecksumManifestPicksCorrectEntryAmongMultiple verifies that when a +// manifest contains several valid entries the parser returns the checksum for +// the requested artifact only — not for any other artifact. +func TestParseChecksumManifestPicksCorrectEntryAmongMultiple(t *testing.T) { + t.Parallel() + + arm64Hash := strings.Repeat("a", 64) + amd64Hash := knownHash + manifest := []byte(fmt.Sprintf( + "%s gong_linux_arm64\n"+ + "%s gong_linux_amd64\n", + arm64Hash, amd64Hash, + )) + + got, err := parseChecksumManifest(manifest, "gong_linux_amd64") + + require.NoError(t, err) + assert.Equal(t, amd64Hash, got) +} + +func TestParseChecksumManifestReturnsErrorWhenArtifactMissing(t *testing.T) { + t.Parallel() + + // Manifest contains only arm64; requesting amd64 must fail. + manifest := []byte(knownHash + " gong_linux_arm64\n") + + _, err := parseChecksumManifest(manifest, "gong_linux_amd64") + + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum entry not found for gong_linux_amd64") +} + +// TestParseChecksumManifestReturnsErrorForMalformedLine covers the case where +// the manifest holds a line with only one whitespace-separated token (no +// checksum column). The parser skips such lines — because a corrupt entry for +// an unrelated artifact must not block a valid one — so the artifact is never +// matched and "checksum entry not found" is returned. The caller therefore +// always receives an error rather than a silent empty string. +func TestParseChecksumManifestReturnsErrorForMalformedLine(t *testing.T) { + t.Parallel() + + // Single-token line: the artifact name appears but with no checksum column. + manifest := []byte("gong_linux_amd64\n") + + _, err := parseChecksumManifest(manifest, "gong_linux_amd64") + + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum entry not found for gong_linux_amd64") +} + +// TestParseChecksumManifestReturnsErrorForInvalidChecksum covers a line that is +// structurally valid (two fields) but whose checksum column contains non-hex +// characters. This is distinct from a malformed line: the artifact IS found, +// but isValidSHA256 rejects the value before we return it. +func TestParseChecksumManifestReturnsErrorForInvalidChecksum(t *testing.T) { + t.Parallel() + + // 64 characters but all 'Z' — valid length, invalid hex. + invalidChecksum := strings.Repeat("Z", 64) + artifactName := "gong_linux_amd64" + manifest := []byte(invalidChecksum + " " + artifactName + "\n") + + _, err := parseChecksumManifest(manifest, artifactName) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid checksum for gong_linux_amd64") +} + +// ─── verifySHA256 ──────────────────────────────────────────────────────────── + +func TestVerifySHA256ReturnsNilForMatchingChecksum(t *testing.T) { + t.Parallel() + + content := []byte("fake gong binary content") + path := tempFileWithContent(t, content) + expected := hashOf(content) + + err := verifySHA256(path, expected) + + assert.NoError(t, err) +} + +func TestVerifySHA256ReturnsErrorForMismatch(t *testing.T) { + t.Parallel() + + content := []byte("fake gong binary content") + path := tempFileWithContent(t, content) + // Checksum of different content — guaranteed not to match. + wrongHash := hashOf([]byte("completely different content")) + + err := verifySHA256(path, wrongHash) + + require.Error(t, err) + // Error must name both hashes so the operator can diagnose without + // running external tools. + assert.Contains(t, err.Error(), "checksum mismatch") + assert.Contains(t, err.Error(), hashOf(content)) // actual + assert.Contains(t, err.Error(), wrongHash) // expected +} + +// TestVerifySHA256AcceptsUppercaseChecksum ensures the comparison is +// case-insensitive, which is important because GoReleaser emits lowercase +// hashes but users may paste uppercase values from other tools. +func TestVerifySHA256AcceptsUppercaseChecksum(t *testing.T) { + t.Parallel() + + content := []byte("fake gong binary content") + path := tempFileWithContent(t, content) + upper := strings.ToUpper(hashOf(content)) + + err := verifySHA256(path, upper) + + assert.NoError(t, err) +} + +// TestVerifySHA256ReturnsErrorForNonExistentFile ensures the function surfaces +// the OS error when the target file is absent, so callers get a clear signal +// instead of a hash-mismatch error. +func TestVerifySHA256ReturnsErrorForNonExistentFile(t *testing.T) { + t.Parallel() + + err := verifySHA256(t.TempDir()+"/does-not-exist", knownHash) + + require.Error(t, err) + assert.True(t, os.IsNotExist(err), "expected a not-exist error, got: %v", err) +} + +// TestVerifySHA256ReturnsErrorForInvalidExpectedChecksum verifies that the +// guard inside verifySHA256 fires before any file I/O when the caller passes +// a structurally invalid expected value. +func TestVerifySHA256ReturnsErrorForInvalidExpectedChecksum(t *testing.T) { + t.Parallel() + + path := tempFileWithContent(t, []byte("anything")) + + err := verifySHA256(path, "tooshort") + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid expected checksum") +} + +// ─── isValidSHA256 ─────────────────────────────────────────────────────────── + +func TestIsValidSHA256(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + input string + want bool + }{ + {"lowercase valid", knownHash, true}, + {"uppercase valid", strings.ToUpper(knownHash), true}, + {"mixed case valid", strings.ToUpper(knownHash[:32]) + knownHash[32:], true}, + {"too short", knownHash[:63], false}, + {"too long", knownHash + "0", false}, + {"empty string", "", false}, + {"non-hex chars", strings.Repeat("Z", 64), false}, + {"spaces inside", strings.Repeat("a", 32) + " " + strings.Repeat("b", 31), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, isValidSHA256(tc.input)) + }) + } +} diff --git a/pkg/gong/gong.go b/pkg/gong/gong.go index 02cd49420f..f5ee9df685 100644 --- a/pkg/gong/gong.go +++ b/pkg/gong/gong.go @@ -24,8 +24,7 @@ import ( var Version string const ( - BaseURL = "https://storage.googleapis.com/gong-release" - + BaseURL = "https://storage.googleapis.com/gong-release" binDir = "bin" httpTimeout = 5 * time.Minute filePermissions = 0o755 @@ -35,8 +34,10 @@ const ( // It supports installing multiple versions side-by-side; concurrent installs of // the same version are deduplicated, while different versions install in parallel. type Checker struct { - mu sync.Mutex - slots map[string]*installSlot + mu sync.Mutex + slots map[string]*installSlot + baseURL string // overridden in tests to redirect downloads to a local HTTP server + installDir string // overridden in tests to install to a temporary directory } type installSlot struct { @@ -61,6 +62,7 @@ func (g *Checker) EnsureGongInstalled(ctx context.Context, version string) (stri g.dropSlot(resolvedVersion, slot) } }) + return slot.path, slot.err } @@ -86,14 +88,29 @@ func (g *Checker) dropSlot(version string, slot *installSlot) { } } -func (g *Checker) binaryPath(version string) (string, error) { - m := user.NewConfigManager(afero.NewOsFs()) - bruinHomeDirAbsPath, err := m.EnsureAndGetBruinHomeDir() - if err != nil { - return "", errors.Wrap(err, "failed to get bruin home directory") +// effectiveBaseURL returns the base URL to use for downloads. +// Tests set g.baseURL to a local httptest.Server address; production code +// leaves it empty and falls back to the package-level BaseURL constant. +func (g *Checker) effectiveBaseURL() string { + if g.baseURL != "" { + return g.baseURL } + return BaseURL +} - binDirPath := filepath.Join(bruinHomeDirAbsPath, binDir) +func (g *Checker) binaryPath(version string) (string, error) { + var binDirPath string + if g.installDir != "" { + // Used by tests to redirect installs to a temporary directory. + binDirPath = g.installDir + } else { + m := user.NewConfigManager(afero.NewOsFs()) + bruinHomeDirAbsPath, err := m.EnsureAndGetBruinHomeDir() + if err != nil { + return "", errors.Wrap(err, "failed to get bruin home directory") + } + binDirPath = filepath.Join(bruinHomeDirAbsPath, binDir) + } if err := os.MkdirAll(binDirPath, filePermissions); err != nil { return "", errors.Wrap(err, "failed to create bin directory") } @@ -134,11 +151,44 @@ func (g *Checker) ensureInstalled(ctx context.Context, version string) (string, return gongBinaryPath, nil } -// buildDownloadURL constructs the download URL for the gong binary based on OS and architecture. +// buildDownloadURL constructs the download URL for the gong binary using the +// package-level BaseURL constant. Existing callers and unit tests use this form. func buildDownloadURL(version string) string { - osName := getOSName() - archName := getArchName() - return fmt.Sprintf("%s/releases/%s/%s/gong_%s", BaseURL, version, osName, archName) + return buildDownloadURLWithBase(BaseURL, version) +} + +// buildDownloadURLWithBase is like buildDownloadURL but accepts an explicit base +// URL so that tests can redirect downloads to a local HTTP server. +func buildDownloadURLWithBase(base, version string) string { + return fmt.Sprintf("%s/releases/%s/%s/gong_%s", base, version, getOSName(), getArchName()) +} + +// buildChecksumURL returns the URL of the SHA256 checksum manifest published +// alongside a gong release. The manifest uses the standard sha256sum format. +func buildChecksumURL(base, version string) string { + return fmt.Sprintf("%s/releases/%s/checksums.txt", base, version) +} + +// downloadChecksumManifest fetches the raw bytes of the checksum manifest at +// the given URL. The caller is responsible for parsing the returned bytes. +func downloadChecksumManifest(ctx context.Context, url string, client *http.Client) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create checksum request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download checksum file: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download checksum file: server returned status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap + if err != nil { + return nil, fmt.Errorf("failed to read checksum file: %w", err) + } + return data, nil } func getOSName() string { @@ -192,7 +242,10 @@ func (g *Checker) downloadGong(ctx context.Context, version, destPath string) er _, _ = fmt.Fprintf(output, "This is a one-time operation.\n") _, _ = fmt.Fprintf(output, "\n") - downloadURL := buildDownloadURL(version) + base := g.effectiveBaseURL() + artifactName := fmt.Sprintf("gong_%s_%s", getOSName(), getArchName()) + downloadURL := buildDownloadURLWithBase(base, version) + _, _ = fmt.Fprintf(output, "Downloading from %s\n", downloadURL) client := &http.Client{ @@ -239,6 +292,20 @@ func (g *Checker) downloadGong(ctx context.Context, version, destPath string) er return fmt.Errorf("failed to close temporary file: %w", err) } + // Verify the downloaded binary against the published checksum manifest before + // granting executable permissions or moving it to the final destination. + // On any failure the deferred os.Remove cleans up tmpPath automatically. + manifest, err := downloadChecksumManifest(ctx, buildChecksumURL(base, version), client) + if err != nil { + return err + } + expectedChecksum, err := parseChecksumManifest(manifest, artifactName) + if err != nil { + return err + } + if err := verifySHA256(tmpPath, expectedChecksum); err != nil { + return err + } // Set executable permissions (on Windows this is a no-op effectively) if err := os.Chmod(tmpPath, filePermissions); err != nil { return fmt.Errorf("failed to set executable permissions: %w", err) diff --git a/pkg/gong/gong_install_test.go b/pkg/gong/gong_install_test.go new file mode 100644 index 0000000000..54168137cc --- /dev/null +++ b/pkg/gong/gong_install_test.go @@ -0,0 +1,182 @@ +package gong + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ─── shared fixtures ───────────────────────────────────────────────────────── + +// fakeBinaryContent is a small, deterministic blob used as the stand-in gong +// executable. Using a package-level var (read-only in tests) avoids allocating +// it repeatedly while keeping all tests hermetic. +var fakeBinaryContent = []byte("fake gong binary content for integration tests") + +// checksumManifestFor returns a sha256sum-format manifest whose single entry +// covers binary under the current platform's artifact name (e.g. gong_linux_amd64). +// This mirrors the format that GoReleaser publishes as checksums.txt. +func checksumManifestFor(binary []byte) string { + sum := sha256.Sum256(binary) + artifactName := fmt.Sprintf("gong_%s_%s", getOSName(), getArchName()) + return hex.EncodeToString(sum[:]) + " " + artifactName + "\n" +} + +// installBinaryPath reconstructs the on-disk path where Checker places the +// binary when installDir is set. It mirrors (*Checker).binaryPath exactly so +// there is a single authoritative location per test. +func installBinaryPath(installDir, version string) string { + name := "gong-" + version + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(installDir, name) +} + +// newInstallTestServer starts an httptest.Server that routes the two URLs +// Checker derives from its baseURL: +// +// - /releases/{version}/{os}/gong_{arch} → serves binary with HTTP 200 +// - /releases/{version}/checksums.txt → serves checksumBody with checksumStatus +// +// Requests for any other path are treated as test failures. +// checksumStatus == 0 is normalised to http.StatusOK. +// The server is automatically closed when the test finishes. +func newInstallTestServer( + t *testing.T, + version string, + binary []byte, + checksumBody string, + checksumStatus int, +) *httptest.Server { + t.Helper() + + if checksumStatus == 0 { + checksumStatus = http.StatusOK + } + binaryURLPath := fmt.Sprintf("/releases/%s/%s/gong_%s", version, getOSName(), getArchName()) + checksumURLPath := fmt.Sprintf("/releases/%s/checksums.txt", version) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case binaryURLPath: + w.WriteHeader(http.StatusOK) + _, _ = w.Write(binary) + case checksumURLPath: + w.WriteHeader(checksumStatus) + _, _ = io.WriteString(w, checksumBody) + default: + // Any unexpected request is a test bug, not a product bug. + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// ─── integration tests ─────────────────────────────────────────────────────── + +const installTestVersion = "v0.0.0-test" + +// TestEnsureGongInstalled_ValidChecksumInstallsBinary is the happy-path test: +// the checksum manifest matches the downloaded binary, so EnsureGongInstalled +// must return no error and write the binary to the install directory with the +// exact same bytes that the server sent. +func TestEnsureGongInstalled_ValidChecksumInstallsBinary(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + manifest := checksumManifestFor(fakeBinaryContent) + srv := newInstallTestServer(t, installTestVersion, fakeBinaryContent, manifest, 0) + checker := &Checker{baseURL: srv.URL, installDir: installDir} + + gotPath, err := checker.EnsureGongInstalled(context.Background(), installTestVersion) + + require.NoError(t, err, "valid checksum must not produce an error") + expectedPath := installBinaryPath(installDir, installTestVersion) + assert.Equal(t, expectedPath, gotPath, "returned path must equal the final install destination") + + // Content integrity: bytes on disk must exactly match what the server served. + gotBytes, readErr := os.ReadFile(expectedPath) + require.NoError(t, readErr, "installed binary must be readable at the returned path") + assert.Equal(t, fakeBinaryContent, gotBytes, "installed binary content must equal the downloaded binary") +} + +// TestEnsureGongInstalled_InvalidChecksumBlocksInstall verifies that when the +// checksum manifest contains a hash for different content, EnsureGongInstalled +// returns an error that names the mismatch, and leaves no file at the final +// install path (the temp file is cleaned up automatically by the deferred +// os.Remove inside downloadGong). +func TestEnsureGongInstalled_InvalidChecksumBlocksInstall(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + // Build a manifest whose hash is for content that differs from fakeBinaryContent. + wrongManifest := checksumManifestFor([]byte("this is not the binary that was downloaded")) + srv := newInstallTestServer(t, installTestVersion, fakeBinaryContent, wrongManifest, 0) + checker := &Checker{baseURL: srv.URL, installDir: installDir} + + _, err := checker.EnsureGongInstalled(context.Background(), installTestVersion) + + require.Error(t, err, "a checksum mismatch must produce an error") + assert.Contains(t, err.Error(), "checksum mismatch", + "error message must identify the cause as a checksum mismatch") + _, statErr := os.Stat(installBinaryPath(installDir, installTestVersion)) + assert.True(t, os.IsNotExist(statErr), + "binary must not exist at the final install path after a mismatch") +} + +// TestEnsureGongInstalled_MissingChecksumEntryBlocksInstall verifies that when +// the checksum manifest does not contain an entry for the current platform's +// artifact name, EnsureGongInstalled fails before installing anything. +func TestEnsureGongInstalled_MissingChecksumEntryBlocksInstall(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + // Manifest has a valid entry but for a completely different artifact name, + // so the lookup for the current platform's artifact returns "not found". + unrelatedHash := hashOf(fakeBinaryContent) + manifestWithWrongArtifact := unrelatedHash + " gong_completely_different_arch\n" + srv := newInstallTestServer(t, installTestVersion, fakeBinaryContent, manifestWithWrongArtifact, 0) + checker := &Checker{baseURL: srv.URL, installDir: installDir} + + _, err := checker.EnsureGongInstalled(context.Background(), installTestVersion) + + require.Error(t, err, "a missing checksum entry must produce an error") + assert.Contains(t, err.Error(), "checksum entry not found", + "error message must report that no matching checksum was found in the manifest") + _, statErr := os.Stat(installBinaryPath(installDir, installTestVersion)) + assert.True(t, os.IsNotExist(statErr), + "binary must not exist at the final install path when its checksum entry is absent") +} + +// TestEnsureGongInstalled_ChecksumServerFailureBlocksInstall verifies that an +// HTTP 500 from the checksum endpoint prevents the binary from reaching its +// final install path. The binary download itself succeeds; only the checksum +// fetch fails — this is the strongest proof that verification is mandatory. +func TestEnsureGongInstalled_ChecksumServerFailureBlocksInstall(t *testing.T) { + t.Parallel() + + installDir := t.TempDir() + srv := newInstallTestServer(t, installTestVersion, fakeBinaryContent, "", http.StatusInternalServerError) + checker := &Checker{baseURL: srv.URL, installDir: installDir} + + _, err := checker.EnsureGongInstalled(context.Background(), installTestVersion) + + require.Error(t, err, + "an HTTP error on the checksum endpoint must propagate as an error") + _, statErr := os.Stat(installBinaryPath(installDir, installTestVersion)) + assert.True(t, os.IsNotExist(statErr), + "binary must not exist at the final install path after a checksum server failure") +}