Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions lib/io/endpoint/tls/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
27 changes: 27 additions & 0 deletions lib/io/endpoint/tls/trust_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
93 changes: 93 additions & 0 deletions test/io/endpoint/tls/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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(
Expand Down
62 changes: 62 additions & 0 deletions test/io/endpoint/tls/trust_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)}

Expand Down
Loading