feat: support different checksum format to support RHEL

This commit is contained in:
Julian Sommer
2026-05-20 13:36:04 +10:00
parent 20a8a39608
commit bec7c382f1
2 changed files with 146 additions and 9 deletions
+28 -9
View File
@@ -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: "<hex> <name>" (text mode), "<hex> *<name>"
// (binary mode), and a bare single-token "<hex>" line (single-file .sha512 from
// Alpine, which does not carry a filename).
// Accepts four on-the-wire shapes: "<hex> <name>" (coreutils text mode),
// "<hex> *<name>" (coreutils binary mode), "<algo> (<name>) = <hex>" (BSD
// style, used by Rocky/RHEL/Alma/Fedora/CentOS Stream CHECKSUM files), and a
// bare single-token "<hex>" 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: "<algo> (<name>) = <hex>". 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 {
+118
View File
@@ -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)
})
}