Skip to content

Raise FormatError for a corrupt checksums.yaml.gz in Package#verify - #9702

Open
arpitjain099 wants to merge 2 commits into
ruby:masterfrom
arpitjain099:fix/verify-corrupt-checksums-format-error
Open

Raise FormatError for a corrupt checksums.yaml.gz in Package#verify#9702
arpitjain099 wants to merge 2 commits into
ruby:masterfrom
arpitjain099:fix/verify-corrupt-checksums-format-error

Conversation

@arpitjain099

Copy link
Copy Markdown
Contributor

Gem::Package#verify is documented to raise Gem::Package::FormatError when a .gem is corrupt, and it maps the corruption errors it knows about (Zlib::GzipFile::Error, EOFError, TarInvalidError, Errno::ENOENT) onto that. Two corrupt-checksums.yaml.gz shapes slip through it as raw exceptions:

  • #digest builds its algorithm set with @checksums.to_h { |algorithm, _| ... }. @checksums is loaded straight from checksums.yaml.gz, so if that YAML's root isn't a mapping (a scalar, say), .to_h raises NoMethodError.
  • Each top-level key is passed to Gem::Security.create_digest, i.e. OpenSSL::Digest.new(algorithm). A key that isn't a known digest name raises OpenSSL::Digest::DigestError.

Neither is on verify's rescue list, so instead of FormatError "... is corrupt" the caller gets a raw error plus backtrace. Older rubygems funneled these through a broad rescue StandardError; the rescue list was later narrowed to specific classes, which is reasonable but dropped the coverage for these two, so it's a regression in the error contract rather than an ancient wart.

This guards the checksums structure at the source in #digest: reject a non-mapping root and turn an unknown-algorithm DigestError into FormatError. That keeps verify's deliberately narrow rescue list intact instead of widening it back to a catch-all.

To be clear on scope: this is only about the error type. The gem is rejected either way; a corrupt/mistyped checksums file never verifies. The fix just makes it fail with the documented FormatError instead of an OpenSSL/NoMethod error, which matters for any caller that rescues FormatError around Gem::Package.new(path).verify/.spec (installers, mirrors, SBOM/SCA tooling).

Verification: added two regression tests (unknown algorithm name, non-mapping root). Both raise the raw error on master and Gem::Package::FormatError with the fix. ruby -Itest -Ilib test/rubygems/test_gem_package.rb is green (60 tests, 0 failures) on ruby 4.0.

Package#digest builds its digest set from @Checksums, which is loaded
straight from the untrusted checksums.yaml.gz inside the .gem. If that
YAML root isn't a mapping, digest calls .to_h on it and raises
NoMethodError; if a top-level key isn't a known OpenSSL digest name,
Gem::Security.create_digest raises OpenSSL::Digest::DigestError. Neither
is on verify's rescue list, so verify leaks a raw exception and
backtrace instead of the Gem::Package::FormatError it raises for every
other corrupt-package case.

Older rubygems funneled these through a broad rescue StandardError; the
narrowed rescue list dropped that, so this is a regression in the error
contract. Guard the checksums structure at the source: reject a
non-mapping root and turn an unknown-algorithm DigestError into
FormatError, which keeps verify's deliberately narrow rescue intact.
Adds two regression tests.

Signed-off-by: arpitjain099 <arpitjain099@gmail.com>
@hsbt

hsbt commented Jul 21, 2026

Copy link
Copy Markdown
Member

Please fix the failing tests on CI.

OpenSSL raises RuntimeError, not OpenSSL::Digest::DigestError, when the
digest name is unknown, and the class varies across engines, so the
narrower rescue never fired and the raw error escaped Package#verify.
That is what failed test_verify_checksum_bad_algorithm on every platform.

The algorithm name is read from the gem's own checksums.yaml.gz, so any
failure to build a digest from it means the gem is malformed. Only the
checksums branch is widened; the internal DIGEST_NAME path is unchanged.

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
@arpitjain099

Copy link
Copy Markdown
Contributor Author

Fixed in 119b249.

The cause was my rescue clause catching the wrong class. Gem::Security.create_digest is just OpenSSL::Digest.new, and for an unknown digest name that raises RuntimeError, not OpenSSL::Digest::DigestError:

OpenSSL::Digest.new("this-is-not-a-digest-name")
#=> RuntimeError: Unsupported digest algorithm (this-is-not-a-digest-name).

So the rescue never fired, the raw error escaped Package#verify, and assert_raise Gem::Package::FormatError failed. That also explains why it went red on every platform including JRuby and TruffleRuby, since the exception class differs by engine but is never DigestError.

It now rescues StandardError there. The algorithm name comes from the gem's own checksums.yaml.gz, so any failure to build a digest from it means the gem is malformed, which is what FormatError is for. I only widened the checksums branch; the elsif path that uses the internal DIGEST_NAME constant is untouched, so a genuine problem with our own default digest still surfaces as itself.

One note on verification: my local Ruby is 2.6 and master needs 3.2+, so I could not run the suite here. I confirmed the behaviour directly against the same to_h/rescue construct, where the old clause leaks RuntimeError and the new one raises FormatError, but CI is the real check. Happy to iterate if anything else goes red.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants