ssl-test 1.6.0 → 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa4bf88f0998469b7b82910dcef9f53cc6a64827c5aba691591138b78d33877e
4
- data.tar.gz: 1666b7fd1b40b3a611d04521041ad64a5a0c6536c3d2fafb3b412ce184c371d5
3
+ metadata.gz: 886440c38c24dd0ad30e2fff8552fe89aa924010e8f9218032d030eceb29a422
4
+ data.tar.gz: b854a7f800c8aa3364ed78ebf29b4ec731e3904b7c33b682a39383a5096742d0
5
5
  SHA512:
6
- metadata.gz: a40f9e0a498e0ede200cfd6382c24af3b1ab39163b4ac040b4817dff08dc7c320136b3e8516eb5a4fd4f443083da54cf5a822a5736a4795d93de8c7e2581e38b
7
- data.tar.gz: c51a2d657031506b792960ccd3975521e8cfe5f9c5ffdfd06a7d48a6eacadef63811c4467acc34145d17022de8c3f92ac3a5ccf56316f4766b5ccbefe5fbc9a9
6
+ metadata.gz: 895f1193c0924d93b0bc4c29dea5d9de8b38b047f25c83273d13146c3bffa0a201676e320d6f4a6f373f231f87fdf0fda7bbef5a8983e40d489c8179097ed8dc
7
+ data.tar.gz: c1d2d3cd0014d31e01456dd5f0d1e58a59e820bb77f8e15a2755a27421a107b63d6cc653f115a7e50f19b475f40b1961bbdccab3b92181f1a5f812f68a668604
@@ -3,6 +3,14 @@ on: [push]
3
3
  jobs:
4
4
  specs:
5
5
  runs-on: ubuntu-22.04
6
+ services:
7
+ redis:
8
+ image: redis
9
+ ports: ['6379:6379']
10
+ options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
11
+ memcached:
12
+ image: memcached
13
+ ports: ['11211:11211']
6
14
  steps:
7
15
  - uses: actions/checkout@v2
8
16
  - name: Set up Ruby
data/README.md CHANGED
@@ -100,16 +100,42 @@ After that it fetches the [CRL](https://en.wikipedia.org/wiki/Certificate_revoca
100
100
 
101
101
  ### Caching
102
102
 
103
- OCSP and CRL responses are cached in memory, which makes subsequent testing faster and more robust (avoids network error and throttling) but be careful about memory usage if you try to validate millions of certificates in a row.
103
+ OCSP and CRL responses are cached, which makes subsequent testing faster and more robust (avoids network error and throttling).
104
104
 
105
105
  About the caching duration:
106
106
  - OCSP responses are cached until their "next_update" indicated inside the repsonse
107
107
  - OCSP errors are cached for 5 minutes
108
108
  - CRL responses are cached for 1 hour
109
109
 
110
- CRL responses can be big so when they expires they are re-validated with the server using HTTP caching headers when available (`Etag` & `Last-Modified`) to avoid downloading the list again if it didn't change.
110
+ CRL responses can be big so when they expires they are re-validated with the server using HTTP caching headers when available (`Etag` & `Last-Modified`) to avoid downloading the list again if it didn't change. The cached body is therefore kept in the backend for a longer retention period (~4 days, refreshed on each use) so it's still around to revalidate against; unused lists are dropped after that.
111
111
 
112
- You can check the size of the cache with `SSLTest.cache_size`, which returns:
112
+ #### Cache backend
113
+
114
+ The cache backend is configurable. By default SSLTest uses a simple in-process store (`SSLTest::MemoryStore`). To share the cache across processes and get compression, assign any object implementing the `Rails.cache`-style API (`read`, `write(key, value, expires_in:)`, `delete`):
115
+
116
+ ```ruby
117
+ SSLTest.cache = Rails.cache # shared + compressed (e.g. memcache via Dalli)
118
+ SSLTest.cache = SSLTest::MemoryStore.new # the default in-process store
119
+ SSLTest.cache = MyCustomStore.new # anything responding to read/write/delete
120
+ ```
121
+
122
+ The default in-process store is per-process and unbounded, so be careful about memory usage if you try to validate millions of certificates in a row (the OCSP cache is keyed by certificate serial). Using a shared store like `Rails.cache` with memcache avoids this and shares the cache across processes.
123
+
124
+ If you want a bounded/compressed in-process cache without pulling in `Rails.cache`, the API is intentionally compatible with `ActiveSupport::Cache::MemoryStore`, which you can drop in directly:
125
+
126
+ ```ruby
127
+ require "active_support/cache"
128
+ SSLTest.cache = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes, compress: true)
129
+ ```
130
+
131
+ (It auto-prunes when it exceeds `size`, unlike the built-in store. Note the introspection helpers below are specific to `SSLTest::MemoryStore`.)
132
+
133
+ > **Using memcached (Dalli)?** CRL lists can be large (commonly several MB, up to ~20MB for busy CAs). memcached rejects values over its max item size — 1MB by default — which Dalli surfaces as `Dalli::ValueOverMaxSize` (logged and skipped by `ActiveSupport::Cache::MemCacheStore`, so the test still passes but the list isn't cached and gets re-downloaded every time). To actually cache big CRLs, raise the limit on **both** sides to at least 64MB:
134
+ >
135
+ > - memcached server: start it with `-I 64m`
136
+ > - Dalli client: `Dalli::Client.new(servers, value_max_bytes: 64 * 1024 * 1024)`, or via Rails: `config.cache_store = :mem_cache_store, servers, { value_max_bytes: 64.megabytes }`
137
+
138
+ You can check the size of the **built-in** store with `SSLTest.cache.size`, which returns:
113
139
 
114
140
  ```ruby
115
141
  {
@@ -125,7 +151,9 @@ You can check the size of the cache with `SSLTest.cache_size`, which returns:
125
151
  }
126
152
  ```
127
153
 
128
- You can also flush the cache using `SSLTest.flush_cache` if you want (not recommended)
154
+ You can also flush it using `SSLTest.cache.clear` if you want (not recommended).
155
+
156
+ `size` is specific to the built-in `MemoryStore`; other backends won't respond to it. (The module-level `SSLTest.cache_size` and `SSLTest.flush_cache` from previous versions were **removed in 2.0** — use `SSLTest.cache.size` / `SSLTest.cache.clear` instead.)
129
157
 
130
158
  ### Logging
131
159
 
@@ -167,6 +195,7 @@ But also **revoked certs** like most browsers (not handled by `curl`)
167
195
 
168
196
  See also github releases: https://github.com/jarthod/ssl-test/releases
169
197
 
198
+ * 2.0.0 - 2026-06-16: Make the cache backend configurable. The default stays an in-process `SSLTest::MemoryStore`, but you can now assign any object responding to the `Rails.cache`-style API (`read`/`write`/`delete`) with `SSLTest.cache = Rails.cache` to share responses across processes and get compression (e.g. memcache via Dalli — see the memcached note in the Caching section about raising the max value size for large CRLs). **Breaking:** the module-level `SSLTest.cache_size` and `SSLTest.flush_cache` were removed — use `SSLTest.cache.size` and `SSLTest.cache.clear` instead (these only work with the built-in `MemoryStore`; shared backends like `Rails.cache` can't be enumerated and shouldn't be wholesale-cleared)
170
199
  * 1.6.0 - 2026-06-16: Check revocation with CRL first and fall back to OCSP (was OCSP first) to reduce revocation detection delay
171
200
  * 1.5.0 - 2025-11-28: Add support for local certificates testing and HTTP proxies (#8), changed `#test` method into `#test_url` and `#test_cert` (`#test` remains as an alias for `#test_url` for backward-compatibility)
172
201
  * 1.4.1 - 2022-10-24: Add support for "tcps://" scheme
data/lib/ssl-test/crl.rb CHANGED
@@ -1,6 +1,13 @@
1
1
  module SSLTest
2
2
  module CRL
3
3
  CRL_CACHE_DURATION = 3600 # 1 hour
4
+ # How long a CRL entry is kept in the backend before being dropped if it's no
5
+ # longer used. This is much longer than CRL_CACHE_DURATION so the cached body
6
+ # and caching headers survive past the revalidation window (for cheap 304s),
7
+ # but bounded so unused lists don't pile up forever in a shared/long-lived
8
+ # backend (e.g. memcache). It's refreshed on every fetch (200/304), so
9
+ # actively-used entries never expire from this.
10
+ CRL_CACHE_RETENTION = 100 * CRL_CACHE_DURATION # ~4 days
4
11
 
5
12
  # A note about caching:
6
13
  # I choose to only cache the raw HTTP body here (and not the parsed list or better a hash
@@ -54,10 +61,14 @@ module SSLTest
54
61
  def follow_crl_redirects(uri, open_timeout: 5, read_timeout: 5, redirection_limit: 5, proxy_host: nil, proxy_port: nil)
55
62
  return [nil, "Too many redirections (> #{redirection_limit})"] if redirection_limit == 0
56
63
 
57
- # Return file from cache if not expired
58
- @crl_response_cache ||= {}
59
- cache_entry = @crl_response_cache[uri]
60
- return [cache_entry[:body], nil] if cache_entry && cache_entry.fetch(:expires) > Time.now
64
+ # Return file from cache if not expired.
65
+ # CRL entries are kept in the backend for CRL_CACHE_RETENTION (much longer
66
+ # than CRL_CACHE_DURATION) so the cached body + caching headers survive past
67
+ # the freshness window and can be revalidated cheaply with a conditional
68
+ # request (304). We track our own freshness window with the :expires field.
69
+ cache_key = "#{CACHE_NAMESPACE}/crl/#{uri}"
70
+ cache_entry = cache.read(cache_key)
71
+ return [cache_entry[:body], nil] if cache_entry && cache_entry[:expires] > Time.now
61
72
 
62
73
  @logger&.debug { "SSLTest + CRL: fetch URI #{uri}" }
63
74
  path = uri.path == "" ? "/" : uri.path
@@ -67,9 +78,9 @@ module SSLTest
67
78
 
68
79
  req = Net::HTTP::Get.new(path)
69
80
  # Include conditional caching headers from cache to save bandwidth if list didn't change (304)
70
- if etag = cache_entry&.fetch(:etag)
81
+ if etag = cache_entry&.[](:etag)
71
82
  req["If-None-Match"] = etag
72
- elsif last_mod = cache_entry&.fetch(:last_mod)
83
+ elsif last_mod = cache_entry&.[](:last_mod)
73
84
  req["If-Modified-Since"] = last_mod
74
85
  end
75
86
  http_response = http.request(req)
@@ -77,19 +88,19 @@ module SSLTest
77
88
  when Net::HTTPNotModified
78
89
  # No changes, bump cache expiration time and return cached body
79
90
  @logger&.debug { "SSLTest + CRL: 304 Not Modified" }
80
- @crl_response_cache[uri][:expires] = Time.now + CRL_CACHE_DURATION
91
+ cache.write(cache_key, cache_entry.merge(expires: Time.now + CRL_CACHE_DURATION), expires_in: CRL_CACHE_RETENTION)
81
92
  [cache_entry[:body], nil]
82
93
  when Net::HTTPSuccess
83
94
  # Success, update (or add to) cache and return frech body
84
95
  @logger&.debug { "SSLTest + CRL: 200 OK (#{http_response.body.bytesize} bytes)" }
85
96
  @logger&.warn { "SSLTest + CRL: Warning: massive file size" } if http_response.body.bytesize > 1024**2 # 1MB
86
97
  @logger&.warn { "SSLTest + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
87
- @crl_response_cache[uri] = {
98
+ cache.write(cache_key, {
88
99
  body: http_response.body,
89
100
  expires: Time.now + CRL_CACHE_DURATION,
90
101
  etag: http_response["Etag"],
91
102
  last_mod: http_response["Last-Modified"]
92
- }
103
+ }, expires_in: CRL_CACHE_RETENTION)
93
104
  [http_response.body, nil]
94
105
  when Net::HTTPRedirection
95
106
  follow_crl_redirects(URI(http_response["location"]), open_timeout: open_timeout, read_timeout: read_timeout, proxy_host: proxy_host, proxy_port: proxy_port, redirection_limit: redirection_limit - 1)
@@ -0,0 +1,81 @@
1
+ module SSLTest
2
+ # A tiny in-process cache store used as the default backend. It mirrors the
3
+ # small subset of the ActiveSupport::Cache / Rails.cache API that SSLTest relies
4
+ # on (read/write/delete) so they're interchangeable (assign Rails.cache via
5
+ # SSLTest.cache= to share/compress across processes). Access is guarded by a
6
+ # Mutex because SSLTest is typically used from threaded servers (e.g. Puma).
7
+ #
8
+ # Unlike a shared/compressed backend (memcache via Dalli), this store is
9
+ # per-process, uncompressed and unbounded, so be careful about memory usage if
10
+ # you validate millions of certificates in a row (the OCSP cache is keyed by
11
+ # certificate serial). For those workloads, configure SSLTest.cache to a shared
12
+ # store instead.
13
+ class MemoryStore
14
+ def initialize
15
+ @data = {}
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def read(key)
20
+ @mutex.synchronize do
21
+ entry = @data[key]
22
+ next nil unless entry
23
+ if entry[:expires_at] && entry[:expires_at] <= Time.now
24
+ @data.delete(key)
25
+ next nil
26
+ end
27
+ entry[:value]
28
+ end
29
+ end
30
+
31
+ def write(key, value, expires_in: nil)
32
+ @mutex.synchronize do
33
+ @data[key] = { value: value, expires_at: expires_in && Time.now + expires_in }
34
+ end
35
+ value
36
+ end
37
+
38
+ def delete(key)
39
+ @mutex.synchronize { @data.delete(key) }
40
+ end
41
+
42
+ def clear
43
+ @mutex.synchronize { @data.clear }
44
+ end
45
+
46
+ # Yields [key, value] for every entry that hasn't expired.
47
+ def each
48
+ return enum_for(:each) unless block_given?
49
+ now = Time.now
50
+ @mutex.synchronize { @data.dup }.each do |key, entry|
51
+ next if entry[:expires_at] && entry[:expires_at] <= now
52
+ yield key, entry[:value]
53
+ end
54
+ end
55
+
56
+ # Returns a breakdown of the cached SSLTest entries (CRL lists and OCSP
57
+ # responses/errors) with approximate byte sizes, mainly useful for monitoring
58
+ # memory usage. Specific to ssl-test's key namespace.
59
+ def size
60
+ crl_lists = ocsp_responses = ocsp_errors = 0
61
+ crl_bytes = ocsp_bytes = 0
62
+ each do |key, value|
63
+ case key
64
+ when %r{\A#{CACHE_NAMESPACE}/crl/}
65
+ crl_lists += 1
66
+ crl_bytes += ObjectSize.size(value)
67
+ when %r{\A#{CACHE_NAMESPACE}/ocsp-error/}
68
+ ocsp_errors += 1
69
+ ocsp_bytes += ObjectSize.size(value)
70
+ when %r{\A#{CACHE_NAMESPACE}/ocsp/}
71
+ ocsp_responses += 1
72
+ ocsp_bytes += ObjectSize.size(value)
73
+ end
74
+ end
75
+ {
76
+ crl: { lists: crl_lists, bytes: crl_bytes },
77
+ ocsp: { responses: ocsp_responses, errors: ocsp_errors, bytes: ocsp_bytes }
78
+ }
79
+ end
80
+ end
81
+ end
data/lib/ssl-test/ocsp.rb CHANGED
@@ -5,15 +5,18 @@ module SSLTest
5
5
  private
6
6
 
7
7
  def test_ocsp_revocation cert, issuer:, chain:, **options
8
- @ocsp_response_cache ||= {}
9
- @ocsp_request_error_cache ||= {}
10
-
11
8
  unicity_key = "#{cert.issuer}/#{cert.serial}"
9
+ error_cache_key = "#{CACHE_NAMESPACE}/ocsp-error/#{unicity_key}"
10
+ response_cache_key = "#{CACHE_NAMESPACE}/ocsp/#{unicity_key}"
12
11
 
13
- current_request_error_cache = @ocsp_request_error_cache[unicity_key]
14
- return current_request_error_cache[:error] if current_request_error_cache && Time.now <= current_request_error_cache[:expires]
12
+ # Expiry is handled by the cache backend (expires_in), so a cached value is
13
+ # always still valid: a present error means we recently failed, a present
14
+ # response means it hasn't reached its next_update yet.
15
+ cached_error = cache.read(error_cache_key)
16
+ return cached_error if cached_error
15
17
 
16
- if @ocsp_response_cache[unicity_key].nil? || @ocsp_response_cache[unicity_key][:next_update] <= Time.now
18
+ ocsp_response = cache.read(response_cache_key)
19
+ if ocsp_response.nil?
17
20
  authority_info_access = cert.extensions.find do |extension|
18
21
  extension.oid == "authorityInfoAccess"
19
22
  end
@@ -57,11 +60,13 @@ module SSLTest
57
60
 
58
61
  return ocsp_soft_fail_return("Serial check failed (URI: #{ocsp_uri})", unicity_key) unless response_certificate_id.serial == certificate_id.serial
59
62
 
60
- @ocsp_response_cache[unicity_key] = { status: status, reason: reason, revocation_time: revocation_time, next_update: next_update }
63
+ ocsp_response = { status: status, reason: reason, revocation_time: revocation_time }
64
+ # Cache until the response's next_update. If it's already past (or
65
+ # missing), skip caching and just use the fresh result for this call.
66
+ ttl = next_update && next_update - Time.now
67
+ cache.write(response_cache_key, ocsp_response, expires_in: ttl) if ttl && ttl > 0
61
68
  end
62
69
 
63
- ocsp_response = @ocsp_response_cache[unicity_key]
64
-
65
70
  return [true, revocation_reason_to_string(ocsp_response[:reason]), ocsp_response[:revocation_time]] if ocsp_response[:status] == OpenSSL::OCSP::V_CERTSTATUS_REVOKED
66
71
  :ocsp_ok
67
72
  end
@@ -135,7 +140,7 @@ module SSLTest
135
140
 
136
141
  def ocsp_soft_fail_return(reason, unicity_key = nil)
137
142
  error = [false, reason, nil]
138
- @ocsp_request_error_cache[unicity_key] = { error: error, expires: Time.now + ERROR_CACHE_DURATION } if unicity_key
143
+ cache.write("#{CACHE_NAMESPACE}/ocsp-error/#{unicity_key}", error, expires_in: ERROR_CACHE_DURATION) if unicity_key
139
144
  error
140
145
  end
141
146
  end
data/lib/ssl-test.rb CHANGED
@@ -3,6 +3,7 @@ require "net/https"
3
3
  require "openssl"
4
4
  require "uri"
5
5
  require "ssl-test/object_size"
6
+ require "ssl-test/memory_store"
6
7
  require "ssl-test/ocsp"
7
8
  require "ssl-test/crl"
8
9
 
@@ -10,7 +11,11 @@ module SSLTest
10
11
  extend OCSP
11
12
  extend CRL
12
13
 
13
- VERSION = -"1.6.0"
14
+ VERSION = -"2.0.0"
15
+
16
+ # Prefix for all cache keys so SSLTest entries coexist cleanly inside a shared
17
+ # cache (e.g. Rails.cache).
18
+ CACHE_NAMESPACE = -"ssl-test"
14
19
 
15
20
  class << self
16
21
  def test_url url, open_timeout: 5, read_timeout: 5, proxy_host: nil, proxy_port: nil, redirection_limit: 5
@@ -79,24 +84,30 @@ module SSLTest
79
84
  end
80
85
  end
81
86
 
87
+ # The cache backend used to store CRL and OCSP responses. Defaults to an
88
+ # in-process MemoryStore. To share the cache across processes (and get
89
+ # compression), assign Rails.cache (or any object responding to the
90
+ # Rails.cache-style API: read/write/delete), e.g. `SSLTest.cache = Rails.cache`.
91
+ def cache
92
+ @cache ||= MemoryStore.new
93
+ end
94
+
95
+ def cache= store
96
+ @cache = store
97
+ end
98
+
99
+ # Removed in 2.0: introspection now lives on the cache store. With the
100
+ # built-in MemoryStore use SSLTest.cache.size; other backends (e.g. memcache)
101
+ # can't be enumerated.
82
102
  def cache_size
83
- {
84
- crl: {
85
- lists: @crl_response_cache&.size || 0,
86
- bytes: ObjectSize.size(@crl_response_cache)
87
- },
88
- ocsp: {
89
- responses: @ocsp_response_cache&.size || 0,
90
- errors: @ocsp_request_error_cache&.size || 0,
91
- bytes: ObjectSize.size(@ocsp_response_cache) + ObjectSize.size(@ocsp_request_error_cache)
92
- }
93
- }
103
+ raise NoMethodError, "SSLTest.cache_size was removed in 2.0; use SSLTest.cache.size instead (available on the built-in SSLTest::MemoryStore)."
94
104
  end
95
105
 
106
+ # Removed in 2.0: clearing now lives on the cache store. With the built-in
107
+ # MemoryStore use SSLTest.cache.clear (note: calling clear on a shared backend
108
+ # like Rails.cache would wipe unrelated entries).
96
109
  def flush_cache
97
- @crl_response_cache = {}
98
- @ocsp_response_cache = {}
99
- @ocsp_request_error_cache = {}
110
+ raise NoMethodError, "SSLTest.flush_cache was removed in 2.0; use SSLTest.cache.clear instead."
100
111
  end
101
112
 
102
113
  def logger= logger
@@ -0,0 +1,103 @@
1
+ require "ssl-test"
2
+ require "active_support"
3
+ require "active_support/cache"
4
+ require "tmpdir"
5
+
6
+ # Verifies the cache backends people are likely to plug into SSLTest.cache (the
7
+ # classic Rails/ActiveSupport stores) satisfy the read / write / expiration
8
+ # contract SSLTest relies on, including (de)serialization of the value shapes it
9
+ # stores: Hashes containing Strings (incl. binary CRL bodies), Times, Integers
10
+ # and nils, plus Arrays (OCSP errors).
11
+ #
12
+ # Stores backed by an external server (MemCacheStore, RedisCacheStore) or an
13
+ # extra gem are skipped when unavailable, so the suite stays green locally; CI
14
+ # provides the servers (see .github/workflows/ruby.yml) so they actually run.
15
+ describe "ActiveSupport cache backend compatibility" do
16
+ # Representative of what SSLTest caches: a CRL entry (binary body + Time) and
17
+ # an OCSP error entry (an Array). Fixed Time so serialization round-trips are
18
+ # deterministic.
19
+ let(:crl_entry) do
20
+ { body: ("\x30\x82\x01\x02".b * 50), expires: Time.utc(2030, 1, 1, 12), etag: 'W/"abc123"', last_mod: nil }
21
+ end
22
+ let(:ocsp_error) { [false, "Request failed (URI: http://ocsp.example.com)", nil] }
23
+
24
+ # Stores that actually persist values (NullStore intentionally doesn't).
25
+ CACHING_STORES = %w[MemoryStore FileStore MemCacheStore RedisCacheStore]
26
+
27
+ def build_store(name)
28
+ case name
29
+ when "MemoryStore"
30
+ ActiveSupport::Cache::MemoryStore.new
31
+ when "FileStore"
32
+ ActiveSupport::Cache::FileStore.new(Dir.mktmpdir("ssl-test-cache"))
33
+ when "NullStore"
34
+ ActiveSupport::Cache::NullStore.new
35
+ when "MemCacheStore"
36
+ require "dalli"
37
+ ActiveSupport::Cache::MemCacheStore.new(ENV.fetch("MEMCACHE_SERVERS", "127.0.0.1:11211"))
38
+ when "RedisCacheStore"
39
+ require "redis"
40
+ ActiveSupport::Cache::RedisCacheStore.new(url: ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/15"))
41
+ end
42
+ end
43
+
44
+ around do |example|
45
+ previous = SSLTest.cache
46
+ example.run
47
+ ensure
48
+ SSLTest.cache = previous
49
+ end
50
+
51
+ (CACHING_STORES + %w[NullStore]).each do |name|
52
+ context name do
53
+ before do
54
+ begin
55
+ SSLTest.cache = build_store(name)
56
+ rescue LoadError => e
57
+ skip "#{name} unavailable: #{e.message}"
58
+ end
59
+
60
+ # For server-backed stores, ActiveSupport silently treats a missing
61
+ # server as a cache miss; probe so we skip (rather than fail) when the
62
+ # server isn't running.
63
+ if CACHING_STORES.include?(name)
64
+ SSLTest.cache.write("ssl-test/probe", "ok", expires_in: 60)
65
+ skip "#{name} server not reachable" unless SSLTest.cache.read("ssl-test/probe") == "ok"
66
+ end
67
+ end
68
+
69
+ if name == "NullStore"
70
+ it "acts as a no-op (the gem still works, just without caching)" do
71
+ SSLTest.cache.write("ssl-test/crl/x", crl_entry, expires_in: nil)
72
+ expect(SSLTest.cache.read("ssl-test/crl/x")).to be_nil
73
+ end
74
+ else
75
+ it "round-trips a CRL entry (binary body + Time serialization)" do
76
+ SSLTest.cache.write("ssl-test/crl/x", crl_entry, expires_in: 100 * 3600)
77
+ expect(SSLTest.cache.read("ssl-test/crl/x")).to eq(crl_entry)
78
+ end
79
+
80
+ it "round-trips an OCSP error entry (Array serialization)" do
81
+ SSLTest.cache.write("ssl-test/ocsp-error/y", ocsp_error, expires_in: 300)
82
+ expect(SSLTest.cache.read("ssl-test/ocsp-error/y")).to eq(ocsp_error)
83
+ end
84
+
85
+ it "returns nil for a missing key" do
86
+ expect(SSLTest.cache.read("ssl-test/ocsp/missing")).to be_nil
87
+ end
88
+
89
+ it "persists entries written with no expiry (expires_in: nil)" do
90
+ SSLTest.cache.write("ssl-test/crl/persist", crl_entry, expires_in: nil)
91
+ expect(SSLTest.cache.read("ssl-test/crl/persist")).to eq(crl_entry)
92
+ end
93
+
94
+ it "honors expires_in" do
95
+ SSLTest.cache.write("ssl-test/ocsp/z", { status: 0 }, expires_in: 0.1)
96
+ expect(SSLTest.cache.read("ssl-test/ocsp/z")).to eq({ status: 0 })
97
+ sleep 0.2
98
+ expect(SSLTest.cache.read("ssl-test/ocsp/z")).to be_nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,79 @@
1
+ require "ssl-test"
2
+
3
+ describe SSLTest::MemoryStore do
4
+ subject(:store) { described_class.new }
5
+
6
+ it "round-trips written values" do
7
+ store.write("k", "v")
8
+ expect(store.read("k")).to eq("v")
9
+ end
10
+
11
+ it "returns nil for missing keys" do
12
+ expect(store.read("missing")).to be_nil
13
+ end
14
+
15
+ it "expires entries after expires_in" do
16
+ store.write("k", "v", expires_in: -1) # already expired
17
+ expect(store.read("k")).to be_nil
18
+ end
19
+
20
+ it "keeps entries with no expiry" do
21
+ store.write("k", "v", expires_in: nil)
22
+ expect(store.read("k")).to eq("v")
23
+ end
24
+
25
+ it "deletes and clears entries" do
26
+ store.write("a", 1)
27
+ store.write("b", 2)
28
+ store.delete("a")
29
+ expect(store.read("a")).to be_nil
30
+ expect(store.read("b")).to eq(2)
31
+ store.clear
32
+ expect(store.read("b")).to be_nil
33
+ end
34
+
35
+ it "iterates non-expired entries with #each" do
36
+ store.write("live", 1)
37
+ store.write("dead", 2, expires_in: -1)
38
+ expect(store.each.to_a).to eq([["live", 1]])
39
+ end
40
+
41
+ it "#size reports a CRL/OCSP breakdown" do
42
+ store.write("ssl-test/crl/http://example.com/x.crl", "body")
43
+ store.write("ssl-test/ocsp/issuer/1", { status: 0 })
44
+ store.write("ssl-test/ocsp-error/issuer/2", [false, "err", nil])
45
+ store.write("unrelated/key", "ignored")
46
+ expect(store.size).to match({
47
+ crl: { lists: 1, bytes: be > 0 },
48
+ ocsp: { responses: 1, errors: 1, bytes: be > 0 }
49
+ })
50
+ end
51
+ end
52
+
53
+ # #size as reported through the default store after real CRL/OCSP fetches.
54
+ describe "SSLTest.cache.size" do
55
+ before { SSLTest.cache.clear }
56
+
57
+ it "returns 0 by default" do
58
+ expect(SSLTest.cache.size).to eq({
59
+ crl: { bytes: 0, lists: 0 },
60
+ ocsp: { bytes: 0, errors: 0, responses: 0 }
61
+ })
62
+ end
63
+
64
+ it "returns CRL cache size properly" do
65
+ SSLTest.send(:follow_crl_redirects, URI("http://crl.certigna.fr/certigna.crl")) # 1.1k
66
+ SSLTest.send(:follow_crl_redirects, URI("http://crl3.digicert.com/DigiCertTLSHybridECCSHA3842020CA1-1.crl")) # 26k
67
+ expect(SSLTest.cache.size[:crl][:lists]).to eq(2)
68
+ expect(SSLTest.cache.size[:crl][:bytes]).to be > 2000
69
+ end
70
+
71
+ it "returns OCSP cache size properly" do
72
+ SSLTest.test("https://github.com")
73
+ expect(SSLTest.cache.size[:ocsp][:responses]).to eq(1)
74
+ expect(SSLTest.cache.size[:ocsp][:errors]).to eq(0)
75
+ expect(SSLTest.cache.size[:ocsp][:bytes]).to be > 0
76
+ expect(SSLTest.cache.size[:crl][:lists]).to eq(1)
77
+ expect(SSLTest.cache.size[:crl][:bytes]).to be > 100
78
+ end
79
+ end
@@ -15,12 +15,11 @@ RSpec.configure do |config|
15
15
  # one, and examples tagged `:retry` are re-run a few times (via rspec-retry) so
16
16
  # transient network blips don't fail the suite.
17
17
  config.verbose_retry = true
18
- config.display_try_failure_messages = true
19
18
  config.default_sleep_interval = 1
20
19
  end
21
20
 
22
21
  describe SSLTest do
23
- before { SSLTest.flush_cache }
22
+ before { SSLTest.cache.clear }
24
23
 
25
24
  let(:proxy_thread) { nil }
26
25
 
@@ -186,7 +185,7 @@ describe SSLTest do
186
185
  expect(valid).to eq(true)
187
186
  expect(cert).to be_a OpenSSL::X509::Certificate
188
187
  # make sure both were used
189
- expect(SSLTest.cache_size).to match({
188
+ expect(SSLTest.cache.size).to match({
190
189
  crl: hash_including(lists: 1),
191
190
  ocsp: hash_including(responses: 1, errors: 0)
192
191
  })
@@ -237,35 +236,8 @@ describe SSLTest do
237
236
  end
238
237
  end
239
238
 
240
- describe '.cache_size' do
241
- before { SSLTest.flush_cache }
242
-
243
- it "returns 0 by default" do
244
- expect(SSLTest.cache_size).to eq({
245
- crl: { bytes: 0, lists: 0 },
246
- ocsp: { bytes: 0, errors: 0, responses: 0 }
247
- })
248
- end
249
-
250
- it "returns CRL cache size properly" do
251
- SSLTest.send(:follow_crl_redirects, URI("http://crl.certigna.fr/certigna.crl")) # 1.1k
252
- SSLTest.send(:follow_crl_redirects, URI("http://crl3.digicert.com/DigiCertTLSHybridECCSHA3842020CA1-1.crl")) # 26k
253
- expect(SSLTest.cache_size[:crl][:lists]).to eq(2)
254
- expect(SSLTest.cache_size[:crl][:bytes]).to be > 2000
255
- end
256
-
257
- it "returns OCSP cache size properly" do
258
- SSLTest.test("https://github.com")
259
- expect(SSLTest.cache_size[:ocsp][:responses]).to eq(1)
260
- expect(SSLTest.cache_size[:ocsp][:errors]).to eq(0)
261
- expect(SSLTest.cache_size[:ocsp][:bytes]).to be > 150
262
- expect(SSLTest.cache_size[:crl][:lists]).to eq(1)
263
- expect(SSLTest.cache_size[:crl][:bytes]).to be > 500
264
- end
265
- end
266
-
267
239
  describe '.follow_crl_redirects' do
268
- before { SSLTest.flush_cache }
240
+ before { SSLTest.cache.clear }
269
241
  # 19MB: http://crl3.digicert.com/ssca-sha2-g6.crl
270
242
  it "fetch CRL list and updates cache" do
271
243
  uri = URI("http://crl.certigna.fr/certigna.crl")
@@ -274,11 +246,11 @@ describe SSLTest do
274
246
  expect(error).to be_nil
275
247
 
276
248
  # Check cache status
277
- cache = SSLTest.instance_variable_get('@crl_response_cache')
278
- expect(cache.size).to equal 1
279
- expect(cache.keys).to match_array [uri]
280
- expect(cache[uri].keys).to match_array [:body, :expires, :etag, :last_mod]
281
- expect(cache[uri][:expires]).to be > (Time.now + 3590)
249
+ cache_key = "ssl-test/crl/#{uri}"
250
+ entry = SSLTest.cache.read(cache_key)
251
+ expect(entry).not_to be_nil
252
+ expect(entry.keys).to match_array [:body, :expires, :etag, :last_mod]
253
+ expect(entry[:expires]).to be > (Time.now + 3590)
282
254
 
283
255
  # Make sure we return value from cache
284
256
  body2, error2 = nil, nil
@@ -287,7 +259,7 @@ describe SSLTest do
287
259
  expect(body2).to be(body) # using cache
288
260
 
289
261
  # Make sure we return cached value in case of 304
290
- cache[uri][:expires] = Time.now # cache is now expired
262
+ SSLTest.cache.write(cache_key, entry.merge(expires: Time.now), expires_in: nil) # cache is now expired
291
263
  body2, error2 = nil, nil
292
264
  time = Benchmark.realtime { body2, error2 = SSLTest.send(:follow_crl_redirects, uri) }
293
265
  expect(time).to be > 0.001 # a request is made
@@ -295,6 +267,33 @@ describe SSLTest do
295
267
  end
296
268
  end
297
269
 
270
+ describe '.cache' do
271
+ # Restore the default in-process store after tests that swap the backend so
272
+ # global state doesn't leak between examples.
273
+ after { SSLTest.cache = SSLTest::MemoryStore.new }
274
+
275
+ it "defaults to an in-process MemoryStore" do
276
+ SSLTest.instance_variable_set(:@cache, nil) # reset memoized default
277
+ expect(SSLTest.cache).to be_a SSLTest::MemoryStore
278
+ end
279
+
280
+ it "uses the configured backend for CRL and OCSP" do
281
+ store = SSLTest::MemoryStore.new
282
+ SSLTest.cache = store
283
+ expect(store).to receive(:write).at_least(:once).and_call_original
284
+ expect(store).to receive(:read).at_least(:once).and_call_original
285
+ SSLTest.test("https://github.com")
286
+ end
287
+
288
+ it "cache_size (removed in 2.0) raises pointing to cache.size" do
289
+ expect { SSLTest.cache_size }.to raise_error(NoMethodError, /SSLTest\.cache\.size/)
290
+ end
291
+
292
+ it "flush_cache (removed in 2.0) raises pointing to cache.clear" do
293
+ expect { SSLTest.flush_cache }.to raise_error(NoMethodError, /SSLTest\.cache\.clear/)
294
+ end
295
+ end
296
+
298
297
  describe '.test_cert' do
299
298
  it "returns no error on valid SNI website" do
300
299
  cert = OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, 'fixtures/www_mycs_com_client.pem')))
@@ -439,7 +438,7 @@ describe SSLTest do
439
438
  expect(valid).to eq(true)
440
439
  expect(cert).to eq(cert)
441
440
  # make sure both were used
442
- expect(SSLTest.cache_size).to match({
441
+ expect(SSLTest.cache.size).to match({
443
442
  crl: hash_including(lists: 1),
444
443
  ocsp: hash_including(responses: 1, errors: 0)
445
444
  })
data/ssl-test.gemspec CHANGED
@@ -22,4 +22,10 @@ Gem::Specification.new do |spec|
22
22
  spec.add_development_dependency "rspec"
23
23
  spec.add_development_dependency "rspec-retry"
24
24
  spec.add_development_dependency "webrick"
25
+ # Used to verify SSLTest.cache works with the classic Rails/ActiveSupport
26
+ # cache stores (MemoryStore, FileStore, NullStore, MemCacheStore via dalli,
27
+ # RedisCacheStore via redis).
28
+ spec.add_development_dependency "activesupport"
29
+ spec.add_development_dependency "dalli"
30
+ spec.add_development_dependency "redis"
25
31
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ssl-test
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrien Rey-Jarthon
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-06-16 00:00:00.000000000 Z
10
+ date: 2026-06-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bundler
@@ -79,6 +79,48 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: activesupport
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: dalli
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: redis
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
82
124
  email:
83
125
  - jobs@adrienjarthon.com
84
126
  executables: []
@@ -94,8 +136,10 @@ files:
94
136
  - Rakefile
95
137
  - lib/ssl-test.rb
96
138
  - lib/ssl-test/crl.rb
139
+ - lib/ssl-test/memory_store.rb
97
140
  - lib/ssl-test/object_size.rb
98
141
  - lib/ssl-test/ocsp.rb
142
+ - spec/cache_backends_spec.rb
99
143
  - spec/fixtures/digicert_com_ca_bundle.pem
100
144
  - spec/fixtures/digicert_com_client.pem
101
145
  - spec/fixtures/expired_cert_ca_bundle.pem
@@ -116,6 +160,7 @@ files:
116
160
  - spec/fixtures/www_github_com_client.pem
117
161
  - spec/fixtures/www_mycs_com_ca_bundle.pem
118
162
  - spec/fixtures/www_mycs_com_client.pem
163
+ - spec/memory_store_spec.rb
119
164
  - spec/ssl-test_spec.rb
120
165
  - ssl-test.gemspec
121
166
  homepage: https://github.com/jarthod/ssl-test
@@ -140,6 +185,7 @@ rubygems_version: 3.6.2
140
185
  specification_version: 4
141
186
  summary: Test website SSL certificate validity
142
187
  test_files:
188
+ - spec/cache_backends_spec.rb
143
189
  - spec/fixtures/digicert_com_ca_bundle.pem
144
190
  - spec/fixtures/digicert_com_client.pem
145
191
  - spec/fixtures/expired_cert_ca_bundle.pem
@@ -160,4 +206,5 @@ test_files:
160
206
  - spec/fixtures/www_github_com_client.pem
161
207
  - spec/fixtures/www_mycs_com_ca_bundle.pem
162
208
  - spec/fixtures/www_mycs_com_client.pem
209
+ - spec/memory_store_spec.rb
163
210
  - spec/ssl-test_spec.rb