diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index c59b70ad2..739596558 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -286,6 +286,7 @@ func init() { imagesImportCmd.Flags().String("platform", "Linux/UNIX", "Specified platform (e.g Linux/UNIX, Windows)") imagesImportCmd.Flags().StringSlice("tag", nil, "Tag to apply to the imported AMI as key=value (repeatable; e.g. --tag spinifex:managed-by=elbv2)") imagesImportCmd.Flags().Bool("force", false, "Force command execution (overwrites existing files)") + imagesImportCmd.Flags().Bool("skip-verify", false, "Skip catalog-image checksum verification (INSECURE; operator assumes integrity responsibility)") } func runimagesImportCmd(cmd *cobra.Command, args []string) { @@ -297,6 +298,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { cfgFile, _ := cmd.Flags().GetString("config") forceCmd, _ := cmd.Flags().GetBool("force") + skipVerify, _ := cmd.Flags().GetBool("skip-verify") ostmpDir, _ := cmd.Flags().GetString("tmp-dir") // Use default config path @@ -399,16 +401,12 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { os.Exit(1) } - fmt.Printf("✅ Created config directory: %s\n", imagePath) - // Next, if the file is selected to download, fetch it, extract disk image, and save to path if imageName != "" && localFile == "" { // Download the file to the image path filename := path.Base(image.URL) imageFile = fmt.Sprintf("%s/%s", imagePath, filename) - fmt.Printf("Downloading image %s to %s\n", image.URL, imageFile) - // If image path exists, skip if admin.FileExists(imageFile) && !forceCmd { fmt.Printf("Image file already exists, skipping download, use --force to overwrite: %s\n", imageFile) @@ -421,8 +419,23 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { } } - // Update image file path for later extraction - //imagePath = imageFilePath + // Verify before extract: the catalog digest is of the artifact as it + // sits on the mirror (.tar.xz/.img/.raw). Also catches a poisoned + // cache on the re-run path — failure leaves the file on disk so the + // operator can inspect; recover with --force. + if skipVerify { + fmt.Fprintf(os.Stderr, "⚠️ --skip-verify set: checksum verification skipped for %s\n", imageName) + } else { + if image.Checksum == "" || image.ChecksumType == "" { + fmt.Fprintf(os.Stderr, "Catalog entry %q is missing Checksum/ChecksumType; refusing import.\n", imageName) + os.Exit(1) + } + if err := utils.VerifyImageChecksum(imageFile, image.Checksum, image.ChecksumType); err != nil { + printChecksumError(os.Stderr, imageFile, imageName, image, err) + os.Exit(1) + } + fmt.Printf("✅ Verified image checksum (%s)\n", image.ChecksumType) + } } // Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image @@ -434,9 +447,6 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { } extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath) - - fmt.Println("Extracted image to:", extractedImagePath) - if err != nil { fmt.Fprintf(os.Stderr, "Could not extract image: %v\n", err) os.Exit(1) @@ -2134,3 +2144,17 @@ func installCACertificate(caPemPath string) { fmt.Printf("✅ CA certificate installed to system trust store\n") } + +// printChecksumError writes the failure, the source URL (printed for every +// error kind so 404/non-HTTPS/size-cap failures tell the operator which URL +// to investigate), and the exact --force recovery command. The cached file +// is left in place: an implicit auto-delete would mutate state inside +// "verify", and a tampered artifact is forensically useful intact. +func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Images, err error) { + fmt.Fprintf(w, "Image integrity verification failed: %v\n", err) + fmt.Fprintf(w, " file: %s\n", imageFile) + fmt.Fprintf(w, " source: %s\n", image.Checksum) + fmt.Fprintln(w) + fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:") + fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName) +} diff --git a/cmd/spinifex/cmd/admin_test.go b/cmd/spinifex/cmd/admin_test.go new file mode 100644 index 000000000..4fdb92cac --- /dev/null +++ b/cmd/spinifex/cmd/admin_test.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/stretchr/testify/assert" +) + +// Regression guard: source URL must print for every error kind, not just +// mismatch, so a 404/non-HTTPS/size-cap failure still tells the operator +// which URL to investigate. +func TestPrintChecksumError(t *testing.T) { + image := utils.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"} + const imageFile = "/var/lib/img.tar.xz" + const imageName = "debian-12-x86_64" + + errs := []error{ + fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch), + fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed), + errors.New("open /x: no such file"), + } + for _, e := range errs { + var buf bytes.Buffer + printChecksumError(&buf, imageFile, imageName, image, e) + out := buf.String() + assert.Contains(t, out, image.Checksum, "source URL must print for: %v", e) + assert.Contains(t, out, imageFile) + assert.Contains(t, out, "spx admin images import --name "+imageName+" --force") + } +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 90410eeb0..cca285a4b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -89,9 +89,40 @@ Operational commands for inspecting cluster state. These fan out NATS requests t | Command | Flags | Prerequisites | Basic Logic | Test Cases | Status | |---------|-------|---------------|-------------|------------|--------| -| `spx admin images import` | `--name` | Cluster must be running, image file must exist locally | Imports a local disk image (QCOW2) as an AMI → uploads to Predastore S3 → registers as available AMI | 1. Import valid QCOW2 image
2. Image appears in `describe-images` | **DONE** | +| `spx admin images import` | `--name`, `--file`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, and verify the SHA-256/SHA-512 digest before extraction. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip verification — operator-supplied media is outside Spinifex's trust boundary, and the skip is logged at INFO for audit. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. | 1. Import valid catalog image (verifies checksum)
2. Tampered cache hit fails with `ErrChecksumMismatch`
3. `--force` recovers after a mismatch
4. `--file` import skips verification
5. `--skip-verify` bypasses checksum with WARN | **DONE** | | `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** | +#### Image integrity verification (CMMC SI.L1-3.14.2) + +Catalog imports (`spx admin images import --name `) verify the image +against the catalog-declared SHA-256/SHA-512 digest before extraction. The sums +file is fetched from the catalog `Checksum` URL over HTTPS only (cross-scheme +redirects refused), and verification runs on both fresh downloads and cache +hits so a poisoned cache is caught on the next import. + +On mismatch the import exits non-zero, the cached file is left on disk for +inspection, and the printed guidance is `spx admin images import --name +--force` to re-download. + +`--file` imports skip verification by design: operator-supplied media is +outside Spinifex's trust boundary and the operator is responsible for +integrity (e.g. `sha256sum` against a trusted upstream digest before import). +The skip is recorded as an INFO `slog` event with `reason=local-file-import` +so a CMMC assessor can audit the decision from journald. + +`--skip-verify` bypasses the checksum step for catalog imports. The command +still downloads via the catalog URL but does not compare the image digest +against the sums file. Intended for narrow cases such as debugging upstream +mirror issues or running against a transiently-broken `latest/` path; the +skip is logged at WARN with `reason=skip-verify-flag` and printed to stderr +so operators and assessors see it. Prefer `--file` with an out-of-band +verified image over `--skip-verify` whenever possible. + +**Limitation:** verification confirms the image matches the digest the mirror +served. A mirror compromise that swaps both image and sums file is not +detected; closing that gap requires GPG signature verification of the sums +file, deferred to a later phase. + ## AWS Commands ### EC2 - Instance Management diff --git a/spinifex/utils/images.go b/spinifex/utils/images.go index 6134ed3cc..79a0e7676 100644 --- a/spinifex/utils/images.go +++ b/spinifex/utils/images.go @@ -3,11 +3,23 @@ package utils import ( "bufio" "bytes" + "context" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "encoding/hex" "errors" "fmt" + "hash" "io" + "log/slog" + "net/http" + "net/url" "os" "os/exec" + "os/signal" "path/filepath" "runtime" "strings" @@ -18,6 +30,230 @@ import ( var ErrQCOWDetected = errors.New("qcow format detected") +const sumsFileMaxSize = 1 * 1024 * 1024 // 1 MB, matches formation.go JoinRequest cap. + +var ( + ErrChecksumMismatch = errors.New("image checksum mismatch") + ErrChecksumNotFound = errors.New("checksum entry for image filename not found") + ErrUnsupportedChecksumType = errors.New("unsupported checksum type") + ErrChecksumFetchFailed = errors.New("checksum fetch failed") +) + +// checksumExtraRootCAs is a test-only hook so unit tests can stand up an +// httptest.NewTLSServer (which uses a self-signed cert) without relaxing the +// HTTPS enforcement. Production builds leave this nil and the verification +// client uses the system trust store. +var checksumExtraRootCAs *x509.CertPool + +// checksumFetchTimeout is a var (not const) so tests can shrink it to exercise +// the context-deadline path without waiting 30s. +var checksumFetchTimeout = 30 * time.Second + +// VerifyImageChecksum fetches the sums file at checksumURL, locates the entry +// for imagePath's basename, hashes imagePath with the algorithm named by +// checksumType ("sha256" or "sha512"), and compares digests. +// +// Fails closed: every error path returns without accepting the image. A +// non-HTTPS scheme, non-2xx status, transport error, response over 1 MB, or +// cross-scheme redirect all wrap ErrChecksumFetchFailed. Unknown algorithm +// wraps ErrUnsupportedChecksumType. Missing filename entry wraps +// ErrChecksumNotFound. Digest mismatch wraps ErrChecksumMismatch and the +// wrapped error's %v includes expected and actual hex. +func VerifyImageChecksum(imagePath, checksumURL, checksumType string) error { + hasher, err := newHasher(checksumType) + if err != nil { + return err + } + + expected, err := fetchExpectedDigest(checksumURL, filepath.Base(imagePath)) + if err != nil { + slog.Error("image checksum fetch failed", "source", checksumURL, "err", err) + return err + } + + // Distinct error for a catalog/sums-file algorithm mismatch — without this + // it'd surface as "tampering" via ConstantTimeCompare's length-0 return. + if len(expected) != hasher.Size()*2 { + return fmt.Errorf("%w: digest length %d from sums file does not match %s output length %d", + ErrChecksumFetchFailed, len(expected), checksumType, hasher.Size()*2) + } + + actual, err := hashImageFile(imagePath, hasher) + if err != nil { + return fmt.Errorf("hash image file: %w", err) + } + + if subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 { + slog.Error("image checksum mismatch", + "image", imagePath, + "algorithm", checksumType, + "expected", expected, + "actual", actual, + "source", checksumURL, + ) + return fmt.Errorf("%w: expected %s got %s", ErrChecksumMismatch, expected, actual) + } + + return nil +} + +func newHasher(checksumType string) (hash.Hash, error) { + switch strings.ToLower(checksumType) { + case "sha256": + return sha256.New(), nil + case "sha512": + return sha512.New(), nil + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedChecksumType, checksumType) + } +} + +// fetchExpectedDigest downloads the sums file and returns the hex digest for +// the given filename. Enforces HTTPS on initial URL and every redirect hop, +// caps redirects at 10, and truncates response at sumsFileMaxSize+1 so the +// size check is unambiguous. +func fetchExpectedDigest(checksumURL, filename string) (string, error) { + parsed, err := url.Parse(checksumURL) + if err != nil { + return "", fmt.Errorf("%w: parse url: %v", ErrChecksumFetchFailed, err) + } + if parsed.Scheme != "https" { + return "", fmt.Errorf("%w: non-https checksum url scheme %q", ErrChecksumFetchFailed, parsed.Scheme) + } + + ctx, cancel := context.WithTimeout(context.Background(), checksumFetchTimeout) + defer cancel() + intCh := make(chan os.Signal, 1) + signal.Notify(intCh, os.Interrupt) + defer signal.Stop(intCh) + go func() { + select { + case <-intCh: + cancel() + case <-ctx.Done(): + } + }() + + transport := &http.Transport{} + if checksumExtraRootCAs != nil { + transport.TLSClientConfig = &tls.Config{RootCAs: checksumExtraRootCAs} + } + client := &http.Client{ + Timeout: checksumFetchTimeout, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing non-https redirect to %s", req.URL.Redacted()) + } + return nil + }, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) + if err != nil { + return "", fmt.Errorf("%w: build request: %v", ErrChecksumFetchFailed, err) + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrChecksumFetchFailed, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return "", fmt.Errorf("%w: unexpected status %s", ErrChecksumFetchFailed, resp.Status) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, sumsFileMaxSize+1)) + if err != nil { + return "", fmt.Errorf("%w: read body: %v", ErrChecksumFetchFailed, err) + } + if len(body) > sumsFileMaxSize { + return "", fmt.Errorf("%w: sums file exceeds %d byte limit", ErrChecksumFetchFailed, sumsFileMaxSize) + } + + digest, err := parseSumsFile(body, filename) + if err != nil { + return "", err + } + return digest, nil +} + +// parseSumsFile scans a coreutils-style sums file and returns the hex digest +// matching filename. Filename match is case-sensitive: upstream Debian/Ubuntu/ +// Alpine sums are consistently lowercase and a divergence is a real signal. +// +// Accepts three on-the-wire shapes: " " (text mode), " *" +// (binary mode), and a bare single-token "" line (single-file .sha512 from +// Alpine, which does not carry a filename). +func parseSumsFile(body []byte, filename string) (string, error) { + var bareDigest string + bareCount := 0 + scanner := bufio.NewScanner(bytes.NewReader(body)) + scanner.Buffer(make([]byte, 0, 64*1024), sumsFileMaxSize+1) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + switch len(fields) { + case 1: + bareDigest = fields[0] + bareCount++ + case 2: + name := strings.TrimPrefix(fields[1], "*") + if name == filename { + return fields[0], nil + } + default: + // More than two fields: not a format we recognise. Skip rather than + // reject outright — signed sums files occasionally have trailing + // commentary we want to tolerate. + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("%w: scan sums file: %v", ErrChecksumFetchFailed, err) + } + + // Alpine single-file .sha512 fallback: exactly one bare-digest line in the + // whole file. Any more and we refuse, because we can't tell which line is + // the right one without a filename. + if bareCount == 1 { + return bareDigest, nil + } + + return "", fmt.Errorf("%w: %s", ErrChecksumNotFound, filename) +} + +// hashImageFile streams the image through hasher and returns the digest as +// lowercase hex. Zero-byte files are rejected outright so a truncated +// download surfaces as an explicit error rather than an opaque "checksum +// mismatch". +func hashImageFile(imagePath string, hasher hash.Hash) (string, error) { + f, err := os.Open(imagePath) + if err != nil { + return "", err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return "", err + } + if stat.Size() == 0 { + return "", fmt.Errorf("image file %s is empty (likely a truncated or failed download)", imagePath) + } + + if _, err := io.Copy(hasher, f); err != nil { + return "", err + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} + type Images struct { Name string `json:"name"` Description string `json:"description"` diff --git a/spinifex/utils/utils.go b/spinifex/utils/utils.go index de05fa6b9..8683efb51 100644 --- a/spinifex/utils/utils.go +++ b/spinifex/utils/utils.go @@ -249,9 +249,7 @@ func DownloadFileWithProgress(url string, name string, filename string, timeout WithTotal(int(cl)). Start() - var written int64 reader := io.TeeReader(resp.Body, progressWriter(func(n int) { - written += int64(n) // Update progress bar with the number of bytes read in this chunk bar.Add(n) })) @@ -261,8 +259,6 @@ func DownloadFileWithProgress(url string, name string, filename string, timeout if err != nil { return fmt.Errorf("copy error: %v", err) } - - pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written))) return err } else { // Unknown size: spinner that shows bytes downloaded @@ -280,8 +276,6 @@ func DownloadFileWithProgress(url string, name string, filename string, timeout if err != nil { return fmt.Errorf("copy error: %v", err) } - - pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written))) } return nil diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index 30cfcf89a..9230a7774 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -1,12 +1,20 @@ package utils import ( + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/hex" "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" + "path/filepath" "runtime" "strings" "sync" + "sync/atomic" "syscall" "testing" "time" @@ -1005,3 +1013,264 @@ func TestHashMAC(t *testing.T) { // Different input → different output assert.NotEqual(t, mac, HashMAC("02:00:00", "other-id")) } + +// writeTempImage writes content to a file named name under t.TempDir() and +// returns the absolute path. +func writeTempImage(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(path, content, 0o600)) + return path +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func sha512Hex(b []byte) string { + sum := sha512.Sum512(b) + return hex.EncodeToString(sum[:]) +} + +// newSumsServer stands up a TLS test server returning body for any request, +// configures the package-level trust hook, and registers cleanup. Returns the +// server URL. +func newSumsServer(t *testing.T, body string) string { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + return srv.URL + "/SUMS" +} + +// trustTestServer adds srv's certificate to the package-level trust pool so +// VerifyImageChecksum's HTTPS-only client accepts it. Restores the previous +// pool on test cleanup. +func trustTestServer(t *testing.T, srv *httptest.Server) { + t.Helper() + prev := checksumExtraRootCAs + pool := x509.NewCertPool() + if prev != nil { + pool = prev.Clone() + } + pool.AddCert(srv.Certificate()) + checksumExtraRootCAs = pool + t.Cleanup(func() { checksumExtraRootCAs = prev }) +} + +func TestVerifyImageChecksum(t *testing.T) { + const debianName = "debian-12-generic-amd64.tar.xz" + const ubuntuName = "noble-server-cloudimg-amd64.img" + const alpineName = "alb-alpine-3.21.6-x86_64.raw" + + debianBytes := []byte("debian-image-bytes-fixture") + ubuntuBytes := []byte("ubuntu-image-bytes-fixture") + alpineBytes := []byte("alpine-image-bytes-fixture") + + t.Run("debian sha512 multi-line match", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("%s %s\n%s other-file\n", + sha512Hex(debianBytes), debianName, sha512Hex([]byte("unrelated"))) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + assert.NoError(t, err) + }) + + t.Run("ubuntu sha256 binary-mode asterisk match", func(t *testing.T) { + img := writeTempImage(t, ubuntuName, ubuntuBytes) + body := fmt.Sprintf("%s *%s\n", sha256Hex(ubuntuBytes), ubuntuName) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha256") + assert.NoError(t, err) + }) + + t.Run("alpine single-line named match", func(t *testing.T) { + img := writeTempImage(t, alpineName, alpineBytes) + body := fmt.Sprintf("%s %s\n", sha512Hex(alpineBytes), alpineName) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + assert.NoError(t, err) + }) + + t.Run("alpine bare-digest fallback", func(t *testing.T) { + img := writeTempImage(t, alpineName, alpineBytes) + body := sha512Hex(alpineBytes) + "\n" + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + assert.NoError(t, err) + }) + + t.Run("case-sensitive filename mismatch", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Capitalised "Debian-..." in sums file: legitimate divergence from upstream + // convention, treated as not-found. + body := fmt.Sprintf("%s Debian-12-generic-amd64.tar.xz\n", sha512Hex(debianBytes)) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) + + t.Run("comment lines skipped", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("# Hash file generated by upstream\n# PGP-signed below\n%s %s\n", + sha512Hex(debianBytes), debianName) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + assert.NoError(t, err) + }) + + t.Run("digest mismatch", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + wrong := sha512Hex([]byte("a different blob entirely")) + body := fmt.Sprintf("%s %s\n", wrong, debianName) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumMismatch) + // Both expected and actual digests should be in the rendered error so + // operators can compare without scraping slog. + assert.Contains(t, err.Error(), wrong) + assert.Contains(t, err.Error(), sha512Hex(debianBytes)) + }) + + t.Run("filename absent from sums file", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("%s some-other-file.tar.xz\n%s yet-another.iso\n", + sha512Hex([]byte("a")), sha512Hex([]byte("b"))) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + assert.ErrorIs(t, err, ErrChecksumNotFound) + }) + + t.Run("unsupported checksum type rejected before any fetch", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Server URL is gibberish — function must fail before reaching it. + err := VerifyImageChecksum(img, "https://example.invalid/SUMS", "md5") + assert.ErrorIs(t, err, ErrUnsupportedChecksumType) + }) + + t.Run("http 404 wraps fetch failure", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) + + t.Run("non-https initial url rejected before any request", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Hit a port nothing listens on — if HTTPS check ever regresses we'd + // still get an error, but we want ErrChecksumFetchFailed wrapping the + // scheme refusal specifically. + err := VerifyImageChecksum(img, "http://127.0.0.1:1/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.Contains(t, err.Error(), "non-https") + }) + + t.Run("non-https redirect refused", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Plain-HTTP target the TLS server will try to redirect us to. + plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "%s %s\n", sha512Hex(debianBytes), debianName) + })) + t.Cleanup(plain.Close) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, plain.URL+"/SUMS", http.StatusFound) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) + + t.Run("redirect cap exceeded", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + var hits atomic.Int32 + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + http.Redirect(w, r, r.URL.Path, http.StatusFound) + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.GreaterOrEqual(t, int(hits.Load()), 10) + }) + + t.Run("sums file size cap exceeded", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Body of 2 MB of unrelated entries — over the 1 MB cap. + var b strings.Builder + junk := strings.Repeat("a", 128) + for b.Len() <= sumsFileMaxSize+1 { + fmt.Fprintf(&b, "%s %s.bin\n", sha512Hex([]byte(junk)), junk) + } + url := newSumsServer(t, b.String()) + err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.Contains(t, err.Error(), "exceeds") + }) + + t.Run("context deadline exceeded wraps fetch failure", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + t.Cleanup(srv.Close) + trustTestServer(t, srv) + + prev := checksumFetchTimeout + checksumFetchTimeout = 100 * time.Millisecond + t.Cleanup(func() { checksumFetchTimeout = prev }) + + err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) + + t.Run("digest length mismatch rejected before hashing", func(t *testing.T) { + // sha256 digest served, caller declares sha512: algorithm disagreement, + // not tampering. Must be distinct from ErrChecksumMismatch. + img := writeTempImage(t, debianName, debianBytes) + body := fmt.Sprintf("%s %s\n", sha256Hex(debianBytes), debianName) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + assert.NotErrorIs(t, err, ErrChecksumMismatch) + assert.Contains(t, err.Error(), "digest length") + }) + + t.Run("empty image file rejected with clear error", func(t *testing.T) { + img := writeTempImage(t, debianName, nil) + body := fmt.Sprintf("%s %s\n", sha512Hex(nil), debianName) + url := newSumsServer(t, body) + err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.NotErrorIs(t, err, ErrChecksumMismatch) + assert.Contains(t, err.Error(), "empty") + }) + + t.Run("transport error wrapped", func(t *testing.T) { + img := writeTempImage(t, debianName, debianBytes) + // Stand up a TLS server, trust it, then close immediately so any + // subsequent request fails at the transport layer. + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + trustTestServer(t, srv) + url := srv.URL + "/SUMS" + srv.Close() + err := VerifyImageChecksum(img, url, "sha512") + require.Error(t, err) + assert.ErrorIs(t, err, ErrChecksumFetchFailed) + }) +}