feat: validate image checksums (#147)

This commit is contained in:
Julian Sommer
2026-04-16 17:00:27 +10:00
committed by GitHub
parent 6f02f6bc22
commit 084e93cfd0
6 changed files with 604 additions and 16 deletions
+33 -9
View File
@@ -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)
}
+34
View File
@@ -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")
}
}
+32 -1
View File
@@ -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<br>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)<br>2. Tampered cache hit fails with `ErrChecksumMismatch`<br>3. `--force` recovers after a mismatch<br>4. `--file` import skips verification<br>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 <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 <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
+236
View File
@@ -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: "<hex> <name>" (text mode), "<hex> *<name>"
// (binary mode), and a bare single-token "<hex>" 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"`
-6
View File
@@ -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
+269
View File
@@ -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)
})
}