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..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"} @@ -12,6 +24,87 @@ 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 + + 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 + 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)}