feat: verify local image imports with --checksum and record AMI source digests (#1102)

This commit is contained in:
Julian Sommer
2026-09-23 15:15:23 +10:00
committed by GitHub
parent 85dd1dcd75
commit 28963e28af
15 changed files with 720 additions and 68 deletions
+187 -20
View File
@@ -200,6 +200,15 @@ takes effect immediately. Prompts for confirmation unless --yes is passed.`,
Run: runimagesPromoteCmd,
}
var imagesDescribeCmd = &cobra.Command{
Use: "describe",
Short: "Show an AMI's metadata and source digest",
Long: `Print an AMI's control-plane metadata, including the digest of the
artifact it was imported from and who verified it. The digest describes the
imported source file, not the volume's current contents.`,
Run: runimagesDescribeCmd,
}
var volumesCmd = &cobra.Command{
Use: "volumes",
Short: "Inspect block storage volumes",
@@ -294,7 +303,7 @@ spx admin images import --name debian-13-x86_64
spx admin images list
// Manually import a path
spx admin images import --file /path/to/image --distro debian --version 13 --arch x86_64
spx admin images import --file /path/to/image --checksum /path/to/SHA512SUMS --distro debian --version 13 --arch x86_64
-> x <-
*/
@@ -328,6 +337,7 @@ func init() {
imagesCmd.AddCommand(imagesListCmd)
imagesCmd.AddCommand(imagesRemoveCmd)
imagesCmd.AddCommand(imagesPromoteCmd)
imagesCmd.AddCommand(imagesDescribeCmd)
adminCmd.AddCommand(volumesCmd)
volumesCmd.AddCommand(volumesOrphansCmd)
@@ -427,6 +437,7 @@ func init() {
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)")
imagesImportCmd.Flags().String("checksum", "", "Sums file to verify a --file image against (SHA256SUMS, SHA512SUMS, <image>.sha256/.sha512)")
imagesRemoveCmd.Flags().String("image-id", "", "AMI ID to remove (required)")
imagesRemoveCmd.Flags().Bool("force", false, "Bypass dependency, ownership and config-corrupt checks (salvage mode)")
@@ -436,6 +447,9 @@ func init() {
imagesPromoteCmd.Flags().String("image-id", "", "AMI ID to promote to system image (required)")
imagesPromoteCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt")
_ = imagesPromoteCmd.MarkFlagRequired("image-id")
imagesDescribeCmd.Flags().String("image-id", "", "AMI ID to describe (required)")
_ = imagesDescribeCmd.MarkFlagRequired("image-id")
}
const bytesPerGiB = 1024 * 1024 * 1024
@@ -464,6 +478,91 @@ func amiVolumeSizeGiB(sizeBytes int64) uint64 {
return safecast.Int64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
}
// validateChecksumFlags rejects --checksum combinations that would otherwise be silently ignored.
// An explicitly empty --checksum (e.g. an unset shell variable) must not fall through to an unverified import.
func validateChecksumFlags(checksumPath string, checksumSet bool, localFile string, skipVerify bool) error {
if !checksumSet {
return nil
}
if strings.TrimSpace(checksumPath) == "" {
return errors.New("--checksum requires a sums file path")
}
if localFile == "" {
return errors.New("--checksum requires --file")
}
if skipVerify {
return errors.New("--checksum and --skip-verify are mutually exclusive")
}
return nil
}
// 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
checksumPath, checksumAlgo, expectedDigest string
skipVerify bool
}
// resolveDigest verifies or hashes the source before extract and returns the digest to record.
// Upstream digests are of the artifact as supplied. Failures are reported to errOut before
// returning; the file is left in place, and a catalog download recovers with --force.
func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageDigest, error) {
digest := ebsmetadata.ImageDigest{Filename: filepath.Base(src.imageFile)}
switch sourceDigestMode(src.localFile, src.checksumPath, src.skipVerify) {
case ebsmetadata.DigestCatalog:
if src.image.Checksum == "" || src.image.ChecksumType == "" {
fmt.Fprintf(errOut, "Catalog entry %q is missing Checksum/ChecksumType; refusing import.\n", src.imageName)
return digest, errors.New("catalog entry has no checksum")
}
actual, err := utils.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType)
if err != nil {
printChecksumError(errOut, src.imageFile, src.imageName, src.image, err)
return digest, err
}
fmt.Fprintf(out, "✅ Verified image checksum (%s)\n", src.image.ChecksumType)
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)
if err != nil {
fmt.Fprintf(errOut, "Image integrity verification failed: %v\n", err)
fmt.Fprintf(errOut, " file: %s\n", src.localFile)
fmt.Fprintf(errOut, " checksum: %s\n", src.checksumPath)
return digest, err
}
fmt.Fprintf(out, "✅ Verified image checksum (%s) against %s\n", src.checksumAlgo, src.checksumPath)
digest.Algorithm, digest.Value, digest.Source = src.checksumAlgo, actual, filepath.Base(src.checksumPath)
digest.Verification = ebsmetadata.DigestOperator
default:
if src.localFile != "" {
fmt.Fprintf(errOut, "Importing %s without checksum validation (pass --checksum <sums-file> to verify)\n", src.localFile)
} else {
fmt.Fprintf(errOut, "⚠️ --skip-verify set: checksum verification skipped for %s\n", src.imageName)
}
actual, err := utils.HashImageFile(src.imageFile, "sha256")
if err != nil {
fmt.Fprintf(errOut, "Could not hash image: %v\n", err)
return digest, err
}
digest.Algorithm, digest.Value = "sha256", actual
digest.Verification = ebsmetadata.DigestUnverified
}
return digest, nil
}
// sourceDigestMode picks how an import's source digest is obtained.
func sourceDigestMode(localFile, checksumPath string, skipVerify bool) string {
switch {
case localFile != "" && checksumPath != "":
return ebsmetadata.DigestOperator
case localFile != "" || skipVerify:
return ebsmetadata.DigestUnverified
default:
return ebsmetadata.DigestCatalog
}
}
func runimagesImportCmd(cmd *cobra.Command, args []string) {
var image utils.Images
@@ -503,6 +602,20 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
checksumPath, _ := cmd.Flags().GetString("checksum")
if err := validateChecksumFlags(checksumPath, cmd.Flags().Changed("checksum"), localFile, skipVerify); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var checksumAlgo, expectedDigest string
if checksumPath != "" {
checksumAlgo, expectedDigest, err = utils.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)
}
}
if imageName != "" {
var exists bool
image, exists = utils.AvailableImages[imageName]
@@ -589,8 +702,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Next, if the file is selected to download, fetch it, extract disk image, and save to path
if imageName != "" && localFile == "" {
// Next, if the file is selected to download, fetch it and save to path
if localFile == "" {
// Download the file to the image path
filename := path.Base(image.URL)
imageFile = fmt.Sprintf("%s/%s", imagePath, filename)
@@ -606,24 +719,20 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
}
}
// 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)
}
sourceDigest, err := importSource{
imageFile: imageFile,
imageName: imageName,
localFile: localFile,
image: image,
checksumPath: checksumPath,
checksumAlgo: checksumAlgo,
expectedDigest: expectedDigest,
skipVerify: skipVerify,
}.resolveDigest(os.Stdout, os.Stderr)
if err != nil {
os.Exit(1)
}
// Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image
@@ -669,6 +778,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
BootMode: image.BootMode,
Distro: image.Distro,
DistroFamily: utils.DistroFamily(image.Distro),
SourceDigest: &sourceDigest,
}
// Copy catalog-provided tags (e.g. spinifex:managed-by for system AMIs)
@@ -739,6 +849,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Image import complete. Image-ID (AMI): %s\n", volumeId)
fmt.Printf("✅ Source digest: %s:%s (%s)\n", sourceDigest.Algorithm, sourceDigest.Value, sourceDigest.Verification)
}
// registerImportedAMISnapshot writes the EC2 control plane's snapshot document
@@ -1001,6 +1112,62 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) {
fmt.Printf("✅ Promoted %s to system image (owner: %s).\n", imageID, admin.SystemOwnerAlias)
}
func runimagesDescribeCmd(cmd *cobra.Command, args []string) {
imageID, _ := cmd.Flags().GetString("image-id")
cfgFile, _ := cmd.Flags().GetString("config")
if cfgFile == "" {
cfgFile = DefaultConfigFile()
}
appConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Fprintln(os.Stderr, "Error loading config file:", err)
os.Exit(1)
}
node := appConfig.Nodes[appConfig.Node]
store := objectstore.NewS3ObjectStoreFromConfig(
node.Predastore.Host,
node.Predastore.Region,
node.Predastore.AccessKey,
node.Predastore.SecretKey,
)
meta, err := admin.GetAMIMetadata(store, node.Predastore.Bucket, imageID)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to describe AMI:", err)
os.Exit(1)
}
printAMIDescription(os.Stdout, meta)
}
// printAMIDescription writes an AMI's metadata and source digest for an operator.
func printAMIDescription(w io.Writer, meta ebsmetadata.AMI) {
fmt.Fprintf(w, "Image ID: %s\n", meta.ImageID)
fmt.Fprintf(w, "Name: %s\n", meta.Name)
fmt.Fprintf(w, "Owner: %s\n", meta.ImageOwnerAlias)
if !meta.CreationDate.IsZero() {
fmt.Fprintf(w, "Created: %s\n", meta.CreationDate.UTC().Format("2006-01-02T15:04:05Z"))
}
fmt.Fprintf(w, "Boot mode: %s\n", meta.BootMode)
fmt.Fprintf(w, "Architecture: %s\n", meta.Architecture)
d := meta.SourceDigest
if d == nil {
fmt.Fprintln(w, "Source digest: not recorded (imported before digest recording)")
return
}
fmt.Fprintf(w, "Source digest: %s:%s\n", d.Algorithm, d.Value)
if d.Source != "" {
fmt.Fprintf(w, "Verification: %s (%s)\n", d.Verification, d.Source)
} else {
fmt.Fprintf(w, "Verification: %s\n", d.Verification)
}
fmt.Fprintf(w, "Source file: %s\n", d.Filename)
}
// List remote images available.
func runimagesListCmd(cmd *cobra.Command, args []string) {
//fmt.Println(availableImages)
+183
View File
@@ -2,6 +2,8 @@ package cmd
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/netip"
@@ -9,11 +11,13 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/mulgadc/northstar/pkg/backend"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/formation"
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
@@ -1288,6 +1292,185 @@ func TestAMIVolumeSizeGiB(t *testing.T) {
}
}
func TestValidateChecksumFlags(t *testing.T) {
tests := []struct {
name string
checksum string
unset bool
file string
skipVerify bool
wantErr string
}{
{name: "no checksum", unset: true, file: "img.raw"},
{name: "checksum with file", checksum: "SHA256SUMS", file: "img.raw"},
{name: "empty checksum value", checksum: "", file: "img.raw", wantErr: "--checksum requires a sums file path"},
{name: "blank checksum value", checksum: " ", file: "img.raw", wantErr: "--checksum requires a sums file path"},
{name: "checksum without file", checksum: "SHA256SUMS", wantErr: "--checksum requires --file"},
{name: "checksum with skip-verify", checksum: "SHA256SUMS", file: "img.raw", skipVerify: true,
wantErr: "mutually exclusive"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateChecksumFlags(tt.checksum, !tt.unset, tt.file, tt.skipVerify)
if tt.wantErr == "" {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestSourceDigestMode(t *testing.T) {
tests := []struct {
name string
localFile string
checksum string
skipVerify bool
want string
}{
{name: "file with checksum", localFile: "img.raw", checksum: "SHA256SUMS", want: ebsmetadata.DigestOperator},
{name: "catalog download verified", want: ebsmetadata.DigestCatalog},
{name: "catalog download skip-verify", skipVerify: true, want: ebsmetadata.DigestUnverified},
{name: "file without checksum", localFile: "img.raw", want: ebsmetadata.DigestUnverified},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, sourceDigestMode(tt.localFile, tt.checksum, tt.skipVerify))
})
}
}
func TestImportSourceResolveDigest(t *testing.T) {
imgBytes := []byte("import-source-fixture")
sum := sha256.Sum256(imgBytes)
imgHex := hex.EncodeToString(sum[:])
dir := t.TempDir()
img := filepath.Join(dir, "image.raw")
require.NoError(t, os.WriteFile(img, imgBytes, 0o600))
sums := filepath.Join(dir, "SHA256SUMS")
t.Run("operator verified", func(t *testing.T) {
var out, errOut bytes.Buffer
got, err := importSource{
imageFile: img, localFile: img,
checksumPath: sums, checksumAlgo: "sha256", expectedDigest: imgHex,
}.resolveDigest(&out, &errOut)
require.NoError(t, err)
assert.Equal(t, ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestOperator,
Source: "SHA256SUMS", Filename: "image.raw",
}, got)
assert.Contains(t, out.String(), "Verified image checksum (sha256)")
assert.Empty(t, errOut.String())
})
t.Run("operator mismatch names the file and sums file", func(t *testing.T) {
var out, errOut bytes.Buffer
_, err := importSource{
imageFile: img, localFile: img,
checksumPath: sums, checksumAlgo: "sha256", expectedDigest: strings.Repeat("0", 64),
}.resolveDigest(&out, &errOut)
require.ErrorIs(t, err, utils.ErrChecksumMismatch)
assert.Contains(t, errOut.String(), "file: "+img)
assert.Contains(t, errOut.String(), "checksum: "+sums)
assert.Contains(t, errOut.String(), imgHex)
assert.FileExists(t, img, "the operator's file must be left in place")
})
t.Run("local file without checksum is recorded unverified", func(t *testing.T) {
var out, errOut bytes.Buffer
got, err := importSource{imageFile: img, localFile: img}.resolveDigest(&out, &errOut)
require.NoError(t, err)
assert.Equal(t, ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestUnverified, Filename: "image.raw",
}, got)
assert.Equal(t, "Importing "+img+" without checksum validation (pass --checksum <sums-file> to verify)\n",
errOut.String())
})
t.Run("catalog skip-verify is recorded unverified", func(t *testing.T) {
var out, errOut bytes.Buffer
got, err := importSource{imageFile: img, imageName: "debian-13-x86_64", skipVerify: true}.resolveDigest(&out, &errOut)
require.NoError(t, err)
assert.Equal(t, ebsmetadata.DigestUnverified, got.Verification)
assert.Equal(t, imgHex, got.Value)
assert.Contains(t, errOut.String(), "--skip-verify set")
})
t.Run("catalog entry without checksum is refused", func(t *testing.T) {
var out, errOut bytes.Buffer
_, err := importSource{imageFile: img, imageName: "debian-13-x86_64"}.resolveDigest(&out, &errOut)
require.Error(t, err)
assert.Contains(t, errOut.String(), "missing Checksum/ChecksumType")
})
t.Run("unreadable image fails the unverified hash", func(t *testing.T) {
var out, errOut bytes.Buffer
missing := filepath.Join(dir, "missing.raw")
_, err := importSource{imageFile: missing, localFile: missing}.resolveDigest(&out, &errOut)
require.Error(t, err)
assert.Contains(t, errOut.String(), "Could not hash image")
})
}
func TestImagesDescribeCmd_FlagSchema(t *testing.T) {
imageIDFlag := imagesDescribeCmd.Flags().Lookup("image-id")
require.NotNil(t, imageIDFlag, "--image-id must be defined")
assert.Equal(t, []string{"true"}, imageIDFlag.Annotations[cobraRequiredAnnotation],
"--image-id must be marked required")
}
func TestPrintAMIDescription(t *testing.T) {
base := ebsmetadata.AMI{
ImageID: "ami-1",
Name: "ami-debian-13-x86_64",
ImageOwnerAlias: "system",
CreationDate: time.Date(2026, 9, 23, 1, 2, 3, 0, time.UTC),
BootMode: "uefi",
Architecture: "x86_64",
}
t.Run("digest recorded", func(t *testing.T) {
meta := base
meta.SourceDigest = &ebsmetadata.ImageDigest{
Algorithm: "sha512", Value: "abc123", Verification: ebsmetadata.DigestOperator,
Source: "SHA512SUMS", Filename: "debian-13-generic-amd64.tar.xz",
}
var buf bytes.Buffer
printAMIDescription(&buf, meta)
out := buf.String()
assert.Contains(t, out, "Image ID: ami-1")
assert.Contains(t, out, "Created: 2026-09-23T01:02:03Z")
assert.Contains(t, out, "Boot mode: uefi")
assert.Contains(t, out, "Source digest: sha512:abc123")
assert.Contains(t, out, "Verification: operator (SHA512SUMS)")
assert.Contains(t, out, "Source file: debian-13-generic-amd64.tar.xz")
})
t.Run("unverified digest has no source", func(t *testing.T) {
meta := base
meta.SourceDigest = &ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: "def456", Verification: ebsmetadata.DigestUnverified,
Filename: "image.raw",
}
var buf bytes.Buffer
printAMIDescription(&buf, meta)
assert.Contains(t, buf.String(), "Verification: unverified\n")
})
t.Run("digest not recorded", func(t *testing.T) {
var buf bytes.Buffer
printAMIDescription(&buf, base)
out := buf.String()
assert.Contains(t, out, "Source digest: not recorded (imported before digest recording)")
assert.NotContains(t, out, "Verification:")
})
}
func TestCheckJoinPreconditions(t *testing.T) {
withFakeProc(t, nil)
noStore := filepath.Join(t.TempDir(), "nats", "jetstream")
+2 -1
View File
@@ -166,8 +166,9 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `/tmp`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--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. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--checksum`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `/tmp`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports require an explicit `--boot-mode` because there is no catalog metadata to inherit from, and are verified only when `--checksum <sums-file>` is passed. `--checksum` takes the sums file the image was published with (`SHA256SUMS`, `SHA512SUMS`, `<image>.sha256`/`.sha512`, coreutils, BSD or bare-digest format), looks up the entry for the image's basename, and infers sha256 or sha512 from the digest length. A mismatch exits 1 before anything is written to storage and leaves the file in place. `--checksum` proves only that the image matches the sums file supplied with it, not that either is authentic; verify the sums file's vendor signature separately. `--checksum` is refused without `--file` or with `--skip-verify`. A `--file` import without `--checksum` prints `Importing <file> without checksum validation` to stderr and proceeds; `--name --file` does not fall back to the catalog digest. Every import records the source artifact's digest on the AMI with who verified it (`operator`, `catalog` or `unverified`, the unverified cases hashed as sha256), prints it on success, and shows it in `spx admin images describe`. The digest is of the file as supplied, not of the stored volume. `--skip-verify` bypasses verification for catalog imports and prints a warning to stderr; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
| `spx admin images describe` | `--image-id` (required) | Read-only. Prints the AMI's name, owner, creation date, boot mode and architecture, then its source digest: algorithm and value, verification (`operator` with the sums file name, `catalog` with the sums URL, or `unverified`), and the source file name. AMIs imported before digest recording print `Source digest: not recorded`. The digest is not exposed through `DescribeImages`. |
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-<id>/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
+1 -1
View File
@@ -73,7 +73,7 @@ spx admin images import --name ubuntu-26.04-x86_64
### Option B: Import a local image file
```bash
spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
spx admin images import --file ~/images/ubuntu-26.04-server-cloudimg-amd64.img --checksum ~/images/SHA256SUMS --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
```
Verify the import and note the AMI ID:
+1 -1
View File
@@ -109,7 +109,7 @@ spx admin images list
spx admin images import --name debian-13-arm64
```
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` to import operator-supplied media (verification skipped — operator is responsible for integrity), or `--force` to re-download after a checksum mismatch.
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` with `--checksum <sums-file>` to import operator-supplied media verified against the sums file it was published with, or `--force` to re-download after a checksum mismatch. A `--file` import without `--checksum` proceeds unverified and prints a notice. Every import records the source file's digest on the AMI; `spx admin images describe --image-id <ami>` shows it.
### EKS node image
+15 -7
View File
@@ -72,14 +72,19 @@ curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \
-o awscliv2.zip
```
Mirror the cloud image you intend to run as guest VMs:
Mirror the cloud image you intend to run as guest VMs, together with the sums file it was published with. Keep the upstream filename, because the import looks the image up in the sums file by name:
```bash
mkdir -p images
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" \
-o images/debian-13-amd64.qcow2
cd images
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS"
sha512sum -c --ignore-missing SHA512SUMS
cd ..
```
The sums file only proves the image matches it; a sums file fetched over the same channel as the image can be tampered with alongside it. Where the vendor publishes a signature for the sums file (for example Ubuntu's `SHA256SUMS.gpg`), download it too and check it with `gpg --verify` against the vendor's signing key on this connected machine.
## Step 3. Assemble Transfer Media
```bash
@@ -88,7 +93,7 @@ cp spinifex-${TAG}-linux-${ARCH}.tar.gz /media/spinifex-deploy/tarball/
cp setup.sh /media/spinifex-deploy/
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
cp awscliv2.zip /media/spinifex-deploy/aws/
cp images/*.qcow2 /media/spinifex-deploy/images/
cp images/*.qcow2 images/SHA512SUMS /media/spinifex-deploy/images/
```
## Step 4. Install on the Air-Gapped Target
@@ -139,13 +144,16 @@ sudo systemctl start spinifex.target
## Step 8. Import Cloud Images
Register the pre-staged image with Spinifex:
Register the pre-staged image with Spinifex, verifying it against the sums file carried across with it:
```bash
sudo spx admin images import --file /mnt/usb/images/debian-13-amd64.qcow2 \
sudo spx admin images import --file /mnt/usb/images/debian-13-genericcloud-amd64.qcow2 \
--checksum /mnt/usb/images/SHA512SUMS \
--distro debian --version 13 --arch x86_64 --boot-mode uefi
```
A digest mismatch exits before anything is written to storage. On success the import prints the verified digest, which `spx admin images describe --image-id <ami>` shows again later.
## Step 9. Verify
```bash
@@ -185,4 +193,4 @@ sudo INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-...tar.gz \
### Image Import Fails
`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.
`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. A `checksum entry for image filename not found` error means the image was renamed after download; restore the upstream filename so it matches its line in the sums file. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.
+2 -2
View File
@@ -149,7 +149,7 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
**Update checks and metadata.** Spinifex does not check for updates and does not consume a cloud metadata service (`169.254.169.254` is served *by* the cluster to guest VMs). Node software updates come from the operator's OS package channel. The install-telemetry endpoint above is the only vendor-operated destination contacted by a node; closed-egress deployments should disable it and record the opt-out in the security plan.
**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally with their sums files and use `spx admin images import --file <image> --checksum <sums-file>` with the pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
## 3. Cross-Node (Internal) Connections
@@ -226,7 +226,7 @@ Notes that make the difference between this working and locking you out:
- **`output` is deliberately untouched.** The metadata service's reply path egresses through per-instance policy routing; filtering `output` breaks it in ways that are hard to attribute.
- **On a single-NIC node the cluster and encap sets are the node's public addresses**, because the planes collapse. The rules are still correct, but they are no longer a boundary — an upstream ACL is the only real control there.
Port 4432 must be closed outside the bootstrap window; `spx admin join` opens it transiently. Outbound egress can be limited to the image-catalogue hostnames in [§2](#2-outbound-connections) plus the operator's OS package repositories; on air-gapped nodes, block all outbound HTTPS and use `spx admin images import --file`.
Port 4432 must be closed outside the bootstrap window; `spx admin join` opens it transiently. Outbound egress can be limited to the image-catalogue hostnames in [§2](#2-outbound-connections) plus the operator's OS package repositories; on air-gapped nodes, block all outbound HTTPS and use `spx admin images import --file <image> --checksum <sums-file>`.
## 5. Configuration Surface
+13 -1
View File
@@ -17,7 +17,18 @@ import (
func TestPromoteSystemImage_HappyPath(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
const id = "ami-user-001"
putAMI(t, store, id, "my-app", testRemoveAccountID, "snap-user-001")
digest := &ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: "abc123", Verification: ebsmetadata.DigestOperator,
Source: "SHA256SUMS", Filename: "my-app.raw",
}
require.NoError(t, ebsmetadata.NewStore(store, testRemoveBucket).PutAMI(t.Context(), ebsmetadata.AMI{
ImageID: id,
Name: "my-app",
ImageOwnerAlias: testRemoveAccountID,
SnapshotID: "snap-user-001",
VolumeSizeGiB: 8,
SourceDigest: digest,
}))
result, err := PromoteSystemImage(store, testRemoveBucket, PromoteImageOpts{ImageID: id})
require.NoError(t, err)
@@ -30,6 +41,7 @@ func TestPromoteSystemImage_HappyPath(t *testing.T) {
// Other fields must be preserved.
assert.Equal(t, "my-app", meta.Name)
assert.Equal(t, "snap-user-001", meta.SnapshotID)
assert.Equal(t, digest, meta.SourceDigest)
}
// A promoted AMI's readers derive the snapshot account from the alias, so the
+18
View File
@@ -108,9 +108,27 @@ type AMI struct {
Distro string `json:"distro,omitempty"`
DistroFamily string `json:"distro_family,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
SourceDigest *ImageDigest `json:"source_digest,omitempty"`
State string `json:"state,omitempty"`
}
// ImageDigest identifies the artifact an AMI was imported from, not the
// volume's current bytes; Verification says who vouched for it.
type ImageDigest struct {
Algorithm string `json:"algorithm"`
Value string `json:"value"`
Verification string `json:"verification"`
Source string `json:"source,omitempty"`
Filename string `json:"filename"`
}
// ImageDigest.Verification values.
const (
DigestOperator = "operator"
DigestCatalog = "catalog"
DigestUnverified = "unverified"
)
// VolumeKey keys a volume document under its owning account, so a listing of
// one account's prefix cannot reach another account's document. accountID comes
// from the document's own TenantID, never from the caller.
+25
View File
@@ -50,6 +50,31 @@ func TestVolumeRoundTripsEncryptedAndModification(t *testing.T) {
assert.Equal(t, *want.Modification, *got.Modification)
}
func TestAMIRoundTripsSourceDigest(t *testing.T) {
want := AMI{
ImageID: "ami-1", Name: "debian-13",
SourceDigest: &ImageDigest{
Algorithm: "sha512", Value: "abc123", Verification: DigestOperator,
Source: "SHA512SUMS", Filename: "debian-13-generic-amd64.tar.xz",
},
}
data, err := MarshalAMI(want)
require.NoError(t, err)
got, err := UnmarshalAMI(data)
require.NoError(t, err)
require.NotNil(t, got.SourceDigest)
assert.Equal(t, *want.SourceDigest, *got.SourceDigest)
}
// TestAMIWithoutSourceDigestStillDecodes guards AMIs written before the field
// existed. The version is hard-coded so a SchemaVersion bump fails here.
func TestAMIWithoutSourceDigestStillDecodes(t *testing.T) {
got, err := UnmarshalAMI([]byte(`{"schema_version":2,"image_id":"ami-1"}`))
require.NoError(t, err)
assert.Equal(t, "ami-1", got.ImageID)
assert.Nil(t, got.SourceDigest)
}
func TestMetadataRejectsUnknownSchemaVersion(t *testing.T) {
_, err := UnmarshalAMI([]byte(`{"schema_version":99,"image_id":"ami-1"}`))
require.Error(t, err)
@@ -161,3 +161,35 @@ func TestCreateImageFromInstance_Provider_UsesEbsMetadataVolumeSize(t *testing.T
require.NoError(t, err)
assert.Equal(t, uint64(20), ami.VolumeSizeGiB, "the AMI's volume size must come from the ebsmetadata document")
}
// An image of a running disk is no longer the imported artifact, so it must
// not inherit the source AMI's digest even though it inherits other fields.
func TestCreateImageFromInstance_DoesNotInheritSourceDigest(t *testing.T) {
svc, _, provider := setupProviderSnapshotImageService(t)
doc := seedProviderVolume(t, svc, provider, "vol-digest001", 8)
require.NoError(t, svc.MetadataStore().PutAMI(context.Background(), ebsmetadata.AMI{
ImageID: "ami-src-digest",
Name: "imported",
ImageOwnerAlias: "system",
BootMode: "uefi",
SourceDigest: &ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: "abc123", Verification: ebsmetadata.DigestOperator, Filename: "img.raw",
},
}))
out, err := svc.CreateImageFromInstance(CreateImageParams{
Input: &ec2.CreateImageInput{
InstanceId: aws.String("i-digest001"),
Name: aws.String("from-instance"),
},
RootVolumeID: doc.VolumeID,
SourceImageID: "ami-src-digest",
IsRunning: false,
}, testAccountID)
require.NoError(t, err)
ami, err := svc.MetadataStore().GetAMI(context.Background(), aws.StringValue(out.ImageId))
require.NoError(t, err)
assert.Equal(t, "uefi", ami.BootMode, "the source AMI must have been read")
assert.Nil(t, ami.SourceDigest)
}
@@ -890,6 +890,7 @@ func (s *ImageServiceImpl) CopyImage(ctx context.Context, input *ec2.CopyImageIn
CreationDate: time.Now().UTC(),
BootMode: srcMeta.BootMode,
Tags: tags,
SourceDigest: srcMeta.SourceDigest,
// Zero-copy: the new config shares the source's already-durable
// snapshot, so the image is complete as soon as this is written.
State: "available",
@@ -1769,6 +1769,10 @@ func TestCopyImage_InheritsSourceFields(t *testing.T) {
})
// Source AMI with non-default fields that must propagate.
digest := &ebsmetadata.ImageDigest{
Algorithm: "sha512", Value: "abc123", Verification: ebsmetadata.DigestCatalog,
Source: "https://example.test/SHA512SUMS", Filename: "arm.tar.xz",
}
putTestAMIConfigWithSnapshot(t, store, "ami-arm001", "arm-source", testAccountID, "snap-arm001", ebsmetadata.AMI{
Architecture: "arm64",
PlatformDetails: "Linux/UNIX (arm64)",
@@ -1777,6 +1781,7 @@ func TestCopyImage_InheritsSourceFields(t *testing.T) {
RootDeviceType: "ebs",
Description: "arm source",
BootMode: "uefi",
SourceDigest: digest,
})
before := time.Now()
@@ -1791,6 +1796,7 @@ func TestCopyImage_InheritsSourceFields(t *testing.T) {
assert.Equal(t, uint64(32), newMeta.VolumeSizeGiB)
assert.Equal(t, "ebs", newMeta.RootDeviceType)
assert.Equal(t, "uefi", newMeta.BootMode, "CopyImage must propagate BootMode from source")
assert.Equal(t, digest, newMeta.SourceDigest, "a zero-copy share still holds the source artifact")
assert.False(t, newMeta.CreationDate.Before(before), "CreationDate must be refreshed on copy, not inherited")
}
+92 -13
View File
@@ -41,6 +41,7 @@ var (
ErrChecksumNotFound = errors.New("checksum entry for image filename not found")
ErrUnsupportedChecksumType = errors.New("unsupported checksum type")
ErrChecksumFetchFailed = errors.New("checksum fetch failed")
ErrMalformedChecksum = errors.New("malformed checksum")
)
// checksumExtraRootCAs is a test-only hook for httptest TLS servers; nil in production.
@@ -50,32 +51,28 @@ var checksumExtraRootCAs *x509.CertPool
var checksumFetchTimeout = 30 * time.Second
// VerifyImageChecksum fetches the sums file at checksumURL, finds the entry for imagePath's basename,
// hashes the file with checksumType ("sha256" or "sha512"), and compares digests.
// hashes the file with checksumType ("sha256" or "sha512"), compares digests and returns the actual one.
// Fails closed: non-HTTPS, non-2xx, oversized response, or digest mismatch all return a wrapped error.
func VerifyImageChecksum(imagePath, checksumURL, checksumType string) error {
func VerifyImageChecksum(imagePath, checksumURL, checksumType string) (string, error) {
hasher, err := newHasher(checksumType)
if err != nil {
return err
return "", err
}
expected, err := fetchExpectedDigest(checksumURL, filepath.Base(imagePath))
if err != nil {
slog.Error("image checksum fetch failed", "source", checksumURL, "err", err)
return err
return "", err
}
// Catch algorithm mismatch before ConstantTimeCompare; a length mismatch would look like tampering.
if len(expected) != hasher.Size()*2 {
return fmt.Errorf("%w: digest length %d from sums file does not match %s output length %d",
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 {
actual, err := VerifyImageDigest(imagePath, checksumType, expected)
if errors.Is(err, ErrChecksumMismatch) {
slog.Error("image checksum mismatch",
"image", imagePath,
"algorithm", checksumType,
@@ -83,10 +80,92 @@ func VerifyImageChecksum(imagePath, checksumURL, checksumType string) error {
"actual", actual,
"source", checksumURL,
)
return fmt.Errorf("%w: expected %s got %s", ErrChecksumMismatch, expected, actual)
}
return actual, err
}
// VerifyImageDigest hashes imagePath with algo and compares it with the expected hex digest.
// The actual digest is returned on mismatch too, so callers can report it.
func VerifyImageDigest(imagePath, algo, expected string) (string, error) {
actual, err := HashImageFile(imagePath, algo)
if err != nil {
return "", err
}
if subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 {
return actual, fmt.Errorf("%w: expected %s got %s", ErrChecksumMismatch, expected, actual)
}
return actual, nil
}
// HashImageFile returns the lowercase hex digest of imagePath under algo ("sha256" or "sha512").
func HashImageFile(imagePath, algo string) (string, error) {
hasher, err := newHasher(algo)
if err != nil {
return "", err
}
actual, err := hashImageFile(imagePath, hasher)
if err != nil {
return "", fmt.Errorf("hash image file: %w", err)
}
return actual, nil
}
// ReadExpectedDigest reads a local sums file and returns the algorithm and lowercase hex digest for imageName.
// The algorithm is inferred from the digest length, never from the file's name.
func ReadExpectedDigest(sumsPath, imageName string) (algo, digest string, err error) {
// Stat before open: opening a FIFO would block until a writer appears.
info, err := os.Stat(sumsPath)
if err != nil {
return "", "", err
}
if !info.Mode().IsRegular() {
return "", "", fmt.Errorf("%w: %s is not a regular file", ErrMalformedChecksum, sumsPath)
}
return nil
f, err := os.Open(sumsPath)
if err != nil {
return "", "", err
}
defer f.Close()
body, err := io.ReadAll(io.LimitReader(f, sumsFileMaxSize+1))
if err != nil {
return "", "", fmt.Errorf("read sums file: %w", err)
}
if len(body) > sumsFileMaxSize {
return "", "", fmt.Errorf("%w: sums file exceeds %d byte limit", ErrMalformedChecksum, sumsFileMaxSize)
}
digest, err = parseSumsFile(body, imageName)
if errors.Is(err, ErrChecksumNotFound) && bytes.Contains(body, []byte("/"+imageName)) {
return "", "", fmt.Errorf("%w (the sums file names it with a directory prefix; entries must name the bare filename)", err)
}
if err != nil {
return "", "", err
}
digest = strings.ToLower(digest)
algo, err = algoForDigest(digest)
if err != nil {
return "", "", err
}
return algo, digest, nil
}
// algoForDigest infers the hash algorithm from a lowercase hex digest's length.
func algoForDigest(digest string) (string, error) {
for _, c := range digest {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return "", fmt.Errorf("%w: digest contains non-hex character %q", ErrMalformedChecksum, c)
}
}
switch len(digest) {
case sha256.Size * 2:
return "sha256", nil
case sha512.Size * 2:
return "sha512", nil
default:
return "", fmt.Errorf("%w: digest length %d", ErrUnsupportedChecksumType, len(digest))
}
}
func newHasher(checksumType string) (hash.Hash, error) {
+142 -22
View File
@@ -1342,15 +1342,16 @@ func TestVerifyImageChecksum(t *testing.T) {
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)
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")
_, err := VerifyImageChecksum(img, url, "sha256")
assert.NoError(t, err)
})
@@ -1358,7 +1359,7 @@ func TestVerifyImageChecksum(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")
_, err := VerifyImageChecksum(img, url, "sha512")
assert.NoError(t, err)
})
@@ -1366,7 +1367,7 @@ func TestVerifyImageChecksum(t *testing.T) {
img := writeTempImage(t, alpineName, alpineBytes)
body := sha512Hex(alpineBytes) + "\n"
url := newSumsServer(t, body)
err := VerifyImageChecksum(img, url, "sha512")
_, err := VerifyImageChecksum(img, url, "sha512")
assert.NoError(t, err)
})
@@ -1376,7 +1377,7 @@ func TestVerifyImageChecksum(t *testing.T) {
// 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")
_, err := VerifyImageChecksum(img, url, "sha512")
assert.ErrorIs(t, err, ErrChecksumNotFound)
})
@@ -1385,7 +1386,7 @@ func TestVerifyImageChecksum(t *testing.T) {
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")
_, err := VerifyImageChecksum(img, url, "sha512")
assert.NoError(t, err)
})
@@ -1394,7 +1395,7 @@ func TestVerifyImageChecksum(t *testing.T) {
wrong := sha512Hex([]byte("a different blob entirely"))
body := fmt.Sprintf("%s %s\n", wrong, debianName)
url := newSumsServer(t, body)
err := VerifyImageChecksum(img, url, "sha512")
_, 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
@@ -1408,14 +1409,14 @@ func TestVerifyImageChecksum(t *testing.T) {
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")
_, 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")
_, err := VerifyImageChecksum(img, "https://example.invalid/SUMS", "md5")
assert.ErrorIs(t, err, ErrUnsupportedChecksumType)
})
@@ -1426,7 +1427,7 @@ func TestVerifyImageChecksum(t *testing.T) {
}))
t.Cleanup(srv.Close)
trustTestServer(t, srv)
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
_, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
})
@@ -1435,7 +1436,7 @@ func TestVerifyImageChecksum(t *testing.T) {
// 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")
_, 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")
@@ -1453,7 +1454,7 @@ func TestVerifyImageChecksum(t *testing.T) {
}))
t.Cleanup(srv.Close)
trustTestServer(t, srv)
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
_, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
require.Error(t, err)
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
})
@@ -1467,7 +1468,7 @@ func TestVerifyImageChecksum(t *testing.T) {
}))
t.Cleanup(srv.Close)
trustTestServer(t, srv)
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
_, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
require.Error(t, err)
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
assert.GreaterOrEqual(t, int(hits.Load()), 10)
@@ -1482,7 +1483,7 @@ func TestVerifyImageChecksum(t *testing.T) {
fmt.Fprintf(&b, "%s %s.bin\n", sha512Hex([]byte(junk)), junk)
}
url := newSumsServer(t, b.String())
err := VerifyImageChecksum(img, url, "sha512")
_, err := VerifyImageChecksum(img, url, "sha512")
require.Error(t, err)
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
assert.Contains(t, err.Error(), "exceeds")
@@ -1500,7 +1501,7 @@ func TestVerifyImageChecksum(t *testing.T) {
checksumFetchTimeout = 100 * time.Millisecond
t.Cleanup(func() { checksumFetchTimeout = prev })
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
_, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
require.Error(t, err)
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
})
@@ -1511,7 +1512,7 @@ func TestVerifyImageChecksum(t *testing.T) {
img := writeTempImage(t, debianName, debianBytes)
body := fmt.Sprintf("%s %s\n", sha256Hex(debianBytes), debianName)
url := newSumsServer(t, body)
err := VerifyImageChecksum(img, url, "sha512")
_, err := VerifyImageChecksum(img, url, "sha512")
require.Error(t, err)
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
assert.NotErrorIs(t, err, ErrChecksumMismatch)
@@ -1522,7 +1523,7 @@ func TestVerifyImageChecksum(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")
_, err := VerifyImageChecksum(img, url, "sha512")
require.Error(t, err)
assert.NotErrorIs(t, err, ErrChecksumMismatch)
assert.Contains(t, err.Error(), "empty")
@@ -1536,7 +1537,7 @@ func TestVerifyImageChecksum(t *testing.T) {
trustTestServer(t, srv)
url := srv.URL + "/SUMS"
srv.Close()
err := VerifyImageChecksum(img, url, "sha512")
_, err := VerifyImageChecksum(img, url, "sha512")
require.Error(t, err)
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
})
@@ -1562,7 +1563,7 @@ nopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+/abcdefghijklmn
-----END PGP SIGNATURE-----
`, sha256Hex([]byte("boot-iso")), rockyName, sha256Hex(rockyBytes), sha256Hex([]byte("dvd-iso")))
url := newSumsServer(t, body)
err := VerifyImageChecksum(img, url, "sha256")
_, err := VerifyImageChecksum(img, url, "sha256")
assert.NoError(t, err)
})
@@ -1581,7 +1582,7 @@ iHUEABYKAB0WIQTm5n8/yqv7tQAaIQAaIQAFAmJabcdEFGhijklmnopqrstuvwxyz
-----END PGP SIGNATURE-----
`, almaName, sha256Hex(almaBytes))
url := newSumsServer(t, body)
err := VerifyImageChecksum(img, url, "sha256")
_, err := VerifyImageChecksum(img, url, "sha256")
assert.NoError(t, err)
})
@@ -1600,7 +1601,7 @@ iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
-----END PGP SIGNATURE-----
`, sha256Hex([]byte("boot-iso")))
url := newSumsServer(t, body)
err := VerifyImageChecksum(img, url, "sha256")
_, err := VerifyImageChecksum(img, url, "sha256")
assert.ErrorIs(t, err, ErrChecksumNotFound)
})
}
@@ -1657,6 +1658,125 @@ iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm
})
}
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