From e43e636e327719af0471391df8a4824183a4cdd7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 10:39:43 +1200 Subject: [PATCH 1/3] Add value equality for TLS configurations and trust stores --- lib/io/endpoint/tls/configuration.rb | 31 +++++++++ lib/io/endpoint/tls/trust_store.rb | 27 ++++++++ releases.md | 4 ++ test/io/endpoint/tls/configuration.rb | 94 +++++++++++++++++++++++++++ test/io/endpoint/tls/trust_store.rb | 62 ++++++++++++++++++ 5 files changed, 218 insertions(+) diff --git a/lib/io/endpoint/tls/configuration.rb b/lib/io/endpoint/tls/configuration.rb index d7b13ba..a2fbf9a 100644 --- a/lib/io/endpoint/tls/configuration.rb +++ b/lib/io/endpoint/tls/configuration.rb @@ -59,6 +59,37 @@ def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verif # @attribute [Symbol | Nil] The peer verification policy. attr :verification + # Compare configurations by their certificate material and verification policy. + # @parameter other [Object] The object to compare. + # @returns [Boolean] Whether both configurations have the same class and values. + def ==(other) + return other.instance_of?(self.class) && + @trust_store.eql?(other.trust_store) && + @certificate_chain.eql?(other.certificate_chain) && + @private_key.eql?(other.private_key) && + @verification.eql?(other.verification) + end + + alias eql? == + + # Compute a hash from the configuration values. Freeze the configuration before using it as a hash key. + # @returns [Integer] The hash of the configuration. + def hash + return [self.class, @trust_store, @certificate_chain, @private_key, @verification].hash + end + + # Freeze the configuration and independent copies of its certificate material, without freezing caller-owned values. + # @returns [Configuration] This immutable configuration. Use `dup.freeze` to preserve the original configuration too. + def freeze + return self if frozen? + + @trust_store = @trust_store&.dup&.freeze + @certificate_chain = @certificate_chain&.map{|certificate| certificate.dup.freeze}&.freeze + @private_key = @private_key&.dup&.freeze + + super + end + # Whether peer certificates should be verified. # @returns [Boolean] Whether peer verification is enabled. def verify_peer? diff --git a/lib/io/endpoint/tls/trust_store.rb b/lib/io/endpoint/tls/trust_store.rb index ed657d9..56f257d 100644 --- a/lib/io/endpoint/tls/trust_store.rb +++ b/lib/io/endpoint/tls/trust_store.rb @@ -55,6 +55,33 @@ def system_certificates? @system_certificates end + # Compare trust stores by their ordered certificates and system certificate policy. + # @parameter other [Object] The object to compare. + # @returns [Boolean] Whether both trust stores have the same class and values. + def ==(other) + return other.instance_of?(self.class) && + @certificates.eql?(other.certificates) && + @system_certificates.eql?(other.system_certificates?) + end + + alias eql? == + + # Compute a hash from the trust store values. Freeze the trust store before using it as a hash key. + # @returns [Integer] The hash of the trust store. + def hash + return [self.class, @certificates, @system_certificates].hash + end + + # Freeze the trust store and independent copies of its certificates, without freezing caller-owned values. + # @returns [TrustStore] This immutable trust store. Use `dup.freeze` to preserve the original trust store too. + def freeze + return self if frozen? + + @certificates = @certificates.map{|certificate| certificate.dup.freeze}.freeze + + super + end + # Get a representation of the trust store without exposing certificate material. # @returns [String] A redacted representation of the trust store. def inspect diff --git a/releases.md b/releases.md index fc1097d..0bf4669 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Compare TLS configurations and trust stores by value, allowing equivalent configurations to share cache entries. Freezing them creates immutable certificate data without freezing caller-owned values. + ## v0.18.0 - The `openssl` gem 3.3.0 or newer is now required. diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb index 0ba4a8f..9c6792b 100644 --- a/test/io/endpoint/tls/configuration.rb +++ b/test/io/endpoint/tls/configuration.rb @@ -12,6 +12,100 @@ let(:certificate_chain) {["certificate chain"]} let(:private_key) {"private key"} + with "value equality" do + def configuration(**options) + subject.new( + trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["trusted certificate".dup]), + certificate_chain: ["leaf certificate".dup, "intermediate certificate".dup], + private_key: "private key".dup, + verification: :peer, + **options + ) + end + + it "uses independently constructed equivalent configurations as the same hash key" do + first = configuration.freeze + second = configuration.freeze + clients = {["https://example.com", first] => :client} + + expect(first).to be == second + expect(first).to be(:eql?, second) + expect(first.hash).to be == second.hash + expect(first).not.to be_equal(second) + expect(clients[["https://example.com", second]]).to be == :client + end + + it "compares empty configurations" do + expect({subject.new.freeze => :client}[subject.new]).to be == :client + end + + it "compares the effective default verification policy" do + expect(configuration(verification: nil)).to be == configuration(verification: :peer) + end + + [ + {trust_store: nil}, + {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["other certificate"])}, + {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["trusted certificate"], system_certificates: true)}, + {certificate_chain: ["other certificate"]}, + {certificate_chain: ["intermediate certificate", "leaf certificate"]}, + {private_key: "other private key"}, + {verification: :none}, + {verification: :required}, + {certificate_chain: nil, private_key: nil}, + ].each do |options| + with "different #{options.keys.join(', ')}", options: options do + it "keeps cache entries separate" do + first = configuration.freeze + second = configuration(**options).freeze + + expect(first).not.to be == second + expect(second).not.to be(:eql?, first) + expect({first => :client}[second]).to be_nil + end + end + end + + it "does not compare equal to other types or subclasses" do + value = subject.new + subclass = Class.new(subject).new + + expect(value).not.to be == nil + expect(value).not.to be == Object.new + expect(value).not.to be == subclass + expect(subclass).not.to be == value + end + + it "keeps a frozen snapshot usable after the original data changes" do + original = configuration + snapshot = original.dup.freeze + clients = {snapshot => :client} + + original.trust_store.certificates.first.replace("other root") + original.trust_store.certificates.clear + original.certificate_chain.first.replace("other leaf") + original.certificate_chain.clear + original.private_key.replace("other key") + + expect(original).not.to be(:frozen?) + expect(original.trust_store).not.to be(:frozen?) + expect(snapshot).to be == configuration + expect(clients[configuration]).to be == :client + expect(clients[original]).to be_nil + end + + it "prevents mutation through a frozen configuration" do + snapshot = configuration.freeze + + expect{snapshot.trust_store.certificates.clear}.to raise_exception(FrozenError) + expect{snapshot.trust_store.certificates.first.clear}.to raise_exception(FrozenError) + expect{snapshot.certificate_chain.clear}.to raise_exception(FrozenError) + expect{snapshot.certificate_chain.first.clear}.to raise_exception(FrozenError) + expect{snapshot.private_key.clear}.to raise_exception(FrozenError) + expect(snapshot.freeze).to be_equal(snapshot) + end + end + with "certificate material" do let(:configuration) do subject.new( diff --git a/test/io/endpoint/tls/trust_store.rb b/test/io/endpoint/tls/trust_store.rb index 7e443b4..e37de05 100644 --- a/test/io/endpoint/tls/trust_store.rb +++ b/test/io/endpoint/tls/trust_store.rb @@ -10,6 +10,68 @@ let(:certificate) {"trusted certificate"} let(:certificates) {[certificate]} + with "value equality" do + it "uses independently constructed equivalent trust stores as the same hash key" do + first = subject.new(certificates: [certificate.dup]).freeze + second = subject.new(certificates: [certificate.dup]).freeze + + expect(first).to be == second + expect(first).to be(:eql?, second) + expect(first.hash).to be == second.hash + expect(first).not.to be_equal(second) + expect({first => :store}[second]).to be == :store + end + + it "distinguishes certificate contents, order, and system certificate policy" do + first = subject.new(certificates: ["first", "second"]).freeze + others = [ + subject.new(certificates: ["other"]), + subject.new(certificates: ["second", "first"]), + subject.new(certificates: ["first", "second"], system_certificates: true), + ] + + others.each do |other| + expect(first).not.to be == other + expect(other).not.to be(:eql?, first) + expect({first => :store}[other]).to be_nil + end + end + + it "compares system-only trust stores" do + first = subject.new(system_certificates: true).freeze + second = subject.new(system_certificates: true).freeze + + expect({first => :store}[second]).to be == :store + end + + it "does not compare equal to other types or subclasses" do + value = subject.new(certificates: certificates) + subclass = Class.new(subject).new(certificates: certificates) + + expect(value).not.to be == nil + expect(value).not.to be == Object.new + expect(value).not.to be == subclass + expect(subclass).not.to be == value + end + + it "keeps a frozen snapshot usable after the original certificates change" do + input = [certificate.dup] + original = subject.new(certificates: input) + snapshot = original.dup.freeze + stores = {snapshot => :store} + + input.first.replace("other root") + input.clear + + expect(original).not.to be(:frozen?) + expect(snapshot.certificates).to be == [certificate] + expect(stores[subject.new(certificates: certificates)]).to be == :store + expect{snapshot.certificates.clear}.to raise_exception(FrozenError) + expect{snapshot.certificates.first.clear}.to raise_exception(FrozenError) + expect(snapshot.freeze).to be_equal(snapshot) + end + end + with "custom certificates" do let(:trust_store) {subject.new(certificates: certificates)} From f22b213af75fd5c0006d60cca3079b842173436a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 10:43:02 +1200 Subject: [PATCH 2/3] Give parameterized TLS tests unique identities --- test/io/endpoint/tls/configuration.rb | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb index 9c6792b..5b8cf2d 100644 --- a/test/io/endpoint/tls/configuration.rb +++ b/test/io/endpoint/tls/configuration.rb @@ -43,18 +43,18 @@ def configuration(**options) expect(configuration(verification: nil)).to be == configuration(verification: :peer) end - [ - {trust_store: nil}, - {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["other certificate"])}, - {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["trusted certificate"], system_certificates: true)}, - {certificate_chain: ["other certificate"]}, - {certificate_chain: ["intermediate certificate", "leaf certificate"]}, - {private_key: "other private key"}, - {verification: :none}, - {verification: :required}, - {certificate_chain: nil, private_key: nil}, - ].each do |options| - with "different #{options.keys.join(', ')}", options: options do + { + "trust store presence" => {trust_store: nil}, + "trust roots" => {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["other certificate"])}, + "system certificate policy" => {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["trusted certificate"], system_certificates: true)}, + "certificate chain" => {certificate_chain: ["other certificate"]}, + "certificate order" => {certificate_chain: ["intermediate certificate", "leaf certificate"]}, + "private key" => {private_key: "other private key"}, + "disabled verification" => {verification: :none}, + "required verification" => {verification: :required}, + "local identity presence" => {certificate_chain: nil, private_key: nil}, + }.each do |name, options| + with "different #{name}", unique: name, options: options do it "keeps cache entries separate" do first = configuration.freeze second = configuration(**options).freeze From 05006916a4df68f538d11241e0b1618b5c99c094 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 10:44:14 +1200 Subject: [PATCH 3/3] Use shared examples for distinct TLS configurations --- test/io/endpoint/tls/configuration.rb | 43 +++++++++++++-------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb index 5b8cf2d..661841e 100644 --- a/test/io/endpoint/tls/configuration.rb +++ b/test/io/endpoint/tls/configuration.rb @@ -4,6 +4,18 @@ # Copyright, 2026, by Samuel Williams. require "io/endpoint/tls/configuration" +require "sus/shared" + +DifferentTLSConfiguration = Sus::Shared("a different TLS configuration") do |name, options| + it "keeps cache entries separate with different #{name}" do + first = configuration.freeze + second = configuration(**options).freeze + + expect(first).not.to be == second + expect(second).not.to be(:eql?, first) + expect({first => :client}[second]).to be_nil + end +end describe IO::Endpoint::TLS::Configuration do let(:certificate) {"trusted certificate"} @@ -43,28 +55,15 @@ def configuration(**options) expect(configuration(verification: nil)).to be == configuration(verification: :peer) end - { - "trust store presence" => {trust_store: nil}, - "trust roots" => {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["other certificate"])}, - "system certificate policy" => {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["trusted certificate"], system_certificates: true)}, - "certificate chain" => {certificate_chain: ["other certificate"]}, - "certificate order" => {certificate_chain: ["intermediate certificate", "leaf certificate"]}, - "private key" => {private_key: "other private key"}, - "disabled verification" => {verification: :none}, - "required verification" => {verification: :required}, - "local identity presence" => {certificate_chain: nil, private_key: nil}, - }.each do |name, options| - with "different #{name}", unique: name, options: options do - it "keeps cache entries separate" do - first = configuration.freeze - second = configuration(**options).freeze - - expect(first).not.to be == second - expect(second).not.to be(:eql?, first) - expect({first => :client}[second]).to be_nil - end - end - end + it_behaves_like DifferentTLSConfiguration, "trust store presence", {trust_store: nil} + it_behaves_like DifferentTLSConfiguration, "trust roots", {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["other certificate"])} + it_behaves_like DifferentTLSConfiguration, "system certificate policy", {trust_store: IO::Endpoint::TLS::TrustStore.new(certificates: ["trusted certificate"], system_certificates: true)} + it_behaves_like DifferentTLSConfiguration, "certificate chain", {certificate_chain: ["other certificate"]} + it_behaves_like DifferentTLSConfiguration, "certificate order", {certificate_chain: ["intermediate certificate", "leaf certificate"]} + it_behaves_like DifferentTLSConfiguration, "private key", {private_key: "other private key"} + it_behaves_like DifferentTLSConfiguration, "disabled verification", {verification: :none} + it_behaves_like DifferentTLSConfiguration, "required verification", {verification: :required} + it_behaves_like DifferentTLSConfiguration, "local identity presence", {certificate_chain: nil, private_key: nil} it "does not compare equal to other types or subclasses" do value = subject.new