diff --git a/spinifex/utils/images.go b/spinifex/utils/images.go index f19a98722..799349dea 100644 --- a/spinifex/utils/images.go +++ b/spinifex/utils/images.go @@ -182,13 +182,21 @@ func fetchExpectedDigest(checksumURL, filename string) (string, error) { return digest, nil } -// parseSumsFile scans a coreutils-style sums file and returns the hex digest -// matching filename. Filename match is case-sensitive: upstream Debian/Ubuntu/ -// Alpine sums are consistently lowercase and a divergence is a real signal. +// parseSumsFile scans a sums file and returns the hex digest matching filename. +// Filename match is case-sensitive: upstream Debian/Ubuntu/Alpine sums are +// consistently lowercase and a divergence is a real signal. // -// Accepts three on-the-wire shapes: " " (text mode), " *" -// (binary mode), and a bare single-token "" line (single-file .sha512 from -// Alpine, which does not carry a filename). +// Accepts four on-the-wire shapes: " " (coreutils text mode), +// " *" (coreutils binary mode), " () = " (BSD +// style, used by Rocky/RHEL/Alma/Fedora/CentOS Stream CHECKSUM files), and a +// bare single-token "" line (single-file .sha512 from Alpine, which does +// not carry a filename). +// +// GPG cleartext-signed armor is tolerated implicitly: the BEGIN/END markers +// and "Hash:" header don't match any shape's structural guards, and signature +// body lines are individually single tokens but always appear in pairs +// (base64 + the =CRC trailer at minimum), so bareCount stays ≥ 2 and the +// bare-digest fallback never fires on a signed file. func parseSumsFile(body []byte, filename string) (string, error) { var bareDigest string bareCount := 0 @@ -209,10 +217,21 @@ func parseSumsFile(body []byte, filename string) (string, error) { if name == filename { return fields[0], nil } + case 4: + // BSD-style: " () = ". The "=" guard also rejects + // armor lines like "-----BEGIN PGP SIGNED MESSAGE-----" that happen + // to tokenise into 4 fields. Filenames have no spaces in upstream + // usage (Rocky/Alma/Fedora pin dated builds). + if fields[2] != "=" || !strings.HasPrefix(fields[1], "(") || !strings.HasSuffix(fields[1], ")") { + continue + } + name := strings.TrimSuffix(strings.TrimPrefix(fields[1], "("), ")") + if name == filename { + return fields[3], nil + } default: - // More than two fields: not a format we recognise. Skip rather than - // reject outright — signed sums files occasionally have trailing - // commentary we want to tolerate. + // Unrecognised shape. Skip rather than reject outright — signed sums + // files occasionally have trailing commentary we want to tolerate. } } if err := scanner.Err(); err != nil { diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index 8eaf7b244..0e33bee4b 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -1464,4 +1464,122 @@ func TestVerifyImageChecksum(t *testing.T) { 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) { + // Signature-block lines are individually single tokens. The + // bare-digest fallback only fires when bareCount == 1; PGP signatures + // always have ≥ 2 (the base64 block + the =CRC trailer), so the + // fallback stays dormant and we correctly report NotFound. + 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) + }) }