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)
+ })
+}