still_active 3.1.0 → 3.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +3 -1
  4. data/lib/still_active/artifactory_client.rb +2 -20
  5. data/lib/still_active/assessment.rb +85 -0
  6. data/lib/still_active/cli.rb +84 -23
  7. data/lib/still_active/config.rb +2 -0
  8. data/lib/still_active/deps_dev_client.rb +161 -15
  9. data/lib/still_active/diff.rb +38 -0
  10. data/lib/still_active/ecosystem_lens.rb +80 -44
  11. data/lib/still_active/ecosystems_client.rb +10 -7
  12. data/lib/still_active/errors.rb +10 -0
  13. data/lib/still_active/forgejo_client.rb +5 -1
  14. data/lib/still_active/github_client.rb +28 -5
  15. data/lib/still_active/gitlab_client.rb +5 -1
  16. data/lib/still_active/go_toolchain.rb +75 -0
  17. data/lib/still_active/helpers/alternatives_helper.rb +2 -2
  18. data/lib/still_active/helpers/cyclonedx_helper.rb +7 -0
  19. data/lib/still_active/helpers/diff_markdown_helper.rb +5 -0
  20. data/lib/still_active/helpers/emoji_helper.rb +2 -1
  21. data/lib/still_active/helpers/endoflife_helper.rb +3 -2
  22. data/lib/still_active/helpers/http_cache.rb +109 -0
  23. data/lib/still_active/helpers/http_helper.rb +148 -28
  24. data/lib/still_active/helpers/markdown_helper.rb +1 -1
  25. data/lib/still_active/helpers/repository_check.rb +33 -0
  26. data/lib/still_active/helpers/status_helper.rb +11 -0
  27. data/lib/still_active/helpers/summary_helper.rb +6 -1
  28. data/lib/still_active/helpers/terminal_helper.rb +9 -1
  29. data/lib/still_active/options.rb +7 -0
  30. data/lib/still_active/osv_client.rb +39 -2
  31. data/lib/still_active/repository_signals.rb +113 -0
  32. data/lib/still_active/rubygems_client.rb +34 -0
  33. data/lib/still_active/sarif/rules.rb +1 -1
  34. data/lib/still_active/sbom_workflow.rb +16 -2
  35. data/lib/still_active/source_health.rb +88 -0
  36. data/lib/still_active/version.rb +1 -1
  37. data/lib/still_active/workflow.rb +63 -52
  38. data/lib/still_active.rb +1 -0
  39. data/still_active.gemspec +0 -1
  40. metadata +9 -16
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "time"
4
4
  require "octokit"
5
+ require_relative "errors"
5
6
 
6
7
  module StillActive
7
8
  # Repo signals (archived?, last commit date) for github.com-hosted gems.
@@ -20,19 +21,37 @@ module StillActive
20
21
  # object's pushed_at (last push) stands in for the last-commit date: it
21
22
  # matches the default-branch commit date to the day in practice, and folding
22
23
  # the two signals into one call halves the per-gem GitHub requests. Returns
23
- # {} when the repo can't be read, so the caller leaves both signals blank.
24
+ # {} when GitHub says the repo doesn't exist; raises RepoAccessDenied when it
25
+ # refuses the token, and RepoSignalsUnavailable when it can't answer (a rate
26
+ # limit past its short wait, an error status, a network failure).
27
+ # RepositorySignals decides what to ask next.
24
28
  def repo_signals(owner:, name:)
25
29
  return {} if owner.nil? || name.nil?
26
30
 
27
31
  repo = with_rate_limit_retry("repo #{owner}/#{name}") do
28
32
  StillActive.config.github_client.repository("#{owner}/#{name}")
29
33
  end
30
- return {} unless repo
34
+ raise RepoSignalsUnavailable, "#{owner}/#{name}: rate limited" if repo == :rate_limited
31
35
 
32
36
  {archived: repo.archived, last_commit_date: as_time(repo.pushed_at, owner, name)}
37
+ rescue Octokit::NotFound
38
+ {}
39
+ # Permanent "don't know", like a 404: a name Octokit refuses up front (an
40
+ # ArgumentError), a repository GitHub has blocked, or one withheld for legal
41
+ # reasons. A rerun won't change them, so they're not a failure.
42
+ rescue Octokit::InvalidRepository, Octokit::RepositoryUnavailable, Octokit::UnavailableForLegalReasons
43
+ {}
44
+ # Rate limits are Forbidden subclasses in Octokit (a 403), but they say
45
+ # nothing about the repository being private, so they don't stop the chain.
46
+ rescue Octokit::TooManyRequests, Octokit::AbuseDetected, Octokit::TooManyLoginAttempts => e
47
+ warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
48
+ raise RepoSignalsUnavailable, "#{owner}/#{name}: #{e.class}"
49
+ rescue Octokit::Unauthorized, Octokit::Forbidden => e
50
+ warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
51
+ raise RepoAccessDenied, "#{owner}/#{name}: #{e.class}"
33
52
  rescue Octokit::Error, Faraday::Error => e
34
53
  warn("warning: repo signals failed for #{owner}/#{name}: #{e.class}")
35
- {}
54
+ raise RepoSignalsUnavailable, "#{owner}/#{name}: #{e.class}"
36
55
  end
37
56
 
38
57
  # Commits on the default branch since the latest release's tag: the
@@ -48,7 +67,8 @@ module StillActive
48
67
 
49
68
  repo = "#{owner}/#{name}"
50
69
  ["v#{version}", version.to_s].each do |tag|
51
- return with_rate_limit_retry("unreleased-commits #{repo}") { StillActive.config.github_client.compare(repo, tag, "HEAD").ahead_by }
70
+ ahead = with_rate_limit_retry("unreleased-commits #{repo}") { StillActive.config.github_client.compare(repo, tag, "HEAD").ahead_by }
71
+ return (ahead == :rate_limited) ? nil : ahead
52
72
  rescue Octokit::NotFound
53
73
  # this tag form doesn't exist; fall through to try the next one
54
74
  end
@@ -60,6 +80,9 @@ module StillActive
60
80
 
61
81
  private
62
82
 
83
+ # Only an answer that says whether the repo is archived counts: ecosyste.ms
84
+ # 404s a repo it hasn't crawled, and after GitHub failed that is nobody
85
+ # answering.
63
86
  # Pause-and-retry on a rate-limit response when the reset is near, so a
64
87
  # transient secondary/burst limit (which GitHub's concurrent fan-out can
65
88
  # trip even with a token) self-heals instead of dropping the gem's signal.
@@ -76,7 +99,7 @@ module StillActive
76
99
  # Surface the one actionable hint rather than a generic class name,
77
100
  # then return nil so this signal is simply absent for the gem.
78
101
  warn("rate limited on #{label}; set GITHUB_TOKEN to raise your limit, or run less often")
79
- return
102
+ return :rate_limited
80
103
  end
81
104
 
82
105
  retried = true
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "errors"
4
5
  require_relative "helpers/http_helper"
5
6
 
6
7
  module StillActive
@@ -17,10 +18,13 @@ module StillActive
17
18
  return {} if owner.nil? || name.nil?
18
19
 
19
20
  path = "/api/v4/projects/#{encode_project(owner, name)}"
20
- body = HttpHelper.get_json(BASE_URI, path, headers: auth_headers)
21
+ # {} for a 404 (an answer); RepoSignalsUnavailable when GitLab couldn't answer.
22
+ body = HttpHelper.get_json(BASE_URI, path, headers: auth_headers, strict: true)
21
23
  return {} if body.nil?
22
24
 
23
25
  {archived: body["archived"] == true, last_commit_date: parse_time(body["last_activity_at"], owner, name)}
26
+ rescue HttpHelper::Unavailable => e
27
+ raise RepoSignalsUnavailable, e.message
24
28
  end
25
29
 
26
30
  private
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "osv_client"
4
+ require_relative "helpers/endoflife_helper"
5
+
6
+ module StillActive
7
+ # The Go toolchain, which Syft emits as the module `stdlib` (pkg:golang/stdlib@1.27.1).
8
+ # It is a runtime, and deps.dev can't serve it: it keys versions `go1.27.1` so the
9
+ # purl's version 404s, its index stops at go1.25.5, and its advisory list is the same
10
+ # for every version (verified 2026-09-24). So the release lines come from
11
+ # endoflife.date and the advisories from OSV by version, the sources the Ruby
12
+ # runtime check already trusts.
13
+ module GoToolchain
14
+ extend self
15
+
16
+ MODULE = "stdlib"
17
+
18
+ def toolchain?(ecosystem, name)
19
+ ecosystem == :go && name == MODULE
20
+ end
21
+
22
+ # Syft writes 1.27.1, a component version often go1.27.1, Trivy v1.27.1.
23
+ def release(version)
24
+ version.to_s.delete_prefix("go").delete_prefix("v")
25
+ end
26
+
27
+ # The same { info:, default:, vulnerabilities:, vulnerabilities_checked:,
28
+ # project_id:, version_unresolved: }
29
+ # EcosystemLens builds from deps.dev for any other package.
30
+ def signals(version:)
31
+ release = release(version)
32
+ cycles = EndoflifeHelper.fetch_cycles("/api/go.json")
33
+ cycles = nil unless cycles.is_a?(Array) && cycles.all?(Hash) && !cycles.empty?
34
+ latest = cycles&.first
35
+ cycle = cycles&.find { |c| c["cycle"] == release.split(".").first(2).join(".") }
36
+ # Only a plain release (1.20 and earlier shipped as go1.20): OSV writes Go
37
+ # prereleases as 1.27.0-rc.1, so a `1.27rc1` query would come back empty and
38
+ # read as clean.
39
+ vulnerabilities = OsvClient.advisories(ecosystem: :go, name: MODULE, version: release) if release.match?(/\A\d+\.\d+(\.\d+)?\z/)
40
+ warn("warning: go/#{MODULE}@#{release} advisories not checked (not a plain release, or OSV gave no complete answer); treat it as unknown, not clean") if vulnerabilities.nil?
41
+
42
+ {
43
+ info: cycle && {published_at: release_date(cycle, release), licenses: [], **end_of_life(cycle, latest)},
44
+ default: latest && {version: latest["latest"], published_at: latest["latestReleaseDate"]},
45
+ vulnerabilities: vulnerabilities || [],
46
+ vulnerabilities_checked: !vulnerabilities.nil?,
47
+ project_id: nil,
48
+ # The feed is up but doesn't know this release line.
49
+ version_unresolved: !cycles.nil? && cycle.nil?
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ # The feed dates each line's first and latest release only.
56
+ def release_date(cycle, release)
57
+ if release == cycle["latest"]
58
+ cycle["latestReleaseDate"]
59
+ elsif [cycle["cycle"], "#{cycle["cycle"]}.0"].include?(release)
60
+ cycle["releaseDate"]
61
+ end
62
+ end
63
+
64
+ # Go supports its two newest lines. Past that the Go team has said a line gets no
65
+ # more fixes, which is the maintainer's deprecation this field already carries.
66
+ def end_of_life(cycle, latest)
67
+ return {deprecated: false, deprecation_reason: nil} unless EndoflifeHelper.eol_reached?(cycle["eol"])
68
+
69
+ # Worded as what the Go team did, since output labels it the maintainer's note.
70
+ on = EndoflifeHelper.parse_eol(cycle["eol"])&.strftime(" on %Y-%m-%d")
71
+ upgrade = latest && " Upgrade to #{latest["latest"]}."
72
+ {deprecated: true, deprecation_reason: "The Go team ended the #{cycle["cycle"]} release line#{on}; it gets no further security fixes.#{upgrade}"}
73
+ end
74
+ end
75
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "gems"
3
+ require_relative "../rubygems_client"
4
4
 
5
5
  module StillActive
6
6
  # Turns a gem's catalog siblings into ranked "leads" -- the most-downloaded
@@ -33,7 +33,7 @@ module StillActive
33
33
  private
34
34
 
35
35
  def downloads(gem_name)
36
- info = Gems.info(gem_name)
36
+ info = RubygemsClient.info(gem_name)
37
37
  info && info["downloads"]
38
38
  rescue
39
39
  nil
@@ -6,6 +6,7 @@ require "time"
6
6
  require_relative "activity_helper"
7
7
  require_relative "status_helper"
8
8
  require_relative "vulnerability_helper"
9
+ require_relative "repository_check"
9
10
 
10
11
  module StillActive
11
12
  # Renders a still_active workflow result as a CycloneDX SBOM. Emits 1.6 by
@@ -152,6 +153,12 @@ module StillActive
152
153
  "still_active:archived" => boolean_property(data[:archived]),
153
154
  "still_active:deprecated" => boolean_property(data[:deprecated]),
154
155
  "still_active:deprecation_reason" => data[:deprecation_reason],
156
+ # An empty vulnerabilities list reads as clean downstream; this says whether
157
+ # anything was actually looked up.
158
+ "still_active:vulnerabilities_checked" => boolean_property(data[:vulnerabilities_checked]),
159
+ # Present only when no service answered: a blank archived flag here is
160
+ # "unknown", not "no".
161
+ "still_active:repository_check" => (RepositoryCheck.unanswered?(data) ? data[:repository_check] : nil),
155
162
  "still_active:scorecard_score" => data[:scorecard_score]&.to_s,
156
163
  "still_active:libyear" => data[:libyear]&.to_s,
157
164
  "still_active:last_commit_date" => iso8601(data[:last_commit_date])
@@ -12,6 +12,7 @@ module StillActive
12
12
 
13
13
  BUMP_KIND_LABELS = {
14
14
  closed_vulns: "closed vulns",
15
+ advisories_unchecked: "advisories UNCHECKED",
15
16
  introduced_vulns: "INTRODUCED vulns",
16
17
  fresher: "fresher",
17
18
  older_relative: "older relative to latest",
@@ -149,6 +150,10 @@ module StillActive
149
150
  "- #{name} — scorecard #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])}#{note}"
150
151
  when :version_yanked
151
152
  "- #{name} — version yanked from rubygems"
153
+ when :advisories_unchecked
154
+ "- #{name} — advisories could not be checked"
155
+ when :repository_unchecked
156
+ "- #{name} — repository no longer checked (#{MarkdownEscape.inline(ch[:check])}; may be archived)"
152
157
  when :libyear_worsened
153
158
  "- #{name} — libyear #{MarkdownEscape.inline(ch[:from])} → #{MarkdownEscape.inline(ch[:to])} (+#{ch[:delta]}y; same pinned version)"
154
159
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "activity_helper"
4
+ require_relative "repository_check"
4
5
 
5
6
  module StillActive
6
7
  module EmojiHelper
@@ -8,7 +9,7 @@ module StillActive
8
9
 
9
10
  def inactive_gem_emoji(result_hash)
10
11
  case ActivityHelper.activity_level(result_hash)
11
- when :ok then ""
12
+ when :ok then RepositoryCheck.unanswered?(result_hash) ? StillActive.config.unsure_emoji : ""
12
13
  when :stale then StillActive.config.warning_emoji
13
14
  when :archived, :critical then StillActive.config.critical_warning_emoji
14
15
  when :unknown then StillActive.config.unsure_emoji
@@ -87,12 +87,13 @@ module StillActive
87
87
  end
88
88
  end
89
89
 
90
- private
91
-
90
+ # The feed's raw cycles, newest first, or nil when it's unavailable.
92
91
  def fetch_cycles(feed_path)
93
92
  HttpHelper.get_json(ENDOFLIFE_URI, feed_path)
94
93
  end
95
94
 
95
+ private
96
+
96
97
  def normalize_cycle(cycle)
97
98
  version = cycle["cycle"]
98
99
  return unless version && Gem::Version.correct?(version)
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module StillActive
8
+ # An on-disk cache of public sources' JSON answers, so a rerun, or the baseline
9
+ # and current audits of one CI job, don't ask for the same thing twice. Only the
10
+ # hosts and paths in RULES are cached, each for as long as that data can be
11
+ # trusted to hold: an answer that carries advisories for an hour, so a new CVE
12
+ # is at most an hour late (--no-cache for none), a published version's record
13
+ # for a week, since it doesn't change. Nothing credentialed is cached: those
14
+ # hosts aren't listed, and a request with an Authorization header is skipped
15
+ # whatever its host. Failures and 404s aren't cached, only answers.
16
+ #
17
+ # OSV's version query is deliberately absent. It is the arbiter that drops a
18
+ # deps.dev finding the version isn't affected by, so it must never be older
19
+ # than the deps.dev record it judges; cached separately, it could be, and would
20
+ # drop an advisory published after it was cached.
21
+ module HttpCache
22
+ extend self
23
+
24
+ HOUR = 60 * 60
25
+ DAY = 24 * HOUR
26
+ WEEK = 7 * DAY
27
+
28
+ # [host, path pattern, seconds], first match wins.
29
+ RULES = [
30
+ ["api.deps.dev", %r{\A/v3alpha/systems/[^/]+/packages/[^/]+/versions/[^/]+:requirements\z}, WEEK],
31
+ ["api.deps.dev", %r{\A/v3alpha/systems/[^/]+/packages/[^/]+/versions/}, HOUR], # carries advisoryKeys
32
+ ["api.deps.dev", %r{\A/v3alpha/systems/[^/]+/packages/[^/]+\z}, 6 * HOUR], # latest version
33
+ ["api.deps.dev", %r{\A/v3alpha/advisories/}, 6 * HOUR], # CVSS, which the severity gates read
34
+ ["api.deps.dev", %r{\A/v3alpha/projects/}, DAY],
35
+ ["api.osv.dev", %r{\A/v1/vulns/}, 6 * HOUR], # severity and fixed versions
36
+ ["rubygems.org", %r{\A/api/v1/versions/}, 6 * HOUR],
37
+ ["rubygems.org", %r{\A/api/v1/gems/}, DAY],
38
+ ["pypi.org", %r{\A/pypi/[^/]+/[^/]+/json\z}, WEEK], # one published version
39
+ ["packages.ecosyste.ms", %r{/versions/}, WEEK], # one published version's declared deps
40
+ ["repos.ecosyste.ms", %r{\A/api/v1/hosts/}, DAY],
41
+ ["endoflife.date", %r{\A/api/}, DAY]
42
+ ].freeze
43
+
44
+ # Entries older than any rule allows are deleted when the cache is first used.
45
+ MAX_AGE = WEEK
46
+
47
+ # Seconds to cache this request's answer for, or nil to not cache it.
48
+ def ttl(uri, headers)
49
+ return unless StillActive.config.http_cache
50
+ return if headers.keys.any? { _1.to_s.casecmp?("Authorization") }
51
+
52
+ RULES.find { |host, pattern, _| uri.host == host && uri.path.match?(pattern) }&.last
53
+ end
54
+
55
+ # The cached answer, or :miss.
56
+ def read(key, ttl)
57
+ path = path_for(key)
58
+ return :miss unless File.exist?(path)
59
+
60
+ entry = JSON.parse(File.read(path))
61
+ age = Time.now.to_f - entry.fetch("stored_at")
62
+ # A future stamp (clock skew, a cache restored from another machine) would
63
+ # otherwise never expire.
64
+ return :miss if age.negative? || age > ttl
65
+
66
+ entry.fetch("body")
67
+ rescue JSON::ParserError, KeyError, TypeError, SystemCallError
68
+ :miss
69
+ end
70
+
71
+ # Best-effort: a cache that can't be written is just a cache miss next time.
72
+ def write(key, body)
73
+ sweep_once
74
+ path = path_for(key)
75
+ FileUtils.mkdir_p(File.dirname(path))
76
+ temp = "#{path}.#{Process.pid}.#{rand(1 << 32)}.tmp"
77
+ File.write(temp, JSON.generate({"stored_at" => Time.now.to_f, "body" => body}))
78
+ File.rename(temp, path)
79
+ rescue SystemCallError, IOError
80
+ File.delete(temp) if temp && File.exist?(temp)
81
+ end
82
+
83
+ def key(method, uri)
84
+ Digest::SHA256.hexdigest("#{method}\n#{uri}")
85
+ end
86
+
87
+ def directory
88
+ base = ENV["XDG_CACHE_HOME"]
89
+ base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
90
+ File.join(base, "still_active", "http")
91
+ end
92
+
93
+ private
94
+
95
+ def path_for(key)
96
+ File.join(directory, key[0, 2], "#{key}.json")
97
+ end
98
+
99
+ def sweep_once
100
+ return if @swept
101
+
102
+ @swept = true
103
+ cutoff = Time.now - MAX_AGE
104
+ Dir.glob(File.join(directory, "*", "*.{json,tmp}")).each { File.delete(_1) if File.mtime(_1) < cutoff }
105
+ rescue SystemCallError
106
+ nil
107
+ end
108
+ end
109
+ end
@@ -3,10 +3,11 @@
3
3
  require "net/http"
4
4
  require "openssl"
5
5
  require "json"
6
+ require_relative "http_cache"
6
7
 
7
8
  module StillActive
8
9
  module HttpHelper
9
- TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "api.osv.dev", "endoflife.date", "rubygems.pkg.github.com", "repos.ecosyste.ms", "packages.ecosyste.ms", "pypi.org"].freeze
10
+ TRUSTED_HOSTS = ["github.com", "gitlab.com", "codeberg.org", "api.deps.dev", "api.osv.dev", "endoflife.date", "rubygems.org", "rubygems.pkg.github.com", "repos.ecosyste.ms", "packages.ecosyste.ms", "pypi.org"].freeze
10
11
  # Transport-level failures ("host unreachable / connection broke", not an HTTP
11
12
  # error status). Every network entry point degrades to a safe empty result on
12
13
  # these rather than letting one escape and vanish a gem from the audit.
@@ -22,6 +23,11 @@ module StillActive
22
23
  EOFError
23
24
  ].freeze
24
25
  MAX_REDIRECTS = 3
26
+ # A 429 or 503 that says when to come back gets one retry, if that's soon.
27
+ MAX_RETRY_AFTER_SECONDS = 10
28
+ # Idle connections kept per host. The fan-out runs 10 at a time, so more than
29
+ # that would only ever sit idle.
30
+ MAX_IDLE_PER_HOST = 10
25
31
  # Ceiling on a single response body. These are metadata endpoints (version
26
32
  # lists, scorecards, advisories); legitimate responses are well under this.
27
33
  # A source URL is lockfile-derived and a `*.jfrog.io` host is attacker-
@@ -30,16 +36,33 @@ module StillActive
30
36
  # gem with thousands of versions while bounding worst-case memory.
31
37
  MAX_BODY_BYTES = 16 * 1024 * 1024
32
38
  JSON_PARSER = ->(body) { JSON.parse(body) }
39
+ # Raised in strict mode when the source couldn't answer: a transport error, a
40
+ # non-404 error status, a refused redirect, an oversized or garbled body. A 404
41
+ # is an answer ("no such record") and still returns nil. A caller that must not
42
+ # read a failure as "nothing there", like an advisory lookup, asks for strict.
43
+ Unavailable = Class.new(StandardError)
33
44
  IDENTITY = ->(body) { body }
34
45
 
35
46
  extend self
36
47
 
37
- def get_json(base_uri, path, headers: {}, params: {})
48
+ # Close and forget every pooled connection. Specs call it between examples,
49
+ # since a pooled connection holds WebMock's fake socket.
50
+ def reset_connections!
51
+ pool.each_value { |idle| idle.each { close(_1) } }
52
+ @pool = nil
53
+ end
54
+
55
+ # `cache: false` skips the disk cache (a canary has to see the source as it is
56
+ # now); `cache_if` decides whether an answer is sound enough to keep, so a
57
+ # garbled one isn't reread for its whole TTL.
58
+ def get_json(base_uri, path, headers: {}, params: {}, strict: false, cache: true, cache_if: nil)
38
59
  uri = base_uri.dup
39
60
  uri.path = path
40
61
  uri.query = URI.encode_www_form(params) unless params.empty?
41
62
 
42
- request_json(uri, headers) { |target| Net::HTTP::Get.new(target) }
63
+ cached(HttpCache.key("GET", uri), uri, headers, enabled: cache, cache_if: cache_if) do
64
+ request_json(uri, headers, strict: strict) { |target| Net::HTTP::Get.new(target) }
65
+ end
43
66
  end
44
67
 
45
68
  # As get_json, but for endpoints that answer in plain text (the RubyGems
@@ -52,11 +75,13 @@ module StillActive
52
75
  request_json(uri, headers, parse: IDENTITY) { |target| Net::HTTP::Get.new(target) }
53
76
  end
54
77
 
55
- def post_json(base_uri, path, body:, headers: {})
78
+ # Never cached: the POSTs this tool makes (OSV's version query, deps.dev's
79
+ # batch, Artifactory's AQL) each have to be fresh, or aren't public.
80
+ def post_json(base_uri, path, body:, headers: {}, strict: false)
56
81
  uri = base_uri.dup
57
82
  uri.path = path
58
83
 
59
- request_json(uri, headers) do |target|
84
+ request_json(uri, headers, strict: strict) do |target|
60
85
  request = Net::HTTP::Post.new(target)
61
86
  request.body = body
62
87
  request
@@ -65,6 +90,18 @@ module StillActive
65
90
 
66
91
  private
67
92
 
93
+ # A cached answer when there's a fresh one; otherwise asks, and caches a
94
+ # non-nil answer. nil is a 404 or a failure, neither of which is kept.
95
+ def cached(key, uri, headers, enabled:, cache_if: nil)
96
+ ttl = enabled && HttpCache.ttl(uri, headers)
97
+ return yield unless ttl
98
+
99
+ hit = HttpCache.read(key, ttl)
100
+ return hit unless hit == :miss
101
+
102
+ yield.tap { |body| HttpCache.write(key, body) if !body.nil? && (cache_if.nil? || cache_if.call(body)) }
103
+ end
104
+
68
105
  # Two URIs share an origin when scheme, host, and port all match. URI fills
69
106
  # in the default port (443 for https), so the bare and explicit forms of the
70
107
  # same origin compare equal.
@@ -76,40 +113,54 @@ module StillActive
76
113
  # and returns the parsed body (or nil), where `parse` decides JSON vs text.
77
114
  # The block is yielded each URI and returns the request object, so GET and
78
115
  # POST share the redirect/auth/cap logic.
79
- def request_json(uri, headers, parse: JSON_PARSER)
80
- MAX_REDIRECTS.times do
81
- http = Net::HTTP.new(uri.host, uri.port)
82
- http.use_ssl = true
83
- http.open_timeout = 10
84
- http.read_timeout = 10
85
-
116
+ def request_json(uri, headers, parse: JSON_PARSER, strict: false)
117
+ retried = false
118
+ # The Retry-After retry repeats a pass rather than spending one of these.
119
+ passes = 0
120
+ while (passes += 1) <= MAX_REDIRECTS
86
121
  request = yield(uri)
87
122
  headers.each { |key, value| request[key] = value }
88
123
 
89
- outcome, payload = perform(http, request, uri, parse)
124
+ outcome, payload = with_connection(uri) { |http| perform(http, request, uri, parse) }
90
125
  case outcome
126
+ when :retry_after
127
+ if retried
128
+ warn("warning: #{uri.host}#{uri.path} is still rate limited after one retry")
129
+ return unavailable(strict, uri)
130
+ end
131
+ warn("warning: #{uri.host}#{uri.path} returned HTTP #{payload[:code]}, retrying in #{payload[:seconds]}s as it asked")
132
+ sleep(payload[:seconds])
133
+ retried = true
134
+ passes -= 1
135
+ next
91
136
  when :done
92
- return payload
93
- when :stop
137
+ # A 404 is how a source says "no record"; a 200 of `null` says nothing.
138
+ return payload unless payload.nil? && strict
139
+
140
+ warn("warning: #{uri.host}#{uri.path} returned an empty (null) body")
141
+ return unavailable(strict, uri)
142
+ when :not_found
94
143
  return
144
+ when :stop
145
+ return unavailable(strict, uri)
95
146
  when :redirect
96
147
  location = payload["Location"]
97
148
  if location.nil? || location.empty?
98
149
  warn("warning: #{uri.host}#{uri.path} returned HTTP #{payload.code} with no Location header")
99
- return
150
+ return unavailable(strict, uri)
100
151
  end
101
152
 
102
153
  redirect_uri = uri + location
103
154
  unless TRUSTED_HOSTS.include?(redirect_uri.host)
104
155
  warn("warning: #{uri.host}#{uri.path} redirected to untrusted host #{redirect_uri.host}, skipping")
105
- return
156
+ return unavailable(strict, uri)
106
157
  end
107
158
  # We dial every request over TLS (use_ssl = true). A redirect that
108
159
  # downgrades to http is either a misconfiguration or a downgrade
109
160
  # attempt; refuse it rather than silently dialing http-over-TLS.
110
161
  unless redirect_uri.scheme == "https"
111
162
  warn("warning: #{uri.host}#{uri.path} redirected to non-https #{redirect_uri.scheme} target, skipping")
112
- return
163
+ return unavailable(strict, uri)
113
164
  end
114
165
  warn("warning: #{uri.host}#{uri.path} redirected to #{redirect_uri.host}#{redirect_uri.path} (stale metadata?)")
115
166
  # Auth is scoped to an origin (scheme + host + port), not just a host:
@@ -120,42 +171,111 @@ module StillActive
120
171
  end
121
172
 
122
173
  warn("warning: #{uri.host}#{uri.path} too many redirects")
123
- nil
174
+ unavailable(strict, uri)
124
175
  rescue *TRANSPORT_ERRORS => e
125
176
  warn("warning: #{uri.host}#{uri.path} failed: #{e.class} (#{e.message})")
126
- nil
177
+ unavailable(strict, uri)
127
178
  rescue JSON::ParserError => e
128
179
  warn("warning: #{uri.host}#{uri.path} returned invalid JSON: #{e.message}")
129
- nil
180
+ unavailable(strict, uri)
130
181
  rescue URI::InvalidURIError => e
131
182
  warn("warning: #{uri.host}#{uri.path} returned an invalid redirect Location: #{e.message}")
132
- nil
183
+ unavailable(strict, uri)
184
+ end
185
+
186
+ # Every failure has already warned; strict callers also get told.
187
+ def unavailable(strict, uri)
188
+ raise Unavailable, "#{uri.host}#{uri.path}" if strict
133
189
  end
134
190
 
135
191
  # Issues the request in streaming form so the body is read against a size
136
192
  # cap rather than buffered whole. Returns one of:
137
193
  # [:redirect, response] a 3xx, for the caller to follow
138
- # [:stop, nil] non-success (warns unless 404), or body over cap
194
+ # [:not_found, nil] a 404, which is an answer, not a failure
195
+ # [:stop, nil] any other non-success (warns), or body over cap
139
196
  # [:done, parsed] a 2xx with the parsed body (JSON or text)
140
- # Redirect and non-success bodies are never read: returning from the block
141
- # unwinds through Net::HTTP, which closes the connection without draining
142
- # the body, so a huge error/redirect body can't OOM us either.
197
+ # Redirect and error bodies are never read: returning from the block unwinds
198
+ # through Net::HTTP without draining the body, so a huge one can't OOM us, and
199
+ # with_connection closes that connection. A success or a 404 is read to the end
200
+ # and the block finishes normally, so Net::HTTP does its end-of-request
201
+ # bookkeeping: it honours Connection: close and records the time for its idle
202
+ # check, which is what makes the connection safe to pool. Returning from inside
203
+ # skipped both, and a POST on a connection the server had closed then failed.
143
204
  def perform(http, request, uri, parse)
205
+ body = nil
144
206
  http.request(request) do |response|
145
207
  return [:redirect, response] if response.is_a?(Net::HTTPRedirection)
146
208
 
209
+ if response.is_a?(Net::HTTPNotFound)
210
+ return [:stop, nil] if read_capped_body(response, uri).nil?
211
+
212
+ next
213
+ end
214
+
215
+ if (seconds = retry_after(response))
216
+ return [:retry_after, {code: response.code, seconds: seconds}]
217
+ end
218
+
147
219
  unless response.is_a?(Net::HTTPSuccess)
148
- warn("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}") unless response.is_a?(Net::HTTPNotFound)
220
+ warn("warning: #{uri.host}#{uri.path} returned HTTP #{response.code}")
149
221
  return [:stop, nil]
150
222
  end
151
223
 
152
224
  body = read_capped_body(response, uri)
153
225
  return [:stop, nil] if body.nil?
226
+ end
227
+ body.nil? ? [:not_found, nil] : [:done, parse.call(body)]
228
+ end
229
+
230
+ # The seconds a 429 or 503 asks us to wait, when it gives a number of them and
231
+ # the wait is short enough to be worth it; nil otherwise. The HTTP-date form is
232
+ # read as "not soon", since the sources we call send seconds.
233
+ def retry_after(response)
234
+ return unless response.code == "429" || response.code == "503"
235
+
236
+ value = response["Retry-After"].to_s.strip
237
+ return unless value.match?(/\A\d+\z/)
238
+
239
+ value.to_i if value.to_i <= MAX_RETRY_AFTER_SECONDS
240
+ end
154
241
 
155
- return [:done, parse.call(body)]
242
+ # One started connection per host, reused across requests. Only a success or a
243
+ # 404, read to the end with Net::HTTP's request finished normally, goes back
244
+ # (see perform); anything else may have unread bytes and is closed. On reuse,
245
+ # Net::HTTP reconnects a connection the server closed or that sat idle past
246
+ # its keep-alive timeout.
247
+ def with_connection(uri)
248
+ http = pool[[uri.host, uri.port]].pop || open_connection(uri)
249
+ outcome = nil
250
+ begin
251
+ outcome = yield(http)
252
+ ensure
253
+ if [:done, :not_found].include?(outcome&.first) && pool[[uri.host, uri.port]].size < MAX_IDLE_PER_HOST
254
+ pool[[uri.host, uri.port]] << http
255
+ else
256
+ close(http)
257
+ end
156
258
  end
157
259
  end
158
260
 
261
+ def open_connection(uri)
262
+ http = Net::HTTP.new(uri.host, uri.port)
263
+ http.use_ssl = true
264
+ http.open_timeout = 10
265
+ http.read_timeout = 10
266
+ http.start
267
+ end
268
+
269
+ def close(http)
270
+ http.finish if http.started?
271
+ rescue IOError
272
+ nil
273
+ end
274
+
275
+ def pool
276
+ @pool ||= Hash.new { |hash, key| hash[key] = [] }
277
+ end
278
+
159
279
  # Reads the body in chunks, abandoning the read (returns nil) as soon as it
160
280
  # exceeds MAX_BODY_BYTES so an oversized body is never fully materialized.
161
281
  def read_capped_body(response, uri)