mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
refactor: move image catalogue to operator
This commit is contained in:
+15
-14
@@ -36,6 +36,7 @@ import (
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/network/host"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/imagecatalog"
|
||||
operatorprogress "github.com/mulgadc/spinifex/spinifex/operator/progress"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
@@ -506,7 +507,7 @@ const catalogPinnedDigestSource = "catalog:pinned"
|
||||
// importSource is the file an import is about to extract, and what it is to be verified against.
|
||||
type importSource struct {
|
||||
imageFile, imageName, localFile string
|
||||
image utils.Images
|
||||
image imagecatalog.Images
|
||||
checksumPath, checksumAlgo, expectedDigest string
|
||||
skipVerify bool
|
||||
}
|
||||
@@ -525,7 +526,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD
|
||||
// A pinned digest is checked against this file rather than a sums file
|
||||
// fetched from the same host that served the image.
|
||||
if src.image.ChecksumDigest != "" {
|
||||
actual, err := utils.VerifyImageDigest(src.imageFile, src.image.ChecksumType, src.image.ChecksumDigest)
|
||||
actual, err := imagecatalog.VerifyImageDigest(src.imageFile, src.image.ChecksumType, src.image.ChecksumDigest)
|
||||
if err != nil {
|
||||
printChecksumError(errOut, src.imageFile, src.imageName, src.image, err)
|
||||
return digest, err
|
||||
@@ -535,7 +536,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD
|
||||
digest.Verification = ebsmetadata.DigestCatalog
|
||||
return digest, nil
|
||||
}
|
||||
actual, err := utils.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType)
|
||||
actual, err := imagecatalog.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType)
|
||||
if err != nil {
|
||||
printChecksumError(errOut, src.imageFile, src.imageName, src.image, err)
|
||||
return digest, err
|
||||
@@ -544,7 +545,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD
|
||||
digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, src.image.Checksum
|
||||
digest.Verification = ebsmetadata.DigestCatalog
|
||||
case ebsmetadata.DigestOperator:
|
||||
actual, err := utils.VerifyImageDigest(src.localFile, src.checksumAlgo, src.expectedDigest)
|
||||
actual, err := imagecatalog.VerifyImageDigest(src.localFile, src.checksumAlgo, src.expectedDigest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Image integrity verification failed: %v\n", err)
|
||||
fmt.Fprintf(errOut, " file: %s\n", src.localFile)
|
||||
@@ -560,7 +561,7 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "⚠️ --skip-verify set: checksum verification skipped for %s\n", src.imageName)
|
||||
}
|
||||
actual, err := utils.HashImageFile(src.imageFile, "sha256")
|
||||
actual, err := imagecatalog.HashImageFile(src.imageFile, "sha256")
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Could not hash image: %v\n", err)
|
||||
return digest, err
|
||||
@@ -584,7 +585,7 @@ func sourceDigestMode(localFile, checksumPath string, skipVerify bool) string {
|
||||
}
|
||||
|
||||
func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
var image utils.Images
|
||||
var image imagecatalog.Images
|
||||
|
||||
var imageFile string
|
||||
var imageStat os.FileInfo
|
||||
@@ -629,7 +630,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
var checksumAlgo, expectedDigest string
|
||||
if checksumPath != "" {
|
||||
checksumAlgo, expectedDigest, err = utils.ReadExpectedDigest(checksumPath, filepath.Base(localFile))
|
||||
checksumAlgo, expectedDigest, err = imagecatalog.ReadExpectedDigest(checksumPath, filepath.Base(localFile))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not read checksum from %s: %v\n", checksumPath, err)
|
||||
os.Exit(1)
|
||||
@@ -638,7 +639,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
|
||||
if imageName != "" {
|
||||
var exists bool
|
||||
image, exists = utils.AvailableImages[imageName]
|
||||
image, exists = imagecatalog.AvailableImages[imageName]
|
||||
if !exists {
|
||||
fmt.Fprintf(os.Stderr, "Image name not found in available images")
|
||||
os.Exit(1)
|
||||
@@ -763,7 +764,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath)
|
||||
extractedImagePath, err := imagecatalog.ExtractDiskImageFromFile(imageFile, imagePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not extract image: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -797,7 +798,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
SnapshotID: admin.SnapPrefix(volumeId),
|
||||
BootMode: image.BootMode,
|
||||
Distro: image.Distro,
|
||||
DistroFamily: utils.DistroFamily(image.Distro),
|
||||
DistroFamily: imagecatalog.DistroFamily(image.Distro),
|
||||
SourceDigest: &sourceDigest,
|
||||
}
|
||||
|
||||
@@ -1197,11 +1198,11 @@ func runimagesListCmd(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
// Sort A→Z then iterate.
|
||||
keys := slices.Sorted(maps.Keys(utils.AvailableImages))
|
||||
keys := slices.Sorted(maps.Keys(imagecatalog.AvailableImages))
|
||||
for _, k := range keys {
|
||||
img := utils.AvailableImages[k]
|
||||
img := imagecatalog.AvailableImages[k]
|
||||
|
||||
//for _, img := range utils.AvailableImages {
|
||||
//for _, img := range imagecatalog.AvailableImages {
|
||||
tableData = append(tableData, []string{img.Name, img.Distro, img.Version, img.Arch, img.BootMode})
|
||||
}
|
||||
|
||||
@@ -3948,7 +3949,7 @@ func resolveIfaceIP(iface string) string {
|
||||
// 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) {
|
||||
func printChecksumError(w io.Writer, imageFile, imageName string, image imagecatalog.Images, err error) {
|
||||
fmt.Fprintf(w, "Image integrity verification failed: %v\n", err)
|
||||
fmt.Fprintf(w, " file: %s\n", imageFile)
|
||||
if image.ChecksumDigest != "" {
|
||||
|
||||
@@ -20,9 +20,9 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
|
||||
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/imagecatalog"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/formation"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/host/dns"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
@@ -34,13 +34,13 @@ import (
|
||||
// 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"}
|
||||
image := imagecatalog.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"}
|
||||
const imageFile = "/var/lib/img.tar.xz"
|
||||
const imageName = "debian-13-x86_64"
|
||||
|
||||
errs := []error{
|
||||
fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch),
|
||||
fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed),
|
||||
fmt.Errorf("%w: expected abc got def", imagecatalog.ErrChecksumMismatch),
|
||||
fmt.Errorf("%w: 404", imagecatalog.ErrChecksumFetchFailed),
|
||||
errors.New("open /x: no such file"),
|
||||
}
|
||||
for _, e := range errs {
|
||||
@@ -1398,7 +1398,7 @@ func TestImportSourceResolveDigest(t *testing.T) {
|
||||
imageFile: img, localFile: img,
|
||||
checksumPath: sums, checksumAlgo: "sha256", expectedDigest: strings.Repeat("0", 64),
|
||||
}.resolveDigest(&out, &errOut)
|
||||
require.ErrorIs(t, err, utils.ErrChecksumMismatch)
|
||||
require.ErrorIs(t, err, imagecatalog.ErrChecksumMismatch)
|
||||
assert.Contains(t, errOut.String(), "file: "+img)
|
||||
assert.Contains(t, errOut.String(), "checksum: "+sums)
|
||||
assert.Contains(t, errOut.String(), imgHex)
|
||||
@@ -1438,7 +1438,7 @@ func TestImportSourceResolveDigest(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
got, err := importSource{
|
||||
imageFile: img, imageName: "oracle-10.1-x86_64",
|
||||
image: utils.Images{ChecksumType: "sha256", ChecksumDigest: imgHex},
|
||||
image: imagecatalog.Images{ChecksumType: "sha256", ChecksumDigest: imgHex},
|
||||
}.resolveDigest(&out, &errOut)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ebsmetadata.ImageDigest{
|
||||
@@ -1453,9 +1453,9 @@ func TestImportSourceResolveDigest(t *testing.T) {
|
||||
wrong := strings.Repeat("a", 64)
|
||||
_, err := importSource{
|
||||
imageFile: img, imageName: "oracle-10.1-x86_64",
|
||||
image: utils.Images{ChecksumType: "sha256", ChecksumDigest: wrong, URL: "https://example.invalid/ol.qcow2"},
|
||||
image: imagecatalog.Images{ChecksumType: "sha256", ChecksumDigest: wrong, URL: "https://example.invalid/ol.qcow2"},
|
||||
}.resolveDigest(&out, &errOut)
|
||||
require.ErrorIs(t, err, utils.ErrChecksumMismatch)
|
||||
require.ErrorIs(t, err, imagecatalog.ErrChecksumMismatch)
|
||||
assert.Contains(t, errOut.String(), catalogPinnedDigestSource)
|
||||
assert.Contains(t, errOut.String(), "https://example.invalid/ol.qcow2")
|
||||
assert.FileExists(t, img, "the downloaded file must be left in place")
|
||||
|
||||
@@ -878,7 +878,7 @@ ssh -i path/to/key.pem cloud-user@<public-ip>
|
||||
|
||||
### Why these four pin a checksum digest inline
|
||||
|
||||
Every other catalog entry verifies against the publisher's sums file. Oracle ships none — the digests are published as HTML on `yum.oracle.com/oracle-linux-templates.html` — so these four carry `ChecksumDigest` in `spinifex/utils/images.go` instead. That is safe **only** because each URL names an immutable build (`b291`, `b293`, `b178`, `b182`) and Oracle never moves one. A new point release is a new URL and a new digest, never an edit to an existing entry.
|
||||
Every other catalog entry verifies against the publisher's sums file. Oracle ships none — the digests are published as HTML on `yum.oracle.com/oracle-linux-templates.html` — so these four carry `ChecksumDigest` in `spinifex/operator/imagecatalog/images.go` instead. That is safe **only** because each URL names an immutable build (`b291`, `b293`, `b178`, `b182`) and Oracle never moves one. A new point release is a new URL and a new digest, never an edit to an existing entry.
|
||||
|
||||
On arm64 Oracle publishes a `-kvm-cloud-` build alongside a plain `-kvm-` one. The catalog takes `-kvm-cloud-`: it is the one with cloud-init, and so the one that matches x86_64's `-kvm-`. The plain arm64 build imports fine and then has no datasource, which surfaces as an instance with no key and no metadata rather than as an import error.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# Converts mkosi's raw output to a compressed qcow2, writes the .sha256 sidecar
|
||||
# in the coreutils format the spx catalog verifier matches, and uploads both to
|
||||
# the bucket behind iso.mulgadc.com/system-ami/. The object name must match the
|
||||
# basename of the catalog entry's URL in spinifex/utils/images.go.
|
||||
# basename of the catalog entry's URL in spinifex/operator/imagecatalog/images.go.
|
||||
#
|
||||
# Unlike the build, this needs no relaxed confinement: qemu-img and aws create
|
||||
# no namespaces, so the container keeps Docker's default seccomp and AppArmor
|
||||
|
||||
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
# qcow2, writes a .sha256 sidecar (coreutils format, matched by the spx catalog
|
||||
# checksum verifier), and uploads both to the R2 bucket that backs
|
||||
# iso.mulgadc.com/system-ami/. The matching catalog entry in
|
||||
# spinifex/spinifex/utils/images.go then lets operators run:
|
||||
# spinifex/spinifex/operator/imagecatalog/images.go then lets operators run:
|
||||
#
|
||||
# spx admin images import --name <catalog-key>
|
||||
#
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package utils
|
||||
package imagecatalog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -1,4 +1,4 @@
|
||||
package utils
|
||||
package imagecatalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -63,7 +63,7 @@ func TestWithheldImages_VLLMServingCarriesBedrockTags(t *testing.T) {
|
||||
// it decodes to something validateDiskImagePath's file(1) sniff already accepts elsewhere.
|
||||
func rawFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "tests", "unit-test-disk-image.raw"))
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "..", "tests", "unit-test-disk-image.raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("read raw fixture: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
package imagecatalog
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractDiskImageFromFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Log("Temp dir:", tmpDir)
|
||||
|
||||
// Sample .xz (fail)
|
||||
imagePath, err := ExtractDiskImageFromFile("/tmp/file.xz", tmpDir)
|
||||
|
||||
assert.Empty(t, imagePath, "Should be blank")
|
||||
assert.Error(t, err, "Should error")
|
||||
|
||||
// Sample incorrect image (fail)
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/ebs.json", tmpDir)
|
||||
|
||||
assert.Empty(t, imagePath, "Should be blank")
|
||||
assert.Error(t, err, "Should error")
|
||||
assert.ErrorContains(t, err, "unsupported filetype")
|
||||
|
||||
// Sample incorrect image (fail)
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image-bad.raw", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should be blank")
|
||||
assert.Error(t, err, "Should error")
|
||||
assert.ErrorContains(t, err, "no valid disk image found")
|
||||
|
||||
// Sample raw
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.raw", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
_, err = exec.LookPath("tar")
|
||||
|
||||
if err == nil {
|
||||
// Sample .tgz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image2.tgz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".img")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample .tar
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tar", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample .tar.gz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tar.gz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample xz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tar.xz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample tgz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../../tests/unit-test-disk-image.tgz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
} else {
|
||||
t.Skip("tar command not found, skipping archive extraction tests")
|
||||
}
|
||||
|
||||
//err = os.RemoveAll(tmpDir)
|
||||
//assert.NoError(t, err, "Could not remove temp dir")
|
||||
}
|
||||
|
||||
func TestExtractDiskImagePath_NoMatch(t *testing.T) {
|
||||
output := []byte("somefile.txt\nanotherfile.conf\n")
|
||||
diskimage, err := extractDiskImagePath(t.TempDir(), output)
|
||||
assert.Empty(t, diskimage)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestExtractDiskImagePath_EmptyOutput(t *testing.T) {
|
||||
diskimage, err := extractDiskImagePath(t.TempDir(), []byte{})
|
||||
assert.Empty(t, diskimage)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// 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-13-genericcloud-amd64-20260518-2482.tar.xz"
|
||||
const ubuntuName = "resolute-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)
|
||||
got, err := VerifyImageChecksum(img, url, "sha512")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha512Hex(debianBytes), got)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("rocky bsd-style gpg-armored sums match", func(t *testing.T) {
|
||||
const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2"
|
||||
rockyBytes := []byte("rocky-image-bytes-fixture")
|
||||
img := writeTempImage(t, rockyName, rockyBytes)
|
||||
// Captured shape of a real Rocky CHECKSUM file: GPG cleartext-signed
|
||||
// armor wrapping BSD-style "SHA256 (name) = hex" lines, followed by an
|
||||
// ASCII-armored signature block we must skip without misparsing.
|
||||
body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s
|
||||
SHA256 (%s) = %s
|
||||
SHA256 (Rocky-10.0-20250612.0-x86_64-dvd.iso) = %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
|
||||
nopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+/abcdefghijklmn
|
||||
=ABCD
|
||||
-----END PGP SIGNATURE-----
|
||||
`, sha256Hex([]byte("boot-iso")), rockyName, sha256Hex(rockyBytes), sha256Hex([]byte("dvd-iso")))
|
||||
url := newSumsServer(t, body)
|
||||
_, err := VerifyImageChecksum(img, url, "sha256")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("alma bsd-style gpg-armored sums match", func(t *testing.T) {
|
||||
const almaName = "AlmaLinux-10-GenericCloud-10.0-x86_64.qcow2"
|
||||
almaBytes := []byte("alma-image-bytes-fixture")
|
||||
img := writeTempImage(t, almaName, almaBytes)
|
||||
body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
SHA256 (%s) = %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iHUEABYKAB0WIQTm5n8/yqv7tQAaIQAaIQAFAmJabcdEFGhijklmnopqrstuvwxyz
|
||||
=EFGH
|
||||
-----END PGP SIGNATURE-----
|
||||
`, almaName, sha256Hex(almaBytes))
|
||||
url := newSumsServer(t, body)
|
||||
_, err := VerifyImageChecksum(img, url, "sha256")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("bsd-style filename absent", func(t *testing.T) {
|
||||
const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2"
|
||||
rockyBytes := []byte("rocky-image-bytes-fixture")
|
||||
img := writeTempImage(t, rockyName, rockyBytes)
|
||||
body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
|
||||
=ABCD
|
||||
-----END PGP SIGNATURE-----
|
||||
`, sha256Hex([]byte("boot-iso")))
|
||||
url := newSumsServer(t, body)
|
||||
_, err := VerifyImageChecksum(img, url, "sha256")
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSumsFile(t *testing.T) {
|
||||
const target = "Rocky-10.0-20250612.0.x86_64.qcow2"
|
||||
const targetHex = "a3f1c2d4e5f60718293a4b5c6d7e8f9012345678901234567890abcdefabcdef"
|
||||
|
||||
t.Run("bsd 4-field line", func(t *testing.T) {
|
||||
body := []byte("SHA256 (" + target + ") = " + targetHex + "\n")
|
||||
got, err := parseSumsFile(body, target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, targetHex, got)
|
||||
})
|
||||
|
||||
t.Run("bsd missing equals separator skipped", func(t *testing.T) {
|
||||
// Malformed line — third field is not "=". Must not match.
|
||||
body := []byte("SHA256 (" + target + ") - " + targetHex + "\n")
|
||||
_, err := parseSumsFile(body, target)
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
|
||||
t.Run("bsd missing parens skipped", func(t *testing.T) {
|
||||
// Without parens the filename field is ambiguous; reject by not matching.
|
||||
body := []byte("SHA256 " + target + " = " + targetHex + "\n")
|
||||
_, err := parseSumsFile(body, target)
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
|
||||
t.Run("gpg-armored body with no sums returns not found", func(t *testing.T) {
|
||||
// PGP signatures always have ≥ 2 bare-token lines (base64 + =CRC), so bareCount stays ≥ 2 and NotFound is returned.
|
||||
body := []byte(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
|
||||
=ABCD
|
||||
-----END PGP SIGNATURE-----
|
||||
`)
|
||||
_, err := parseSumsFile(body, target)
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
|
||||
t.Run("mixed bsd and coreutils shapes", func(t *testing.T) {
|
||||
// Defensive: a sums file with both shapes should still resolve the
|
||||
// requested filename regardless of which line carries it.
|
||||
body := []byte("# header\n" +
|
||||
targetHex + " some-other.iso\n" +
|
||||
"SHA256 (" + target + ") = " + targetHex + "\n")
|
||||
got, err := parseSumsFile(body, target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, targetHex, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadExpectedDigest(t *testing.T) {
|
||||
const image = "debian-13-generic-amd64.tar.xz"
|
||||
imgBytes := []byte("local-image-bytes-fixture")
|
||||
h256, h512 := sha256Hex(imgBytes), sha512Hex(imgBytes)
|
||||
other := sha512Hex([]byte("other"))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sumsName string
|
||||
body string
|
||||
imageName string
|
||||
dir bool
|
||||
missing bool
|
||||
wantAlgo string
|
||||
wantDigest string
|
||||
wantErr error
|
||||
wantErrMsg string
|
||||
}{
|
||||
{name: "coreutils text sha512", sumsName: "SHA512SUMS",
|
||||
body: h512 + " " + image + "\n" + other + " other.iso\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "coreutils binary sha256", sumsName: "SHA256SUMS",
|
||||
body: h256 + " *" + image + "\n", wantAlgo: "sha256", wantDigest: h256},
|
||||
{name: "bsd sha256", sumsName: "CHECKSUM",
|
||||
body: "SHA256 (" + image + ") = " + h256 + "\n", wantAlgo: "sha256", wantDigest: h256},
|
||||
{name: "bare sha512", sumsName: image + ".sha512",
|
||||
body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "neutral sums name uses length", sumsName: "checksums.txt",
|
||||
body: h256 + " " + image + "\n", wantAlgo: "sha256", wantDigest: h256},
|
||||
{name: "uppercase hex normalised", sumsName: "SHA512SUMS",
|
||||
body: strings.ToUpper(h512) + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "sums file name does not decide the algorithm", sumsName: "SHA256SUMS",
|
||||
body: h512 + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "renamed image in multi-entry file", sumsName: "SHA512SUMS", imageName: "renamed.tar.xz",
|
||||
body: h512 + " " + image + "\n" + other + " other.iso\n", wantErr: ErrChecksumNotFound, wantErrMsg: "renamed.tar.xz"},
|
||||
{name: "path-prefixed entry names the prefix", sumsName: "image.sha512",
|
||||
body: h512 + " ./" + image + "\n", wantErr: ErrChecksumNotFound, wantErrMsg: "directory prefix"},
|
||||
{name: "renamed image with bare digest", sumsName: "image.sha512", imageName: "renamed.tar.xz",
|
||||
body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "sha1 length unsupported", sumsName: "checksums.txt",
|
||||
body: strings.Repeat("a", 40) + " " + image + "\n", wantErr: ErrUnsupportedChecksumType},
|
||||
{name: "non-hex digest", sumsName: "checksums.txt",
|
||||
body: strings.Repeat("g", 64) + " " + image + "\n", wantErr: ErrMalformedChecksum},
|
||||
{name: "oversized file", sumsName: "SHA512SUMS",
|
||||
body: h512 + " " + image + "\n" + strings.Repeat("#", sumsFileMaxSize), wantErr: ErrMalformedChecksum},
|
||||
{name: "directory refused", sumsName: "SHA512SUMS", dir: true, wantErr: ErrMalformedChecksum},
|
||||
{name: "missing file refused", sumsName: "SHA512SUMS", missing: true, wantErr: os.ErrNotExist},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), tc.sumsName)
|
||||
switch {
|
||||
case tc.dir:
|
||||
require.NoError(t, os.Mkdir(path, 0o755))
|
||||
case !tc.missing:
|
||||
require.NoError(t, os.WriteFile(path, []byte(tc.body), 0o600))
|
||||
}
|
||||
imageName := tc.imageName
|
||||
if imageName == "" {
|
||||
imageName = image
|
||||
}
|
||||
|
||||
algo, digest, err := ReadExpectedDigest(path, imageName)
|
||||
if tc.wantErr != nil {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
assert.Contains(t, err.Error(), tc.wantErrMsg)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantAlgo, algo)
|
||||
assert.Equal(t, tc.wantDigest, digest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyImageDigest(t *testing.T) {
|
||||
imgBytes := []byte("verify-digest-fixture")
|
||||
img := writeTempImage(t, "image.raw", imgBytes)
|
||||
|
||||
t.Run("sha256 match returns digest", func(t *testing.T) {
|
||||
got, err := VerifyImageDigest(img, "sha256", sha256Hex(imgBytes))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha256Hex(imgBytes), got)
|
||||
})
|
||||
|
||||
t.Run("sha512 match returns digest", func(t *testing.T) {
|
||||
got, err := VerifyImageDigest(img, "sha512", sha512Hex(imgBytes))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha512Hex(imgBytes), got)
|
||||
})
|
||||
|
||||
t.Run("mismatch names both digests", func(t *testing.T) {
|
||||
wrong := sha512Hex([]byte("tampered"))
|
||||
got, err := VerifyImageDigest(img, "sha512", wrong)
|
||||
require.ErrorIs(t, err, ErrChecksumMismatch)
|
||||
assert.Equal(t, sha512Hex(imgBytes), got)
|
||||
assert.Contains(t, err.Error(), wrong)
|
||||
assert.Contains(t, err.Error(), sha512Hex(imgBytes))
|
||||
})
|
||||
|
||||
t.Run("unsupported algorithm", func(t *testing.T) {
|
||||
_, err := VerifyImageDigest(img, "md5", "abc")
|
||||
assert.ErrorIs(t, err, ErrUnsupportedChecksumType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHashImageFile(t *testing.T) {
|
||||
imgBytes := []byte("hash-image-fixture")
|
||||
img := writeTempImage(t, "image.raw", imgBytes)
|
||||
|
||||
got, err := HashImageFile(img, "sha256")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha256Hex(imgBytes), got)
|
||||
|
||||
_, err = HashImageFile(writeTempImage(t, "empty.raw", nil), "sha256")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty")
|
||||
}
|
||||
|
||||
func TestAvailableImages_Rocky(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arch string
|
||||
filenameSub string
|
||||
}{
|
||||
{"rocky-10-x86_64", "x86_64", "x86_64.qcow2"},
|
||||
{"rocky-10-arm64", "arm64", "aarch64.qcow2"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
img, ok := AvailableImages[tc.name]
|
||||
require.True(t, ok, "catalog must contain %q", tc.name)
|
||||
assert.Equal(t, tc.name, img.Name)
|
||||
assert.Equal(t, "rocky", img.Distro)
|
||||
assert.Equal(t, "10", img.Version)
|
||||
assert.Equal(t, tc.arch, img.Arch)
|
||||
// Rocky 10 cloud images are UEFI-only on both architectures.
|
||||
assert.Equal(t, "uefi", img.BootMode)
|
||||
assert.Equal(t, "sha256", img.ChecksumType)
|
||||
// URL must track the moving .latest. alias — Rocky prunes dated
|
||||
// builds, so a pinned dated URL 404s once it ages out. The checksum
|
||||
// URL must be the BSD-style .CHECKSUM companion of that same alias.
|
||||
assert.Contains(t, img.URL, ".latest.")
|
||||
assert.Contains(t, img.URL, tc.filenameSub)
|
||||
assert.True(t, strings.HasSuffix(img.Checksum, ".CHECKSUM"),
|
||||
"Rocky publishes BSD-style .CHECKSUM files; got %q", img.Checksum)
|
||||
// Catalog Distro must resolve to the rhel family — typo here would
|
||||
// silently render Rocky guests with netplan/sudo group.
|
||||
assert.Equal(t, "rhel", DistroFamily(img.Distro))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistroFamily(t *testing.T) {
|
||||
cases := []struct {
|
||||
distro string
|
||||
want string
|
||||
}{
|
||||
{"debian", "debian"},
|
||||
{"ubuntu", "debian"},
|
||||
{"rocky", "rhel"},
|
||||
{"rhel", "rhel"},
|
||||
{"alma", "rhel"},
|
||||
{"fedora", "rhel"},
|
||||
{"centos", "rhel"},
|
||||
{"alpine", "alpine"},
|
||||
// Case + whitespace normalisation
|
||||
{" Rocky ", "rhel"},
|
||||
{"UBUNTU", "debian"},
|
||||
// Unknown and empty fall through to debian with a warning logged.
|
||||
{"", "debian"},
|
||||
{"plan9", "debian"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.distro, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, DistroFamily(tc.distro))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableImages_ECSNodeEntry(t *testing.T) {
|
||||
img, ok := AvailableImages["spinifex-ecs-node"]
|
||||
require.True(t, ok, "spinifex-ecs-node must be in the system image catalog")
|
||||
assert.Equal(t, "ecs", img.Tags["spinifex:managed-by"],
|
||||
"ECS node image must carry the managed-by=ecs tag the UI guard resolves on")
|
||||
}
|
||||
|
||||
func TestAvailableImages_RDSPostgresEntry(t *testing.T) {
|
||||
img, ok := AvailableImages["spinifex-rds-postgres"]
|
||||
require.True(t, ok, "spinifex-rds-postgres must be in the system image catalog")
|
||||
assert.Equal(t, "rds", img.Tags["spinifex:managed-by"],
|
||||
"RDS image must carry the managed-by=rds tag instance launches resolve on")
|
||||
assert.Equal(t, "postgres", img.Tags["engine"])
|
||||
assert.Equal(t, "18", img.Tags["engine-version"],
|
||||
"the pinned PostgreSQL major version is what EngineVersion resolves against")
|
||||
assert.Equal(t, "format-auth-v1", img.Tags["rds-data-volume-contract"],
|
||||
"RDS launches must exclude images that still discover generic data disks")
|
||||
}
|
||||
@@ -1,23 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateResourceID(t *testing.T) {
|
||||
@@ -201,675 +188,3 @@ func TestValidateErrorPayload(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test file extraction process
|
||||
|
||||
func TestExtractDiskImageFromFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Log("Temp dir:", tmpDir)
|
||||
|
||||
// Sample .xz (fail)
|
||||
imagePath, err := ExtractDiskImageFromFile("/tmp/file.xz", tmpDir)
|
||||
|
||||
assert.Empty(t, imagePath, "Should be blank")
|
||||
assert.Error(t, err, "Should error")
|
||||
|
||||
// Sample incorrect image (fail)
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/ebs.json", tmpDir)
|
||||
|
||||
assert.Empty(t, imagePath, "Should be blank")
|
||||
assert.Error(t, err, "Should error")
|
||||
assert.ErrorContains(t, err, "unsupported filetype")
|
||||
|
||||
// Sample incorrect image (fail)
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image-bad.raw", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should be blank")
|
||||
assert.Error(t, err, "Should error")
|
||||
assert.ErrorContains(t, err, "no valid disk image found")
|
||||
|
||||
// Sample raw
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.raw", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
_, err = exec.LookPath("tar")
|
||||
|
||||
if err == nil {
|
||||
// Sample .tgz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image2.tgz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".img")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample .tar
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tar", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample .tar.gz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tar.gz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample xz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tar.xz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
|
||||
// Sample tgz
|
||||
imagePath, err = ExtractDiskImageFromFile("../../tests/unit-test-disk-image.tgz", tmpDir)
|
||||
|
||||
assert.NotEmpty(t, imagePath, "Should not be blank")
|
||||
assert.Contains(t, imagePath, ".raw")
|
||||
|
||||
assert.NoError(t, err, "Should not error")
|
||||
} else {
|
||||
t.Skip("tar command not found, skipping archive extraction tests")
|
||||
}
|
||||
|
||||
//err = os.RemoveAll(tmpDir)
|
||||
//assert.NoError(t, err, "Could not remove temp dir")
|
||||
}
|
||||
|
||||
func TestExtractDiskImagePath_NoMatch(t *testing.T) {
|
||||
output := []byte("somefile.txt\nanotherfile.conf\n")
|
||||
diskimage, err := extractDiskImagePath(t.TempDir(), output)
|
||||
assert.Empty(t, diskimage)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestExtractDiskImagePath_EmptyOutput(t *testing.T) {
|
||||
diskimage, err := extractDiskImagePath(t.TempDir(), []byte{})
|
||||
assert.Empty(t, diskimage)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// 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-13-genericcloud-amd64-20260518-2482.tar.xz"
|
||||
const ubuntuName = "resolute-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)
|
||||
got, err := VerifyImageChecksum(img, url, "sha512")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha512Hex(debianBytes), got)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("rocky bsd-style gpg-armored sums match", func(t *testing.T) {
|
||||
const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2"
|
||||
rockyBytes := []byte("rocky-image-bytes-fixture")
|
||||
img := writeTempImage(t, rockyName, rockyBytes)
|
||||
// Captured shape of a real Rocky CHECKSUM file: GPG cleartext-signed
|
||||
// armor wrapping BSD-style "SHA256 (name) = hex" lines, followed by an
|
||||
// ASCII-armored signature block we must skip without misparsing.
|
||||
body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s
|
||||
SHA256 (%s) = %s
|
||||
SHA256 (Rocky-10.0-20250612.0-x86_64-dvd.iso) = %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
|
||||
nopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+/abcdefghijklmn
|
||||
=ABCD
|
||||
-----END PGP SIGNATURE-----
|
||||
`, sha256Hex([]byte("boot-iso")), rockyName, sha256Hex(rockyBytes), sha256Hex([]byte("dvd-iso")))
|
||||
url := newSumsServer(t, body)
|
||||
_, err := VerifyImageChecksum(img, url, "sha256")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("alma bsd-style gpg-armored sums match", func(t *testing.T) {
|
||||
const almaName = "AlmaLinux-10-GenericCloud-10.0-x86_64.qcow2"
|
||||
almaBytes := []byte("alma-image-bytes-fixture")
|
||||
img := writeTempImage(t, almaName, almaBytes)
|
||||
body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
SHA256 (%s) = %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iHUEABYKAB0WIQTm5n8/yqv7tQAaIQAaIQAFAmJabcdEFGhijklmnopqrstuvwxyz
|
||||
=EFGH
|
||||
-----END PGP SIGNATURE-----
|
||||
`, almaName, sha256Hex(almaBytes))
|
||||
url := newSumsServer(t, body)
|
||||
_, err := VerifyImageChecksum(img, url, "sha256")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("bsd-style filename absent", func(t *testing.T) {
|
||||
const rockyName = "Rocky-10.0-20250612.0.x86_64.qcow2"
|
||||
rockyBytes := []byte("rocky-image-bytes-fixture")
|
||||
img := writeTempImage(t, rockyName, rockyBytes)
|
||||
body := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
SHA256 (Rocky-10.0-20250612.0-x86_64-boot.iso) = %s
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
|
||||
=ABCD
|
||||
-----END PGP SIGNATURE-----
|
||||
`, sha256Hex([]byte("boot-iso")))
|
||||
url := newSumsServer(t, body)
|
||||
_, err := VerifyImageChecksum(img, url, "sha256")
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSumsFile(t *testing.T) {
|
||||
const target = "Rocky-10.0-20250612.0.x86_64.qcow2"
|
||||
const targetHex = "a3f1c2d4e5f60718293a4b5c6d7e8f9012345678901234567890abcdefabcdef"
|
||||
|
||||
t.Run("bsd 4-field line", func(t *testing.T) {
|
||||
body := []byte("SHA256 (" + target + ") = " + targetHex + "\n")
|
||||
got, err := parseSumsFile(body, target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, targetHex, got)
|
||||
})
|
||||
|
||||
t.Run("bsd missing equals separator skipped", func(t *testing.T) {
|
||||
// Malformed line — third field is not "=". Must not match.
|
||||
body := []byte("SHA256 (" + target + ") - " + targetHex + "\n")
|
||||
_, err := parseSumsFile(body, target)
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
|
||||
t.Run("bsd missing parens skipped", func(t *testing.T) {
|
||||
// Without parens the filename field is ambiguous; reject by not matching.
|
||||
body := []byte("SHA256 " + target + " = " + targetHex + "\n")
|
||||
_, err := parseSumsFile(body, target)
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
|
||||
t.Run("gpg-armored body with no sums returns not found", func(t *testing.T) {
|
||||
// PGP signatures always have ≥ 2 bare-token lines (base64 + =CRC), so bareCount stays ≥ 2 and NotFound is returned.
|
||||
body := []byte(`-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA256
|
||||
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
|
||||
=ABCD
|
||||
-----END PGP SIGNATURE-----
|
||||
`)
|
||||
_, err := parseSumsFile(body, target)
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
|
||||
t.Run("mixed bsd and coreutils shapes", func(t *testing.T) {
|
||||
// Defensive: a sums file with both shapes should still resolve the
|
||||
// requested filename regardless of which line carries it.
|
||||
body := []byte("# header\n" +
|
||||
targetHex + " some-other.iso\n" +
|
||||
"SHA256 (" + target + ") = " + targetHex + "\n")
|
||||
got, err := parseSumsFile(body, target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, targetHex, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadExpectedDigest(t *testing.T) {
|
||||
const image = "debian-13-generic-amd64.tar.xz"
|
||||
imgBytes := []byte("local-image-bytes-fixture")
|
||||
h256, h512 := sha256Hex(imgBytes), sha512Hex(imgBytes)
|
||||
other := sha512Hex([]byte("other"))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sumsName string
|
||||
body string
|
||||
imageName string
|
||||
dir bool
|
||||
missing bool
|
||||
wantAlgo string
|
||||
wantDigest string
|
||||
wantErr error
|
||||
wantErrMsg string
|
||||
}{
|
||||
{name: "coreutils text sha512", sumsName: "SHA512SUMS",
|
||||
body: h512 + " " + image + "\n" + other + " other.iso\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "coreutils binary sha256", sumsName: "SHA256SUMS",
|
||||
body: h256 + " *" + image + "\n", wantAlgo: "sha256", wantDigest: h256},
|
||||
{name: "bsd sha256", sumsName: "CHECKSUM",
|
||||
body: "SHA256 (" + image + ") = " + h256 + "\n", wantAlgo: "sha256", wantDigest: h256},
|
||||
{name: "bare sha512", sumsName: image + ".sha512",
|
||||
body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "neutral sums name uses length", sumsName: "checksums.txt",
|
||||
body: h256 + " " + image + "\n", wantAlgo: "sha256", wantDigest: h256},
|
||||
{name: "uppercase hex normalised", sumsName: "SHA512SUMS",
|
||||
body: strings.ToUpper(h512) + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "sums file name does not decide the algorithm", sumsName: "SHA256SUMS",
|
||||
body: h512 + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "renamed image in multi-entry file", sumsName: "SHA512SUMS", imageName: "renamed.tar.xz",
|
||||
body: h512 + " " + image + "\n" + other + " other.iso\n", wantErr: ErrChecksumNotFound, wantErrMsg: "renamed.tar.xz"},
|
||||
{name: "path-prefixed entry names the prefix", sumsName: "image.sha512",
|
||||
body: h512 + " ./" + image + "\n", wantErr: ErrChecksumNotFound, wantErrMsg: "directory prefix"},
|
||||
{name: "renamed image with bare digest", sumsName: "image.sha512", imageName: "renamed.tar.xz",
|
||||
body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512},
|
||||
{name: "sha1 length unsupported", sumsName: "checksums.txt",
|
||||
body: strings.Repeat("a", 40) + " " + image + "\n", wantErr: ErrUnsupportedChecksumType},
|
||||
{name: "non-hex digest", sumsName: "checksums.txt",
|
||||
body: strings.Repeat("g", 64) + " " + image + "\n", wantErr: ErrMalformedChecksum},
|
||||
{name: "oversized file", sumsName: "SHA512SUMS",
|
||||
body: h512 + " " + image + "\n" + strings.Repeat("#", sumsFileMaxSize), wantErr: ErrMalformedChecksum},
|
||||
{name: "directory refused", sumsName: "SHA512SUMS", dir: true, wantErr: ErrMalformedChecksum},
|
||||
{name: "missing file refused", sumsName: "SHA512SUMS", missing: true, wantErr: os.ErrNotExist},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), tc.sumsName)
|
||||
switch {
|
||||
case tc.dir:
|
||||
require.NoError(t, os.Mkdir(path, 0o755))
|
||||
case !tc.missing:
|
||||
require.NoError(t, os.WriteFile(path, []byte(tc.body), 0o600))
|
||||
}
|
||||
imageName := tc.imageName
|
||||
if imageName == "" {
|
||||
imageName = image
|
||||
}
|
||||
|
||||
algo, digest, err := ReadExpectedDigest(path, imageName)
|
||||
if tc.wantErr != nil {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
assert.Contains(t, err.Error(), tc.wantErrMsg)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantAlgo, algo)
|
||||
assert.Equal(t, tc.wantDigest, digest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyImageDigest(t *testing.T) {
|
||||
imgBytes := []byte("verify-digest-fixture")
|
||||
img := writeTempImage(t, "image.raw", imgBytes)
|
||||
|
||||
t.Run("sha256 match returns digest", func(t *testing.T) {
|
||||
got, err := VerifyImageDigest(img, "sha256", sha256Hex(imgBytes))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha256Hex(imgBytes), got)
|
||||
})
|
||||
|
||||
t.Run("sha512 match returns digest", func(t *testing.T) {
|
||||
got, err := VerifyImageDigest(img, "sha512", sha512Hex(imgBytes))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha512Hex(imgBytes), got)
|
||||
})
|
||||
|
||||
t.Run("mismatch names both digests", func(t *testing.T) {
|
||||
wrong := sha512Hex([]byte("tampered"))
|
||||
got, err := VerifyImageDigest(img, "sha512", wrong)
|
||||
require.ErrorIs(t, err, ErrChecksumMismatch)
|
||||
assert.Equal(t, sha512Hex(imgBytes), got)
|
||||
assert.Contains(t, err.Error(), wrong)
|
||||
assert.Contains(t, err.Error(), sha512Hex(imgBytes))
|
||||
})
|
||||
|
||||
t.Run("unsupported algorithm", func(t *testing.T) {
|
||||
_, err := VerifyImageDigest(img, "md5", "abc")
|
||||
assert.ErrorIs(t, err, ErrUnsupportedChecksumType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHashImageFile(t *testing.T) {
|
||||
imgBytes := []byte("hash-image-fixture")
|
||||
img := writeTempImage(t, "image.raw", imgBytes)
|
||||
|
||||
got, err := HashImageFile(img, "sha256")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, sha256Hex(imgBytes), got)
|
||||
|
||||
_, err = HashImageFile(writeTempImage(t, "empty.raw", nil), "sha256")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty")
|
||||
}
|
||||
|
||||
func TestAvailableImages_Rocky(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arch string
|
||||
filenameSub string
|
||||
}{
|
||||
{"rocky-10-x86_64", "x86_64", "x86_64.qcow2"},
|
||||
{"rocky-10-arm64", "arm64", "aarch64.qcow2"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
img, ok := AvailableImages[tc.name]
|
||||
require.True(t, ok, "catalog must contain %q", tc.name)
|
||||
assert.Equal(t, tc.name, img.Name)
|
||||
assert.Equal(t, "rocky", img.Distro)
|
||||
assert.Equal(t, "10", img.Version)
|
||||
assert.Equal(t, tc.arch, img.Arch)
|
||||
// Rocky 10 cloud images are UEFI-only on both architectures.
|
||||
assert.Equal(t, "uefi", img.BootMode)
|
||||
assert.Equal(t, "sha256", img.ChecksumType)
|
||||
// URL must track the moving .latest. alias — Rocky prunes dated
|
||||
// builds, so a pinned dated URL 404s once it ages out. The checksum
|
||||
// URL must be the BSD-style .CHECKSUM companion of that same alias.
|
||||
assert.Contains(t, img.URL, ".latest.")
|
||||
assert.Contains(t, img.URL, tc.filenameSub)
|
||||
assert.True(t, strings.HasSuffix(img.Checksum, ".CHECKSUM"),
|
||||
"Rocky publishes BSD-style .CHECKSUM files; got %q", img.Checksum)
|
||||
// Catalog Distro must resolve to the rhel family — typo here would
|
||||
// silently render Rocky guests with netplan/sudo group.
|
||||
assert.Equal(t, "rhel", DistroFamily(img.Distro))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistroFamily(t *testing.T) {
|
||||
cases := []struct {
|
||||
distro string
|
||||
want string
|
||||
}{
|
||||
{"debian", "debian"},
|
||||
{"ubuntu", "debian"},
|
||||
{"rocky", "rhel"},
|
||||
{"rhel", "rhel"},
|
||||
{"alma", "rhel"},
|
||||
{"fedora", "rhel"},
|
||||
{"centos", "rhel"},
|
||||
{"alpine", "alpine"},
|
||||
// Case + whitespace normalisation
|
||||
{" Rocky ", "rhel"},
|
||||
{"UBUNTU", "debian"},
|
||||
// Unknown and empty fall through to debian with a warning logged.
|
||||
{"", "debian"},
|
||||
{"plan9", "debian"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.distro, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, DistroFamily(tc.distro))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableImages_ECSNodeEntry(t *testing.T) {
|
||||
img, ok := AvailableImages["spinifex-ecs-node"]
|
||||
require.True(t, ok, "spinifex-ecs-node must be in the system image catalog")
|
||||
assert.Equal(t, "ecs", img.Tags["spinifex:managed-by"],
|
||||
"ECS node image must carry the managed-by=ecs tag the UI guard resolves on")
|
||||
}
|
||||
|
||||
func TestAvailableImages_RDSPostgresEntry(t *testing.T) {
|
||||
img, ok := AvailableImages["spinifex-rds-postgres"]
|
||||
require.True(t, ok, "spinifex-rds-postgres must be in the system image catalog")
|
||||
assert.Equal(t, "rds", img.Tags["spinifex:managed-by"],
|
||||
"RDS image must carry the managed-by=rds tag instance launches resolve on")
|
||||
assert.Equal(t, "postgres", img.Tags["engine"])
|
||||
assert.Equal(t, "18", img.Tags["engine-version"],
|
||||
"the pinned PostgreSQL major version is what EngineVersion resolves against")
|
||||
assert.Equal(t, "format-auth-v1", img.Tags["rds-data-volume-contract"],
|
||||
"RDS launches must exclude images that still discover generic data disks")
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const (
|
||||
eksGPUClusterPfx = "eks-gpu-e2e"
|
||||
|
||||
// eksNodeAMIName / eksNodeGPUAMIName are the registered EKS node AMI
|
||||
// names (see spinifex/spinifex/utils/images.go). Both must be imported —
|
||||
// names (see spinifex/spinifex/operator/imagecatalog/images.go). Both must be imported —
|
||||
// the base image for control-plane parity checks, the GPU variant for
|
||||
// resolveWorkerAMI's GPU branch.
|
||||
eksNodeAMIName = "spinifex-eks-node"
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/imagecatalog"
|
||||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -35,7 +35,7 @@ const catalogHeadTimeout = 30 * time.Second
|
||||
// and the sooner it is seen the smaller the window in which a released binary
|
||||
// points at a dead URL.
|
||||
//
|
||||
// utils.WithheldImages is deliberately not walked: those are the entries whose
|
||||
// imagecatalog.WithheldImages is deliberately not walked: those are the entries whose
|
||||
// artifact is known to be unpublished, so HEADing them would assert a failure
|
||||
// we already know about and hide the ones we do not.
|
||||
func runImageCatalogReachable(t *testing.T, _ *Fixture) {
|
||||
@@ -57,8 +57,8 @@ func runImageCatalogReachable(t *testing.T, _ *Fixture) {
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}},
|
||||
}
|
||||
|
||||
for _, name := range slices.Sorted(maps.Keys(utils.AvailableImages)) {
|
||||
img := utils.AvailableImages[name]
|
||||
for _, name := range slices.Sorted(maps.Keys(imagecatalog.AvailableImages)) {
|
||||
img := imagecatalog.AvailableImages[name]
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user