gem-guardian 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/{main.yml → ci.yml} +3 -21
  3. data/.rubocop.yml +12 -0
  4. data/CHANGELOG.md +25 -1
  5. data/CODE_OF_CONDUCT.md +1 -1
  6. data/Gemfile +0 -1
  7. data/README.md +397 -49
  8. data/Rakefile +27 -27
  9. data/bin/console +2 -2
  10. data/gem-guardian.gemspec +11 -9
  11. data/lib/gem/guardian/artifact_store.rb +13 -2
  12. data/lib/gem/guardian/checksum_provider.rb +181 -0
  13. data/lib/gem/guardian/cli.rb +99 -7
  14. data/lib/gem/guardian/configuration.rb +88 -0
  15. data/lib/gem/guardian/dependency.rb +5 -1
  16. data/lib/gem/guardian/github_release_verifier.rb +2 -2
  17. data/lib/gem/guardian/lockfile_parser.rb +32 -6
  18. data/lib/gem/guardian/progress.rb +66 -0
  19. data/lib/gem/guardian/provenance_verifier.rb +1 -3
  20. data/lib/gem/guardian/registry.rb +83 -0
  21. data/lib/gem/guardian/registry_audit.rb +81 -0
  22. data/lib/gem/guardian/report_builder.rb +3 -4
  23. data/lib/gem/guardian/result_printer.rb +35 -5
  24. data/lib/gem/guardian/rubygems_client.rb +366 -21
  25. data/lib/gem/guardian/verifier.rb +119 -12
  26. data/lib/gem/guardian/version.rb +1 -1
  27. data/lib/gem/guardian.rb +4 -0
  28. data/script/registry_provenance_audit.rb +41 -0
  29. metadata +16 -19
  30. data/sig/gem/guardian/artifact_store.rbs +0 -22
  31. data/sig/gem/guardian/checksum.rbs +0 -14
  32. data/sig/gem/guardian/cli.rbs +0 -60
  33. data/sig/gem/guardian/dependency.rbs +0 -18
  34. data/sig/gem/guardian/error.rbs +0 -26
  35. data/sig/gem/guardian/lockfile_parser.rbs +0 -55
  36. data/sig/gem/guardian/rubygems_client.rbs +0 -46
  37. data/sig/gem/guardian/verifier.rbs +0 -40
  38. data/sig/gem/guardian/version.rbs +0 -10
  39. data/sig/gem/guardian.rbs +0 -4
data/Rakefile CHANGED
@@ -2,42 +2,42 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
+ require "rubocop/rake_task"
6
+ require "yard"
7
+ require "yard/rake/yardoc_task"
5
8
 
6
9
  Rake::TestTask.new(:test) do |t|
7
10
  t.libs << "test"
8
- t.pattern = "test/**/*_test.rb"
11
+ t.libs << "lib"
12
+ t.warning = false
13
+ t.test_files = FileList["test/**/*_test.rb"]
9
14
  end
10
15
 
11
- task default: :test
16
+ RuboCop::RakeTask.new(:rubocop) do |task|
17
+ task.options = ["--parallel"]
18
+ end
12
19
 
13
- namespace :rbs do
14
- desc "Remove generated RBS prototype files"
15
- task :clobber do
16
- sh "rm -rf tmp/sig"
17
- end
20
+ YARD::Rake::YardocTask.new(:yard)
18
21
 
19
- desc "Generate disposable RBS prototypes into tmp/sig"
20
- task :prototype do
21
- sh "rm -rf tmp/sig"
22
- sh "mkdir -p tmp/sig"
23
- sh "bundle exec rbs prototype rb --out-dir=tmp/sig --base-dir=lib lib"
22
+ namespace :yard do
23
+ desc "Validate YARD documentation coverage"
24
+ task :validate do
25
+ require "open3"
24
26
 
25
- unless Dir.exist?("sig")
26
- puts "sig/ does not exist; seeding curated signatures from tmp/sig"
27
- sh "cp -R tmp/sig sig"
28
- end
29
- end
27
+ stdout, stderr, status = Open3.capture3("bundle", "exec", "yard", "stats")
28
+ text = "#{stdout}\n#{stderr}"
29
+ puts text
30
+ abort("yard stats failed") unless status.success?
30
31
 
31
- desc "Validate curated RBS signatures with Steep"
32
- task :validate do
33
- sh "bundle exec rbs validate sig"
34
- end
32
+ match = text.match(/([0-9]+(?:\.[0-9]+)?)%\s+documented/)
33
+ abort("Unable to determine YARD coverage") unless match
35
34
 
36
- desc "Open diff between curated and generated signatures"
37
- task :diff do
38
- sh "diff -ru sig tmp/sig || true"
39
- end
35
+ coverage = match[1].to_f
36
+ minimum = 95.0
37
+ abort(format("YARD coverage %<coverage> is below %<minimum>", { coverage:, minimum: })) if coverage < minimum
40
38
 
41
- desc "Generate disposable RBS prototypes and validate curated signatures"
42
- task check: %i[prototype validate]
39
+ puts format("YARD coverage %.2f%%", coverage)
40
+ end
43
41
  end
42
+
43
+ task default: %i[test rubocop yard:validate]
data/bin/console CHANGED
@@ -7,5 +7,5 @@ require "gem/guardian"
7
7
  # You can add fixtures and/or initialization code here to make experimenting
8
8
  # with your gem easier. You can also use a different console, if you like.
9
9
 
10
- require "irb"
11
- IRB.start(__FILE__)
10
+ require "pry"
11
+ Pry.start
data/gem-guardian.gemspec CHANGED
@@ -8,20 +8,22 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Kenneth Demanawa"]
9
9
  spec.email = ["kenneth.c.demanawa@gmail.com"]
10
10
 
11
- spec.summary = "Consumer-side integrity verification for Ruby gems."
11
+ spec.summary = "Gem integrity and supply-chain verification for Ruby."
12
12
  spec.description = <<~DESC
13
- Audits Bundler checksum coverage and verifies Ruby gem artifacts against RubyGems SHA256 checksums when needed.
13
+ Verifies gem integrity using lockfile, registry, and artifact checksums,
14
+ audits Bundler checksum coverage, and reports supply-chain provenance
15
+ when available.
14
16
  DESC
15
- spec.homepage = "https://github.com/kanutocd/gem-guardian"
17
+
18
+ spec.homepage = "https://kanutocd.github.io/gem-guardian"
16
19
  spec.license = "MIT"
17
20
  spec.required_ruby_version = ">= 3.2"
18
21
 
19
- spec.metadata = {
20
- "homepage_uri" => spec.homepage,
21
- "source_code_uri" => spec.homepage,
22
- "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
23
- "rubygems_mfa_required" => "true"
24
- }
22
+ spec.metadata["homepage_uri"] = spec.homepage
23
+ spec.metadata["documentation_uri"] = spec.homepage
24
+ spec.metadata["source_code_uri"] = "https://github.com/kanutocd/gem-guardian"
25
+ spec.metadata["changelog_uri"] = "#{spec.metadata["source_code_uri"]}/blob/main/CHANGELOG.md"
26
+ spec.metadata["rubygems_mfa_required"] = "true"
25
27
 
26
28
  spec.files = Dir.chdir(__dir__) do
27
29
  tracked_files = `git ls-files -z`.split("\x0")
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "digest"
4
5
  require "tmpdir"
5
6
 
6
7
  module Gem
@@ -16,12 +17,22 @@ module Gem
16
17
 
17
18
  # Returns the local path for +dependency+, downloading it if needed.
18
19
  def path_for(dependency)
19
- FileUtils.mkdir_p(@cache_dir)
20
- path = File.join(@cache_dir, dependency.gem_filename)
20
+ directory = cache_directory_for(dependency)
21
+ FileUtils.mkdir_p(directory)
22
+ path = File.join(directory, dependency.gem_filename)
21
23
  return path if File.file?(path)
22
24
 
23
25
  @client.download_gem(dependency, path)
24
26
  end
27
+
28
+ private
29
+
30
+ def cache_directory_for(dependency)
31
+ source = dependency.respond_to?(:source) && dependency.source
32
+ return @cache_dir if source.to_s.empty?
33
+
34
+ File.join(@cache_dir, Digest::SHA256.hexdigest(source)[0, 16])
35
+ end
25
36
  end
26
37
  end
27
38
  end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Gem
7
+ module Guardian
8
+ # Pluggable checksum providers for registry or publisher supplied SHA256 data.
9
+ #
10
+ # A provider answers one question:
11
+ #
12
+ # "Is there an independent SHA256 for this dependency, and where did it come from?"
13
+ #
14
+ # Providers are intentionally separate from artifact hashing. The downloaded
15
+ # `.gem` file is always hashed locally by {Verifier}; provider results are
16
+ # independent trust anchors that can be compared with that artifact digest.
17
+ module ChecksumProvider
18
+ # Independent checksum data returned by a provider.
19
+ #
20
+ # @!attribute [r] sha256
21
+ # @return [String] lowercase SHA256 hex digest
22
+ # @!attribute [r] source
23
+ # @return [Symbol] provider source category, such as +:registry+ or +:publisher+
24
+ # @!attribute [r] provider
25
+ # @return [String] provider implementation name
26
+ # @!attribute [r] verification_uri
27
+ # @return [String, nil] URI a user or tool can inspect to verify the checksum source
28
+ Result = Data.define(:sha256, :source, :provider, :verification_uri) do
29
+ # @return [Hash{Symbol => Object}] JSON-friendly representation of the provider result,
30
+ # including the checksum, provider name, source category, and verification URI
31
+ def to_h
32
+ {
33
+ sha256: sha256,
34
+ source: source,
35
+ provider: provider,
36
+ verification_uri: verification_uri
37
+ }
38
+ end
39
+ end
40
+
41
+ # Reads checksum metadata from the RubyGems.org-style versions API.
42
+ class RubyGemsApi
43
+ # @param dependency [Dependency] dependency whose checksum should be looked up
44
+ # @param client [RubygemsClient] client used to query the RubyGems.org-style API
45
+ # @return [Result, nil] provider result when checksum metadata is available, otherwise +nil+
46
+ def checksum_for(dependency, client:)
47
+ client.rubygems_api_checksum(dependency)
48
+ end
49
+ end
50
+
51
+ # Reads checksum metadata from a RubyGems/Bundler compact index endpoint.
52
+ class CompactIndex
53
+ # @param dependency [Dependency] dependency whose checksum should be looked up
54
+ # @param client [RubygemsClient] client used to query the compact index endpoint
55
+ # @return [Result, nil] provider result when compact index checksum metadata is available, otherwise +nil+
56
+ def checksum_for(dependency, client:)
57
+ client.compact_index_registry_checksum(dependency)
58
+ end
59
+ end
60
+
61
+ # Restricts another checksum provider to dependencies resolved from a
62
+ # matching gem source.
63
+ #
64
+ # This lets project configuration attach publisher checksum URLs to a
65
+ # private registry without probing that URL for every public gem. Source
66
+ # matching is prefix-based after trailing slashes are normalized, so a
67
+ # configured source such as `https://gems.contribsys.com/` matches locked
68
+ # dependency sources under that registry.
69
+ class SourceScoped
70
+ # @param source [String] source URI prefix this provider applies to
71
+ # @param provider [#checksum_for] checksum provider to delegate to
72
+ def initialize(source:, provider:)
73
+ @source = normalize_source(source)
74
+ @provider = provider
75
+ end
76
+
77
+ # @param dependency [Dependency] dependency whose source should be checked
78
+ # @param client [RubygemsClient] client passed to the delegated provider
79
+ # @return [Result, nil] delegated checksum result when the source matches, otherwise +nil+
80
+ def checksum_for(dependency, client:)
81
+ return unless source_matches?(dependency.source)
82
+
83
+ @provider.checksum_for(dependency, client:)
84
+ end
85
+
86
+ private
87
+
88
+ def source_matches?(source)
89
+ return false if source.to_s.empty?
90
+
91
+ normalize_source(source).start_with?(@source)
92
+ end
93
+
94
+ def normalize_source(source)
95
+ value = source.to_s
96
+ uri = URI(value)
97
+ uri.user = nil
98
+ uri.password = nil
99
+ uri.to_s.delete_suffix("/")
100
+ rescue URI::InvalidURIError
101
+ value.delete_suffix("/")
102
+ end
103
+ end
104
+
105
+ # Reads checksum metadata from a publisher-controlled checksum URL.
106
+ #
107
+ # This is intentionally generic. Commercial or self-hosted publishers can
108
+ # expose a stable checksum file without implementing RubyGems.org metadata
109
+ # APIs. For example, a publisher could host:
110
+ #
111
+ # https://example.com/checksums/mammoth-pro-1.0.0.gem.sha256
112
+ #
113
+ # The template supports these placeholders:
114
+ #
115
+ # - +{name}+
116
+ # - +{version}+
117
+ # - +{platform}+
118
+ # - +{filename}+
119
+ #
120
+ # The response body may contain either a bare SHA256 or a line such as:
121
+ #
122
+ # <sha256> <filename>
123
+ class Url
124
+ SHA256_PATTERN = /\b([a-fA-F0-9]{64})\b/
125
+ OPEN_TIMEOUT = 10
126
+ READ_TIMEOUT = 30
127
+
128
+ # @param template [String] URL template containing dependency placeholders such as +{filename}+
129
+ # @param http [#get_response] HTTP client, mainly for tests. When omitted, +Net::HTTP+ is used with explicit timeouts.
130
+ # @param provider_name [String] provider label used in reports and JSON output
131
+ def initialize(template:, http: Net::HTTP, provider_name: "url")
132
+ @template = template
133
+ @http = http
134
+ @provider_name = provider_name
135
+ end
136
+
137
+ # @param dependency [Dependency] dependency whose checksum should be looked up
138
+ # @param client [RubygemsClient] client used to sanitize the verification URI
139
+ # @return [Result, nil] provider result when the configured URL returns a parseable SHA256, otherwise +nil+
140
+ def checksum_for(dependency, client:)
141
+ uri = URI(expand_template(dependency))
142
+ response = http_get(uri)
143
+ return unless response.is_a?(Net::HTTPSuccess)
144
+
145
+ sha256 = response.body.to_s[SHA256_PATTERN, 1]
146
+ return unless sha256
147
+
148
+ Result.new(
149
+ sha256: sha256.downcase,
150
+ source: :publisher,
151
+ provider: @provider_name,
152
+ verification_uri: client.sanitize_uri(uri)
153
+ )
154
+ rescue StandardError
155
+ nil
156
+ end
157
+
158
+ private
159
+
160
+ def expand_template(dependency)
161
+ filename = dependency.gem_filename
162
+ @template
163
+ .gsub("{name}", dependency.name)
164
+ .gsub("{version}", dependency.version)
165
+ .gsub("{platform}", dependency.platform)
166
+ .gsub("{filename}", filename)
167
+ end
168
+
169
+ def http_get(uri)
170
+ return @http.get_response(uri) unless @http == Net::HTTP
171
+
172
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
173
+ open_timeout: OPEN_TIMEOUT,
174
+ read_timeout: READ_TIMEOUT) do |http|
175
+ http.request(Net::HTTP::Get.new(uri.request_uri))
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -7,8 +7,64 @@ module Gem
7
7
  # Command-line interface and output helpers.
8
8
  module Guardian
9
9
  # Command-line entry point for gem-guardian.
10
- # rubocop:disable Metrics/ClassLength, Metrics/ParameterLists
10
+ # rubocop:disable Metrics/ClassLength
11
11
  class CLI
12
+ # Lightweight lockfile data adapter used when a user verifies only a subset
13
+ # of gems from a Bundler lockfile.
14
+ #
15
+ # {LockfileParser} returns the full dependency graph and all parsed checksum
16
+ # entries. When the CLI receives both +--lockfile+ and explicit
17
+ # +GEM:VERSION[:PLATFORM]+ arguments, this view narrows that data to the
18
+ # requested dependencies while preserving the same reader methods consumed by
19
+ # {Verifier}, {ReportBuilder}, and {ResultPrinter}.
20
+ #
21
+ # @!attribute [r] dependencies
22
+ # @return [Array<Dependency>] dependencies selected for verification
23
+ # @!attribute [r] checksums
24
+ # @return [Hash{Dependency => Hash{String => String}}] checksum algorithms
25
+ # keyed by dependency
26
+ # @!attribute [r] checksums_section_present
27
+ # @return [Boolean] whether the source lockfile contained a +CHECKSUMS+
28
+ # section
29
+ LockfileDataView = Data.define(:dependencies, :checksums, :checksums_section_present) do
30
+ # Looks up a checksum for a dependency and algorithm.
31
+ #
32
+ # @param dependency [Dependency] dependency to look up
33
+ # @param algorithm [String] checksum algorithm name, currently usually
34
+ # +"sha256"+
35
+ # @return [String, nil] checksum digest when present, otherwise +nil+
36
+ def checksum_for(dependency, algorithm = "sha256")
37
+ checksums.fetch(dependency, {}).fetch(algorithm, nil)
38
+ end
39
+
40
+ # Returns only SHA256 checksums from the filtered lockfile data.
41
+ #
42
+ # @return [Hash{Dependency => String}] selected dependencies mapped to
43
+ # their SHA256 digest
44
+ def sha256_checksums
45
+ checksums.each_with_object({}) do |(dependency, algorithms), memo|
46
+ digest = algorithms["sha256"]
47
+ memo[dependency] = digest if digest
48
+ end
49
+ end
50
+
51
+ # Lists selected dependencies that do not have SHA256 lockfile coverage.
52
+ #
53
+ # @return [Array<Dependency>] dependencies missing a SHA256 checksum in
54
+ # the lockfile view
55
+ def missing_checksum_dependencies
56
+ dependencies.reject { |dependency| sha256_checksums.key?(dependency) }
57
+ end
58
+
59
+ # Indicates whether the original lockfile contained a +CHECKSUMS+
60
+ # section.
61
+ #
62
+ # @return [Boolean] +true+ when the source lockfile had checksum metadata
63
+ def checksums_present?
64
+ checksums_section_present
65
+ end
66
+ end
67
+
12
68
  # Starts the CLI with the provided argv.
13
69
  def self.start(argv)
14
70
  new(argv).run
@@ -45,7 +101,6 @@ module Gem
45
101
  end
46
102
 
47
103
  # Runs the verify subcommand.
48
- # rubocop:disable Metrics/MethodLength
49
104
  def verify
50
105
  json_output = flag?("--json")
51
106
  provenance_mode = flag?("--provenance")
@@ -60,26 +115,63 @@ module Gem
60
115
  @stderr.puts e.message
61
116
  1
62
117
  end
63
- # rubocop:enable Metrics/MethodLength
64
118
 
65
119
  # Parses a GEM:VERSION[:PLATFORM] spec string.
66
- def parse_gem_spec(spec)
120
+ def parse_gem_spec(spec, default_platform: "ruby")
67
121
  name, version, platform = spec.split(":", 3)
68
122
  raise Error, "Expected GEM:VERSION[:PLATFORM], got: #{spec}" if name.to_s.empty? || version.to_s.empty?
69
123
 
70
- Dependency.new(name:, version:, platform: platform || "ruby")
124
+ Dependency.new(name:, version:, platform: platform || default_platform)
71
125
  end
72
126
 
73
127
  def resolve_dependencies
74
- lockfile = option_value("--lockfile") || "Gemfile.lock"
128
+ lockfile_path = option_value("--lockfile")
129
+ return resolve_explicit_dependencies unless lockfile_path
130
+
131
+ lockfile_data = @lockfile_parser_class.new(lockfile_path).parse
132
+ return [lockfile_data, lockfile_data.dependencies, lockfile_path] if @argv.empty?
133
+
134
+ filtered_data = filter_lockfile_data(lockfile_data, @argv.map { |spec| parse_gem_spec(spec, default_platform: nil) })
135
+ [filtered_data, filtered_data.dependencies, lockfile_path]
136
+ end
137
+
138
+ def resolve_explicit_dependencies
75
139
  return [nil, @argv.map { |spec| parse_gem_spec(spec) }, nil] unless @argv.empty?
76
140
 
141
+ lockfile = "Gemfile.lock"
77
142
  lockfile_data = @lockfile_parser_class.new(lockfile).parse
78
143
  [lockfile_data, lockfile_data.dependencies, lockfile]
79
144
  end
80
145
 
146
+ def filter_lockfile_data(lockfile_data, requested_dependencies)
147
+ dependencies = requested_dependencies.flat_map do |requested|
148
+ matches = matching_lockfile_dependencies(lockfile_data.dependencies, requested)
149
+ raise Error, "Gem not found in lockfile: #{requested.name}:#{requested.version}" if matches.empty?
150
+
151
+ matches
152
+ end.uniq
153
+ checksums = lockfile_data.checksums.select { |dependency, _algorithms| dependencies.include?(dependency) }
154
+ LockfileDataView.new(dependencies, checksums, lockfile_data.checksums_section_present)
155
+ end
156
+
157
+ def matching_lockfile_dependencies(lockfile_dependencies, requested)
158
+ lockfile_dependencies.select do |dependency|
159
+ dependency.name == requested.name &&
160
+ dependency.version == requested.version &&
161
+ (requested.platform.nil? || dependency.platform == requested.platform)
162
+ end
163
+ end
164
+
81
165
  def verifier_for(lockfile_data)
82
166
  expected_checksums = lockfile_data&.sha256_checksums || {}
167
+ config = Configuration.load
168
+ return @verifier_class.new(expected_checksums:) unless config.checksum_providers?
169
+
170
+ client = RubygemsClient.new(
171
+ checksum_providers: RubygemsClient.default_checksum_providers + config.checksum_providers
172
+ )
173
+ @verifier_class.new(expected_checksums:, client:)
174
+ rescue ArgumentError
83
175
  @verifier_class.new(expected_checksums:)
84
176
  end
85
177
 
@@ -175,6 +267,6 @@ module Gem
175
267
  0
176
268
  end
177
269
  end
178
- # rubocop:enable Metrics/ClassLength, Metrics/ParameterLists
270
+ # rubocop:enable Metrics/ClassLength
179
271
  end
180
272
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "gem/guardian/checksum_provider"
5
+ require "gem/guardian/error"
6
+
7
+ module Gem
8
+ module Guardian
9
+ # Project-level configuration loaded from `.gem-guardian.yml`.
10
+ #
11
+ # The configuration file is intentionally small and policy-oriented. Its
12
+ # first supported use case is declaring publisher checksum providers for
13
+ # private gem registries that do not expose RubyGems.org checksum metadata.
14
+ #
15
+ # @example Publisher checksum provider
16
+ # checksum_providers:
17
+ # - name: contribsys-sidekiq
18
+ # source: https://gems.contribsys.com/
19
+ # template: https://gems.contribsys.com/checksums/{filename}.sha256
20
+ class Configuration
21
+ DEFAULT_PATH = ".gem-guardian.yml"
22
+
23
+ attr_reader :path, :checksum_providers
24
+
25
+ # Loads configuration from `.gem-guardian.yml` in the current directory,
26
+ # or from `GEM_GUARDIAN_CONFIG` when that environment variable is set.
27
+ #
28
+ # @param path [String, nil] explicit configuration path
29
+ # @param cwd [String] working directory used for relative paths
30
+ # @return [Configuration] parsed configuration. Missing files produce an empty configuration.
31
+ # @raise [Error] when the YAML is invalid or has an unsupported shape
32
+ def self.load(path: ENV.fetch("GEM_GUARDIAN_CONFIG", DEFAULT_PATH), cwd: Dir.pwd)
33
+ full_path = absolute_path(path, cwd)
34
+ return new(path: full_path) unless File.file?(full_path)
35
+
36
+ data = YAML.safe_load_file(full_path, permitted_classes: [], aliases: false) || {}
37
+ raise Error, "#{full_path} must contain a YAML mapping" unless data.is_a?(Hash)
38
+
39
+ new(path: full_path, checksum_providers: build_checksum_providers(data.fetch("checksum_providers", [])))
40
+ rescue Psych::Exception => e
41
+ raise Error, "Invalid gem-guardian config #{full_path}: #{e.message}"
42
+ end
43
+
44
+ # @param path [String, nil] source path of the loaded configuration
45
+ # @param checksum_providers [Array<#checksum_for>] configured checksum providers
46
+ def initialize(path: nil, checksum_providers: [])
47
+ @path = path
48
+ @checksum_providers = checksum_providers
49
+ end
50
+
51
+ # @return [Boolean] whether the config declares any checksum providers
52
+ def checksum_providers?
53
+ !checksum_providers.empty?
54
+ end
55
+
56
+ class << self
57
+ private
58
+
59
+ def absolute_path(path, cwd)
60
+ path = DEFAULT_PATH if path.to_s.empty?
61
+ File.absolute_path(path, cwd)
62
+ end
63
+
64
+ def build_checksum_providers(entries)
65
+ raise Error, "checksum_providers must be an array" unless entries.is_a?(Array)
66
+
67
+ entries.map { |entry| build_checksum_provider(entry) }
68
+ end
69
+
70
+ def build_checksum_provider(entry)
71
+ raise Error, "checksum provider entries must be mappings" unless entry.is_a?(Hash)
72
+
73
+ template = entry["template"]
74
+ raise Error, "checksum provider template is required" if template.to_s.empty?
75
+
76
+ provider = ChecksumProvider::Url.new(
77
+ template: template,
78
+ provider_name: entry.fetch("name", "url")
79
+ )
80
+ source = entry["source"]
81
+ return provider if source.to_s.empty?
82
+
83
+ ChecksumProvider::SourceScoped.new(source:, provider:)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -3,7 +3,11 @@
3
3
  module Gem
4
4
  module Guardian
5
5
  # A gem dependency identified by name, version, and platform.
6
- Dependency = Data.define(:name, :version, :platform) do
6
+ Dependency = Data.define(:name, :version, :platform, :source) do
7
+ def initialize(name:, version:, platform:, source: nil)
8
+ super
9
+ end
10
+
7
11
  # Returns the canonical .gem filename for this dependency.
8
12
  def gem_filename
9
13
  platform_suffix = platform && platform != "ruby" ? "-#{platform}" : ""
@@ -14,7 +14,7 @@ module Gem
14
14
  end
15
15
 
16
16
  # Verifies GitHub release checksum, signature, and attestation metadata.
17
- # rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
17
+ # rubocop:disable Metrics/ClassLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
18
18
  class GitHubReleaseVerifier
19
19
  def initialize(client: GitHubClient.new)
20
20
  @client = client
@@ -195,6 +195,6 @@ module Gem
195
195
  :unsupported
196
196
  end
197
197
  end
198
- # rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
198
+ # rubocop:enable Metrics/ClassLength, Metrics/ParameterLists, Metrics/CyclomaticComplexity
199
199
  end
200
200
  end