From 2e4ab8c31537d55ddaeab97fdf65860e9a554c7e Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Thu, 23 Jul 2026 05:29:17 +0000 Subject: [PATCH 1/7] expose tls info on responses optionally --- lib/wreq_ruby/client.rb | 5 ++ lib/wreq_ruby/response.rb | 72 +++++++++++++++++++ src/client.rs | 3 + src/client/resp.rs | 68 +++++++++++++++++- test/tls_info_test.rb | 144 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 test/tls_info_test.rb diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index e2ff2a3..3bd95c9 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -133,6 +133,11 @@ class Client # verification. When false, the client will accept any certificate, # including self-signed or expired ones. Should only be disabled # for testing purposes. + # + # @param tls_info [Boolean, nil] Enable collection of TLS certificate + # information on responses. When true, {Response#tls_info} will return + # a {Wreq::TlsInfo} object for HTTPS responses. Collection is opt-in + # because retaining certificate-chain bytes has a cost. Defaults to false. # # @param no_proxy [Boolean, nil] Disable use of any configured proxy # for this client, even if proxy settings are detected from the diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index 8a497db..d2e717b 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -177,6 +177,78 @@ def chunks # response.close def close end + + # Get TLS certificate information from the response. + # + # Returns a {Wreq::TlsInfo} object when TLS information collection was + # enabled on the client via +tls_info: true+ and the response was received + # over HTTPS. Returns +nil+ when collection was not enabled, the response + # did not use TLS, or the native transport has no TLS information. + # + # @return [Wreq::TlsInfo, nil] TLS certificate information, or nil + # @example + # client = Wreq::Client.new(tls_info: true) + # response = client.get("https://example.com") + # tls = response.tls_info + # tls.peer_certificate # => DER-encoded binary String + # tls.peer_certificate_chain # => frozen Array of DER binary Strings + def tls_info + end + end + + # TLS certificate information extracted from a response. + # + # This is an immutable value object returned by {Response#tls_info} when + # TLS information collection is enabled on the client. Certificate data + # is DER-encoded and independent of response-body consumption and + # connection-pool reuse. + # + # Callers can pass DER bytes to +OpenSSL::X509::Certificate.new+ for + # parsing, subject/issuer inspection, or fingerprint formatting. + # + # @example Inspect TLS info + # tls = response.tls_info + # tls.peer_certificate # => "\x30\x82..." (DER binary String) + # tls.peer_certificate_chain # => ["\x30\x82...", ...] (frozen Array) + # + # @example Parse with OpenSSL + # cert = OpenSSL::X509::Certificate.new(tls.peer_certificate) + # puts cert.subject + class TlsInfo + # Get the DER-encoded leaf certificate of the peer. + # + # @return [String, nil] DER-encoded certificate as a binary String + # (+Encoding::BINARY+), or +nil+ if unavailable + def peer_certificate + end + + # Get the full peer certificate chain. + # + # The returned array is frozen and contains DER-encoded binary Strings. + # It includes the leaf certificate when the native transport supplies it. + # + # @return [Array, nil] frozen Array of DER-encoded binary Strings, + # or +nil+ if unavailable + def peer_certificate_chain + end + + # Returns a compact string representation for debugging. + # + # Only shows byte counts and certificate counts; no raw certificate + # bytes are included. + # + # @return [String] human-readable representation + # @example + # tls.inspect + # # => "#" + def inspect + end + + # Returns the same representation as {#inspect}. + # + # @return [String] + def to_s + end end end end diff --git a/src/client.rs b/src/client.rs index 6f8bf85..956239a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -95,6 +95,8 @@ struct Builder { // ========= TLS options ========= /// Whether to verify TLS certificates. verify: Option, + /// Whether to collect TLS information on responses. + tls_info: Option, // ========= Network options ========= /// Whether to disable the proxy for the client. @@ -358,6 +360,7 @@ impl Client { // TLS options. apply_option!(set_if_some, builder, params.verify, tls_cert_verification); + apply_option!(set_if_some, builder, params.tls_info, tls_info); // Network options. apply_option!(set_if_some, builder, params.proxy, proxy); diff --git a/src/client/resp.rs b/src/client/resp.rs index 9cc2f98..bf31bf9 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,8 +5,10 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; -use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; +use magnus::{Error, Module, RArray, RModule, RString, Ruby, Value, scan_args::scan_args}; use wreq::Uri; +use wreq::tls::TlsInfo as WreqTlsInfo; +use magnus::value::ReprValue; use crate::{ arch::ProcessLocal, @@ -46,6 +48,45 @@ struct NativeResponseState { extensions: Extensions, } +/// TLS certificate information extracted from a response. +#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] +struct TlsInfo { + peer_certificate: Option>, + peer_certificate_chain: Option>>, +} + +impl TlsInfo { + /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. + fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.peer_certificate.as_ref().map(|der| { + ruby.str_from_slice(der) + }) + } + /// Get the full certificate chain as a frozen Array of binary Ruby Strings. + fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.peer_certificate_chain.as_ref().map(|chain| { + let ary = ruby.ary_new_capa(chain.len()); + for cert in chain { + let _ = ary.push(ruby.str_from_slice(cert)); + } + let _: Result = ary.funcall("freeze", ()); + ary + }) + } + + fn inspect(&self) -> String { + let cert_info = match &self.peer_certificate { + Some(der) => format!("peer_certificate=({} bytes)", der.len()), + None => "peer_certificate=nil".to_owned(), + }; + let chain_info = match &self.peer_certificate_chain { + Some(chain) => format!("peer_certificate_chain=({} certs)", chain.len()), + None => "peer_certificate_chain=nil".to_owned(), + }; + format!("#") + } +} + impl Response { /// Create a new [`Response`] instance. pub fn new(response: wreq::Response) -> Self { @@ -180,6 +221,18 @@ impl Response { self.remote_addr.map(|addr| addr.to_string()) } + /// Get TLS certificate information, if available. + fn tls_info(&self) -> Option { + self.extensions.get::().map(|info| { + TlsInfo { + peer_certificate: info.peer_certificate().map(|der| der.to_vec()), + peer_certificate_chain: info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), + } + }) + } + /// Get the response body as bytes. pub fn bytes(ruby: &Ruby, rb_self: &Self) -> Result { let response = rb_self.response(ruby, false)?; @@ -258,5 +311,18 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { response.define_method("json", magnus::method!(Response::json, 0))?; response.define_method("chunks", magnus::method!(Response::chunks, 0))?; response.define_method("close", magnus::method!(Response::close, 0))?; + response.define_method("tls_info", magnus::method!(Response::tls_info, 0))?; + + let tls_info_class = gem_module.define_class("TlsInfo", ruby.class_object())?; + tls_info_class.define_method( + "peer_certificate", + magnus::method!(TlsInfo::peer_certificate, 0), + )?; + tls_info_class.define_method( + "peer_certificate_chain", + magnus::method!(TlsInfo::peer_certificate_chain, 0), + )?; + tls_info_class.define_method("inspect", magnus::method!(TlsInfo::inspect, 0))?; + tls_info_class.define_method("to_s", magnus::method!(TlsInfo::inspect, 0))?; Ok(()) } diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb new file mode 100644 index 0000000..fc25e00 --- /dev/null +++ b/test/tls_info_test.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "test_helper" + +class TlsInfoTest < Minitest::Test + # ---- Opt-in behavior ---- + + def test_tls_info_nil_when_not_enabled + response = Wreq.get("#{HTTPBIN_URL}/get") + assert_nil response.tls_info + end + + def test_tls_info_nil_on_default_client + client = Wreq::Client.new + response = client.get("#{HTTPBIN_URL}/get") + assert_nil response.tls_info + end + + def test_tls_info_present_when_enabled + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + refute_nil response.tls_info + assert_instance_of Wreq::TlsInfo, response.tls_info + end + + # ---- Plain HTTP returns nil ---- + + def test_tls_info_nil_for_plain_http + client = Wreq::Client.new(tls_info: true) + response = client.get("http://httpbin.io/get") + assert_nil response.tls_info + end + + # ---- Peer certificate ---- + + def test_peer_certificate_is_binary_string + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + cert = tls.peer_certificate + refute_nil cert + assert_instance_of String, cert + assert_equal Encoding::BINARY, cert.encoding + assert cert.bytesize > 0 + end + + # ---- Peer certificate chain ---- + + def test_peer_certificate_chain_is_frozen_array + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + chain = tls.peer_certificate_chain + refute_nil chain + assert_instance_of Array, chain + assert chain.frozen?, "certificate chain array must be frozen" + assert chain.length > 0 + end + + def test_peer_certificate_chain_contains_binary_strings + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + chain = response.tls_info.peer_certificate_chain + + chain.each do |cert| + assert_instance_of String, cert + assert_equal Encoding::BINARY, cert.encoding + assert cert.bytesize > 0 + end + end + + def test_peer_certificate_chain_immutable + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + chain = response.tls_info.peer_certificate_chain + + assert_raises(FrozenError) { chain.push("test") } + end + + # ---- Data survives body consumption ---- + + def test_tls_info_available_after_body_read + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + + _body = response.text + tls = response.tls_info + + refute_nil tls + refute_nil tls.peer_certificate + assert tls.peer_certificate.bytesize > 0 + end + + def test_tls_info_available_after_close + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + + response.close + tls = response.tls_info + + refute_nil tls + refute_nil tls.peer_certificate + end + + # ---- Inspect does not leak certificate bytes ---- + + def test_inspect_shows_byte_counts_only + client = Wreq::Client.new(tls_info: true) + response = client.get("#{HTTPBIN_URL}/get") + tls = response.tls_info + + inspection = tls.inspect + assert_match(/peer_certificate=\(\d+ bytes\)/, inspection) + assert_match(/peer_certificate_chain=\(\d+ certs\)/, inspection) + assert_match(/\A# 0 + assert tls2.peer_certificate.bytesize > 0 + end +end \ No newline at end of file From 62aff70aa7cbc7e5d4732078252dc5f47898769f Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Thu, 23 Jul 2026 05:51:21 +0000 Subject: [PATCH 2/7] fix rust formatting --- src/client/resp.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index bf31bf9..2c9e9c9 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,10 +5,10 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; +use magnus::value::ReprValue; use magnus::{Error, Module, RArray, RModule, RString, Ruby, Value, scan_args::scan_args}; use wreq::Uri; use wreq::tls::TlsInfo as WreqTlsInfo; -use magnus::value::ReprValue; use crate::{ arch::ProcessLocal, @@ -58,9 +58,10 @@ struct TlsInfo { impl TlsInfo { /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self.peer_certificate.as_ref().map(|der| { - ruby.str_from_slice(der) - }) + rb_self + .peer_certificate + .as_ref() + .map(|der| ruby.str_from_slice(der)) } /// Get the full certificate chain as a frozen Array of binary Ruby Strings. fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { @@ -223,13 +224,11 @@ impl Response { /// Get TLS certificate information, if available. fn tls_info(&self) -> Option { - self.extensions.get::().map(|info| { - TlsInfo { - peer_certificate: info.peer_certificate().map(|der| der.to_vec()), - peer_certificate_chain: info - .peer_certificate_chain() - .map(|chain| chain.map(|cert| cert.to_vec()).collect()), - } + self.extensions.get::().map(|info| TlsInfo { + peer_certificate: info.peer_certificate().map(|der| der.to_vec()), + peer_certificate_chain: info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), }) } From 4063f04d9435091304d39de4750cb1e5c7659c9d Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Tue, 28 Jul 2026 18:19:40 +0000 Subject: [PATCH 3/7] changes after rebase --- src/client/resp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index 2c9e9c9..ea24ea8 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -224,7 +224,7 @@ impl Response { /// Get TLS certificate information, if available. fn tls_info(&self) -> Option { - self.extensions.get::().map(|info| TlsInfo { + self.state.as_ref().extensions.get::().map(|info| TlsInfo { peer_certificate: info.peer_certificate().map(|der| der.to_vec()), peer_certificate_chain: info .peer_certificate_chain() From f20573f408faf0171b5221eb4f5eec77fc4ca845 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Tue, 28 Jul 2026 18:22:30 +0000 Subject: [PATCH 4/7] fix rust formatting --- src/client/resp.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index ea24ea8..8a4035b 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -224,12 +224,16 @@ impl Response { /// Get TLS certificate information, if available. fn tls_info(&self) -> Option { - self.state.as_ref().extensions.get::().map(|info| TlsInfo { - peer_certificate: info.peer_certificate().map(|der| der.to_vec()), - peer_certificate_chain: info - .peer_certificate_chain() - .map(|chain| chain.map(|cert| cert.to_vec()).collect()), - }) + self.state + .as_ref() + .extensions + .get::() + .map(|info| TlsInfo { + peer_certificate: info.peer_certificate().map(|der| der.to_vec()), + peer_certificate_chain: info + .peer_certificate_chain() + .map(|chain| chain.map(|cert| cert.to_vec()).collect()), + }) } /// Get the response body as bytes. From 0b8c1cbf19f6e77a11d4f8d0902866cc6566d927 Mon Sep 17 00:00:00 2001 From: Arish Anwar Date: Sun, 2 Aug 2026 08:06:58 +0000 Subject: [PATCH 5/7] refactor to reduce fragmentation --- src/client/resp.rs | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/client/resp.rs b/src/client/resp.rs index 8a4035b..aaf3802 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -50,23 +50,21 @@ struct NativeResponseState { /// TLS certificate information extracted from a response. #[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] -struct TlsInfo { - peer_certificate: Option>, - peer_certificate_chain: Option>>, -} +struct TlsInfo(WreqTlsInfo); impl TlsInfo { /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { rb_self - .peer_certificate - .as_ref() + .0 + .peer_certificate() .map(|der| ruby.str_from_slice(der)) } + /// Get the full certificate chain as a frozen Array of binary Ruby Strings. fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self.peer_certificate_chain.as_ref().map(|chain| { - let ary = ruby.ary_new_capa(chain.len()); + rb_self.0.peer_certificate_chain().map(|chain| { + let ary = ruby.ary_new(); for cert in chain { let _ = ary.push(ruby.str_from_slice(cert)); } @@ -76,14 +74,14 @@ impl TlsInfo { } fn inspect(&self) -> String { - let cert_info = match &self.peer_certificate { + let cert_info = match self.0.peer_certificate() { Some(der) => format!("peer_certificate=({} bytes)", der.len()), None => "peer_certificate=nil".to_owned(), }; - let chain_info = match &self.peer_certificate_chain { - Some(chain) => format!("peer_certificate_chain=({} certs)", chain.len()), - None => "peer_certificate_chain=nil".to_owned(), - }; + let chain_info = self.0.peer_certificate_chain().map_or_else( + || "peer_certificate_chain=nil".to_owned(), + |chain| format!("peer_certificate_chain=({} certs)", chain.count()), + ); format!("#") } } @@ -228,12 +226,8 @@ impl Response { .as_ref() .extensions .get::() - .map(|info| TlsInfo { - peer_certificate: info.peer_certificate().map(|der| der.to_vec()), - peer_certificate_chain: info - .peer_certificate_chain() - .map(|chain| chain.map(|cert| cert.to_vec()).collect()), - }) + .cloned() + .map(TlsInfo) } /// Get the response body as bytes. From e1978e837e1552cd29f005514acb2fb399301754 Mon Sep 17 00:00:00 2001 From: gngpp Date: Mon, 3 Aug 2026 09:46:13 +0800 Subject: [PATCH 6/7] refactor(tls): align response metadata API --- docs/fork-safety.md | 14 +-- lib/wreq.rb | 1 + lib/wreq_ruby/client.rb | 10 +- lib/wreq_ruby/response.rb | 74 +++------------ lib/wreq_ruby/tls.rb | 49 ++++++++++ src/arch.rs | 2 +- src/client.rs | 2 +- src/client/resp.rs | 59 +----------- src/lib.rs | 2 + src/tls.rs | 51 +++++++++++ test/support/tls_server.rb | 95 +++++++++++++++++++ test/tls_info_test.rb | 183 ++++++++++--------------------------- 12 files changed, 275 insertions(+), 267 deletions(-) create mode 100644 lib/wreq_ruby/tls.rb create mode 100644 src/tls.rs create mode 100644 test/support/tls_server.rb diff --git a/docs/fork-safety.md b/docs/fork-safety.md index 2824be3..0dfce5c 100644 --- a/docs/fork-safety.md +++ b/docs/fork-safety.md @@ -9,18 +9,20 @@ connections are not safe to reuse. If the parent has already loaded wreq-ruby, native HTTP operations in the child raise `Wreq::ForkError`. This applies to new and existing clients, module -request methods, streaming request bodies, and response body methods. Retrying -the operation in the same child raises the same error. +request methods, streaming request bodies, and response methods backed by native +state. Retrying the operation in the same child raises the same error. Read-only +response metadata such as status, headers, and captured TLS information remains +available. The parent can continue using its clients. When inherited Ruby objects are collected in the child, their native runtime state is left for the operating system to reclaim when the process exits. -## Child processes are unsupported +## HTTP work in forked children is unsupported -A process created with `fork` must not use wreq-ruby, even when it first loads -the extension after the fork. If the parent loaded wreq-ruby, native operations -in the child raise `Wreq::ForkError`. +A process created with `fork` must not start or continue HTTP work through +wreq-ruby, even when it first loads the extension after the fork. If the parent +loaded wreq-ruby, native HTTP operations in the child raise `Wreq::ForkError`. When the extension was not present in the parent, no wreq-ruby state or fork marker reaches the child. The extension cannot reliably distinguish that child diff --git a/lib/wreq.rb b/lib/wreq.rb index 18ccb36..8fca103 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -12,6 +12,7 @@ require_relative "wreq_ruby/emulate" require_relative "wreq_ruby/client" require_relative "wreq_ruby/response" +require_relative "wreq_ruby/tls" require_relative "wreq_ruby/body" require_relative "wreq_ruby/header" require_relative "wreq_ruby/error" diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index 3bd95c9..31cbeb1 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -133,11 +133,11 @@ class Client # verification. When false, the client will accept any certificate, # including self-signed or expired ones. Should only be disabled # for testing purposes. - # - # @param tls_info [Boolean, nil] Enable collection of TLS certificate - # information on responses. When true, {Response#tls_info} will return - # a {Wreq::TlsInfo} object for HTTPS responses. Collection is opt-in - # because retaining certificate-chain bytes has a cost. Defaults to false. + # + # @param tls_info [Boolean, nil] Retain peer certificate data for HTTPS + # responses. When true, {Wreq::Response#tls_info} may return a + # {Wreq::TlsInfo} object. Disabled by default because retaining + # certificate data uses additional memory. # # @param no_proxy [Boolean, nil] Disable use of any configured proxy # for this client, even if proxy settings are detected from the diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index d2e717b..4985495 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -178,76 +178,24 @@ def chunks def close end - # Get TLS certificate information from the response. + # Return TLS information captured for this response. # - # Returns a {Wreq::TlsInfo} object when TLS information collection was - # enabled on the client via +tls_info: true+ and the response was received - # over HTTPS. Returns +nil+ when collection was not enabled, the response - # did not use TLS, or the native transport has no TLS information. + # Returns +nil+ when +tls_info: true+ was not enabled, the response used + # plain HTTP, or the transport supplied no TLS information. Reading or + # closing the response body does not discard captured TLS data. # - # @return [Wreq::TlsInfo, nil] TLS certificate information, or nil + # @return [Wreq::TlsInfo, nil] TLS information for this response, or +nil+ + # when unavailable # @example # client = Wreq::Client.new(tls_info: true) # response = client.get("https://example.com") # tls = response.tls_info - # tls.peer_certificate # => DER-encoded binary String - # tls.peer_certificate_chain # => frozen Array of DER binary Strings - def tls_info - end - end - - # TLS certificate information extracted from a response. - # - # This is an immutable value object returned by {Response#tls_info} when - # TLS information collection is enabled on the client. Certificate data - # is DER-encoded and independent of response-body consumption and - # connection-pool reuse. - # - # Callers can pass DER bytes to +OpenSSL::X509::Certificate.new+ for - # parsing, subject/issuer inspection, or fingerprint formatting. - # - # @example Inspect TLS info - # tls = response.tls_info - # tls.peer_certificate # => "\x30\x82..." (DER binary String) - # tls.peer_certificate_chain # => ["\x30\x82...", ...] (frozen Array) - # - # @example Parse with OpenSSL - # cert = OpenSSL::X509::Certificate.new(tls.peer_certificate) - # puts cert.subject - class TlsInfo - # Get the DER-encoded leaf certificate of the peer. - # - # @return [String, nil] DER-encoded certificate as a binary String - # (+Encoding::BINARY+), or +nil+ if unavailable - def peer_certificate - end - - # Get the full peer certificate chain. # - # The returned array is frozen and contains DER-encoded binary Strings. - # It includes the leaf certificate when the native transport supplies it. - # - # @return [Array, nil] frozen Array of DER-encoded binary Strings, - # or +nil+ if unavailable - def peer_certificate_chain - end - - # Returns a compact string representation for debugging. - # - # Only shows byte counts and certificate counts; no raw certificate - # bytes are included. - # - # @return [String] human-readable representation - # @example - # tls.inspect - # # => "#" - def inspect - end - - # Returns the same representation as {#inspect}. - # - # @return [String] - def to_s + # if tls + # tls.peer_certificate # => DER-encoded binary String + # tls.peer_certificate_chain # => frozen Array of DER binary Strings + # end + def tls_info end end end diff --git a/lib/wreq_ruby/tls.rb b/lib/wreq_ruby/tls.rb new file mode 100644 index 0000000..bed4b6d --- /dev/null +++ b/lib/wreq_ruby/tls.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +unless defined?(Wreq) + module Wreq + # Peer certificate data captured for one HTTPS response. + # + # Instances are returned by {Wreq::Response#tls_info}. Certificate bytes + # remain available after the response body is read or closed, even if the + # connection is later reused. + # + # The returned certificate Strings are Ruby-owned copies. Changing one does + # not alter the stored TLS data or values returned by later calls. The chain + # Array is frozen, but its String elements remain mutable. + # + # Certificates use the DER encoding described by the X.509 profile in + # RFC 5280. + # + # @example Parse the peer certificate with OpenSSL + # require "openssl" + # + # client = Wreq::Client.new(tls_info: true) + # response = client.get("https://example.com") + # der = response.tls_info&.peer_certificate + # + # if der + # certificate = OpenSSL::X509::Certificate.new(der) + # puts certificate.subject + # end + # @see https://www.rfc-editor.org/rfc/rfc5280#section-4.1 X.509 certificate format + class TlsInfo + # Return the peer's leaf certificate. + # + # @return [String, nil] a new DER-encoded String with + # +Encoding::BINARY+, or +nil+ when the transport did not provide one + def peer_certificate + end + + # Return the peer certificate chain. + # + # The Array is frozen. Each element is a new DER-encoded binary String. + # The chain includes the leaf certificate when the transport supplies it. + # + # @return [Array, nil] a frozen Array of certificate copies, or + # +nil+ when the transport did not provide a chain + def peer_certificate_chain + end + end + end +end diff --git a/src/arch.rs b/src/arch.rs index 91d542b..afbbc81 100644 --- a/src/arch.rs +++ b/src/arch.rs @@ -16,7 +16,7 @@ use std::mem::ManuallyDrop; /// system reclaim it when the process exits. /// /// This wrapper only controls destruction. Call [`crate::rt::ensure_current`] -/// before accessing the inner value. +/// before using process-bound state stored inside it. #[derive(Clone)] pub(crate) struct ProcessLocal(ManuallyDrop); diff --git a/src/client.rs b/src/client.rs index 956239a..9a2b5c4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -95,7 +95,7 @@ struct Builder { // ========= TLS options ========= /// Whether to verify TLS certificates. verify: Option, - /// Whether to collect TLS information on responses. + /// Whether to retain peer certificate data on responses. tls_info: Option, // ========= Network options ========= diff --git a/src/client/resp.rs b/src/client/resp.rs index aaf3802..a2f25d4 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,10 +5,8 @@ use bytes::Bytes; use futures_util::TryFutureExt; use http::{Extensions, HeaderMap, response::Response as HttpResponse}; use http_body_util::BodyExt; -use magnus::value::ReprValue; -use magnus::{Error, Module, RArray, RModule, RString, Ruby, Value, scan_args::scan_args}; +use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; use wreq::Uri; -use wreq::tls::TlsInfo as WreqTlsInfo; use crate::{ arch::ProcessLocal, @@ -19,6 +17,7 @@ use crate::{ header::Headers, http::{StatusCode, Version}, rt, + tls::TlsInfo, }; /// A response from a request. @@ -48,44 +47,6 @@ struct NativeResponseState { extensions: Extensions, } -/// TLS certificate information extracted from a response. -#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] -struct TlsInfo(WreqTlsInfo); - -impl TlsInfo { - /// Get the DER-encoded leaf certificate of the peer as a binary Ruby String. - fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self - .0 - .peer_certificate() - .map(|der| ruby.str_from_slice(der)) - } - - /// Get the full certificate chain as a frozen Array of binary Ruby Strings. - fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { - rb_self.0.peer_certificate_chain().map(|chain| { - let ary = ruby.ary_new(); - for cert in chain { - let _ = ary.push(ruby.str_from_slice(cert)); - } - let _: Result = ary.funcall("freeze", ()); - ary - }) - } - - fn inspect(&self) -> String { - let cert_info = match self.0.peer_certificate() { - Some(der) => format!("peer_certificate=({} bytes)", der.len()), - None => "peer_certificate=nil".to_owned(), - }; - let chain_info = self.0.peer_certificate_chain().map_or_else( - || "peer_certificate_chain=nil".to_owned(), - |chain| format!("peer_certificate_chain=({} certs)", chain.count()), - ); - format!("#") - } -} - impl Response { /// Create a new [`Response`] instance. pub fn new(response: wreq::Response) -> Self { @@ -220,12 +181,12 @@ impl Response { self.remote_addr.map(|addr| addr.to_string()) } - /// Get TLS certificate information, if available. + /// Return peer certificate data retained for this response. fn tls_info(&self) -> Option { self.state .as_ref() .extensions - .get::() + .get::() .cloned() .map(TlsInfo) } @@ -309,17 +270,5 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { response.define_method("chunks", magnus::method!(Response::chunks, 0))?; response.define_method("close", magnus::method!(Response::close, 0))?; response.define_method("tls_info", magnus::method!(Response::tls_info, 0))?; - - let tls_info_class = gem_module.define_class("TlsInfo", ruby.class_object())?; - tls_info_class.define_method( - "peer_certificate", - magnus::method!(TlsInfo::peer_certificate, 0), - )?; - tls_info_class.define_method( - "peer_certificate_chain", - magnus::method!(TlsInfo::peer_certificate_chain, 0), - )?; - tls_info_class.define_method("inspect", magnus::method!(TlsInfo::inspect, 0))?; - tls_info_class.define_method("to_s", magnus::method!(TlsInfo::inspect, 0))?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 82d852d..549a1f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod http; mod options; mod rt; mod serde; +mod tls; use magnus::{Error, Module, Ruby, Value}; @@ -98,6 +99,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { http::include(ruby, &gem_module)?; header::include(ruby, &gem_module)?; cookie::include(ruby, &gem_module)?; + tls::include(ruby, &gem_module)?; client::include(ruby, &gem_module)?; emulate::include(ruby, &gem_module)?; #[cfg(unix)] diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..98f7e22 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,51 @@ +//! Ruby wrappers for TLS metadata attached to a response. +//! +//! Certificates use the DER encoding described by the X.509 profile in +//! [RFC 5280 section 4.1](https://www.rfc-editor.org/rfc/rfc5280#section-4.1). + +use magnus::{Error, Module, RArray, RModule, RString, Ruby, value::ReprValue}; + +/// Read-only Ruby wrapper around [`wreq::tls::TlsInfo`]. +/// +/// The native value keeps certificate bytes alive independently of the response +/// body. Its `Bytes` buffers are cheap to clone, while accessors copy the data +/// into Ruby-owned Strings so callers cannot mutate the stored metadata. +#[derive(Clone)] +#[magnus::wrap(class = "Wreq::TlsInfo", free_immediately, size)] +pub(crate) struct TlsInfo(pub(crate) wreq::tls::TlsInfo); + +impl TlsInfo { + /// Copy the DER-encoded leaf certificate into a binary Ruby String. + fn peer_certificate(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self + .0 + .peer_certificate() + .map(|der| ruby.str_from_slice(der)) + } + + /// Copy the certificate chain into a frozen Array of binary Ruby Strings. + /// + /// Only the Array is frozen. Its Strings are independent copies and remain + /// mutable in Ruby. + fn peer_certificate_chain(ruby: &Ruby, rb_self: &Self) -> Option { + rb_self.0.peer_certificate_chain().map(|chain| { + let certificates = ruby.ary_from_iter(chain.map(|cert| ruby.str_from_slice(cert))); + certificates.freeze(); + certificates + }) + } +} + +/// Define the `Wreq::TlsInfo` Ruby class and its readers. +pub(crate) fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { + let tls_info_class = gem_module.define_class("TlsInfo", ruby.class_object())?; + tls_info_class.define_method( + "peer_certificate", + magnus::method!(TlsInfo::peer_certificate, 0), + )?; + tls_info_class.define_method( + "peer_certificate_chain", + magnus::method!(TlsInfo::peer_certificate_chain, 0), + )?; + Ok(()) +} diff --git a/test/support/tls_server.rb b/test/support/tls_server.rb new file mode 100644 index 0000000..a8ce995 --- /dev/null +++ b/test/support/tls_server.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "openssl" +require "socket" +require "timeout" + +# A small HTTPS server that serves every expected request on one TLS connection. +module TlsTestServer + RESPONSE_BODY = "ok" + + module_function + + def with_connection(request_count:) + tcp_server = TCPServer.new("127.0.0.1", 0) + context, certificate_der = server_context + ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, context) + outcome = Queue.new + server_thread = Thread.new do + socket = ssl_server.accept + request_lines = [] + + request_count.times do |index| + request_lines << read_request(socket) + connection = (index == request_count - 1) ? "close" : "keep-alive" + socket.write(response(connection)) + socket.flush + end + + outcome << {connections: 1, requests: request_lines} + rescue => error + outcome << error + ensure + socket&.close + end + server_thread.report_on_exception = false + + yield "https://127.0.0.1:#{tcp_server.addr[1]}/", certificate_der + + result = Timeout.timeout(5) { outcome.pop } + raise result if result.is_a?(StandardError) + + result + ensure + tcp_server&.close + server_thread&.join(5) + if server_thread&.alive? + server_thread.kill + server_thread.join + end + end + + def read_request(socket) + request_line = socket.gets + raise EOFError, "client closed before sending a request" unless request_line + + loop do + line = socket.gets + raise EOFError, "client closed while sending headers" unless line + break if line == "\r\n" + end + + request_line + end + private_class_method :read_request + + def response(connection) + [ + "HTTP/1.1 200 OK", + "Content-Length: #{RESPONSE_BODY.bytesize}", + "Connection: #{connection}", + "", + RESPONSE_BODY + ].join("\r\n") + end + private_class_method :response + + def server_context + key = OpenSSL::PKey::RSA.new(2048) + certificate = OpenSSL::X509::Certificate.new + certificate.version = 2 + certificate.serial = 1 + certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse("/CN=127.0.0.1") + certificate.public_key = key.public_key + certificate.not_before = Time.now - 60 + certificate.not_after = Time.now + 3600 + certificate.sign(key, OpenSSL::Digest.new("SHA256")) + + context = OpenSSL::SSL::SSLContext.new.tap do |ssl_context| + ssl_context.cert = certificate + ssl_context.key = key + end + [context, certificate.to_der] + end + private_class_method :server_context +end diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb index fc25e00..60f1d43 100644 --- a/test/tls_info_test.rb +++ b/test/tls_info_test.rb @@ -1,144 +1,55 @@ # frozen_string_literal: true require "test_helper" +require_relative "support/tls_server" class TlsInfoTest < Minitest::Test - # ---- Opt-in behavior ---- - - def test_tls_info_nil_when_not_enabled - response = Wreq.get("#{HTTPBIN_URL}/get") - assert_nil response.tls_info - end - - def test_tls_info_nil_on_default_client - client = Wreq::Client.new - response = client.get("#{HTTPBIN_URL}/get") - assert_nil response.tls_info - end - - def test_tls_info_present_when_enabled - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - refute_nil response.tls_info - assert_instance_of Wreq::TlsInfo, response.tls_info - end - - # ---- Plain HTTP returns nil ---- - - def test_tls_info_nil_for_plain_http - client = Wreq::Client.new(tls_info: true) - response = client.get("http://httpbin.io/get") - assert_nil response.tls_info - end - - # ---- Peer certificate ---- - - def test_peer_certificate_is_binary_string - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - tls = response.tls_info - - cert = tls.peer_certificate - refute_nil cert - assert_instance_of String, cert - assert_equal Encoding::BINARY, cert.encoding - assert cert.bytesize > 0 - end - - # ---- Peer certificate chain ---- - - def test_peer_certificate_chain_is_frozen_array - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - tls = response.tls_info - - chain = tls.peer_certificate_chain - refute_nil chain - assert_instance_of Array, chain - assert chain.frozen?, "certificate chain array must be frozen" - assert chain.length > 0 - end - - def test_peer_certificate_chain_contains_binary_strings - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - chain = response.tls_info.peer_certificate_chain - - chain.each do |cert| - assert_instance_of String, cert - assert_equal Encoding::BINARY, cert.encoding - assert cert.bytesize > 0 + HTTPBIN_HTTP_URL = ENV.fetch("HTTPBIN_HTTP_URL", HTTPBIN_URL.sub(/\Ahttps:/, "http:")) + + def test_tls_info_is_nil_when_disabled_or_request_is_plain_http + default_response = Wreq::Client.new.get("#{HTTPBIN_URL}/get") + plain_response = Wreq::Client.new(tls_info: true).get("#{HTTPBIN_HTTP_URL}/get") + + assert_nil default_response.tls_info + assert_nil plain_response.tls_info + end + + def test_certificate_data_survives_body_lifecycle_on_a_reused_connection + fixture = TlsTestServer.with_connection(request_count: 2) do |base_url, certificate_der| + client = Wreq::Client.new( + tls_info: true, + verify: false, + http1_only: true, + no_proxy: true, + timeout: 5 + ) + + read_response = client.get("#{base_url}read") + assert_equal "ok", read_response.text + read_tls = read_response.tls_info + + closed_response = client.get("#{base_url}close") + closed_response.close + closed_tls = closed_response.tls_info + + assert_instance_of Wreq::TlsInfo, read_tls + certificate = read_tls.peer_certificate + chain = read_tls.peer_certificate_chain + assert_equal certificate_der, certificate + assert_equal Encoding::BINARY, certificate.encoding + assert_equal [certificate_der], chain + assert_equal Encoding::BINARY, chain.first.encoding + assert_predicate chain, :frozen? + assert_empty Wreq::TlsInfo.instance_methods(false) & %i[inspect to_h to_s] + + certificate.clear + assert_equal certificate_der, read_tls.peer_certificate + assert_equal certificate_der, closed_tls.peer_certificate end - end - - def test_peer_certificate_chain_immutable - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - chain = response.tls_info.peer_certificate_chain - - assert_raises(FrozenError) { chain.push("test") } - end - - # ---- Data survives body consumption ---- - - def test_tls_info_available_after_body_read - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - - _body = response.text - tls = response.tls_info - - refute_nil tls - refute_nil tls.peer_certificate - assert tls.peer_certificate.bytesize > 0 - end - - def test_tls_info_available_after_close - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - - response.close - tls = response.tls_info - - refute_nil tls - refute_nil tls.peer_certificate - end - - # ---- Inspect does not leak certificate bytes ---- - - def test_inspect_shows_byte_counts_only - client = Wreq::Client.new(tls_info: true) - response = client.get("#{HTTPBIN_URL}/get") - tls = response.tls_info - - inspection = tls.inspect - assert_match(/peer_certificate=\(\d+ bytes\)/, inspection) - assert_match(/peer_certificate_chain=\(\d+ certs\)/, inspection) - assert_match(/\A# 0 - assert tls2.peer_certificate.bytesize > 0 + assert_equal( + {connections: 1, requests: ["GET /read HTTP/1.1\r\n", "GET /close HTTP/1.1\r\n"]}, + fixture + ) end -end \ No newline at end of file +end From ee3b1ecc47d9352784f1e0a8d90b64f559f2cebf Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 5 Aug 2026 11:35:20 +0800 Subject: [PATCH 7/7] feat(tls): add compact inspection --- examples/tls_info.rb | 27 +++++++++++++++++++++++++++ lib/wreq_ruby/tls.rb | 24 ++++++++++++++++++++++++ test/tls_info_test.rb | 6 +++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 examples/tls_info.rb diff --git a/examples/tls_info.rb b/examples/tls_info.rb new file mode 100644 index 0000000..38b25ff --- /dev/null +++ b/examples/tls_info.rb @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "openssl" +require_relative "../lib/wreq" + +url = ARGV.fetch(0, "https://example.com") +client = Wreq::Client.new(tls_info: true) +response = client.get(url) +tls_info = response.tls_info +response.close + +abort "TLS information is unavailable for #{url}" unless tls_info + +p tls_info + +if (der = tls_info.peer_certificate) + certificate = OpenSSL::X509::Certificate.new(der) + puts "Subject: #{certificate.subject}" + puts "Issuer: #{certificate.issuer}" + puts "Valid from: #{certificate.not_before}" + puts "Valid until: #{certificate.not_after}" +end + +chain = tls_info.peer_certificate_chain +chain_size = chain ? chain.length : "unavailable" +puts "Certificate chain: #{chain_size}" diff --git a/lib/wreq_ruby/tls.rb b/lib/wreq_ruby/tls.rb index bed4b6d..65b4682 100644 --- a/lib/wreq_ruby/tls.rb +++ b/lib/wreq_ruby/tls.rb @@ -47,3 +47,27 @@ def peer_certificate_chain end end end + +# ======================== Ruby API Extensions ======================== + +module Wreq + class TlsInfo + # Return a compact summary for debugging. + # + # The summary reports the leaf certificate size and the number of + # certificates in the chain without printing the DER data. + # + # @return [String] TLS certificate metadata + # @example + # tls_info.inspect + # # => "#" + def inspect + certificate = peer_certificate + chain = peer_certificate_chain + certificate_size = certificate ? "#{certificate.bytesize}B" : "nil" + chain_size = chain ? chain.length : "nil" + + "#<#{self.class} peer_certificate=#{certificate_size} peer_certificate_chain=#{chain_size}>" + end + end +end diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb index 60f1d43..a92b8ff 100644 --- a/test/tls_info_test.rb +++ b/test/tls_info_test.rb @@ -40,7 +40,11 @@ def test_certificate_data_survives_body_lifecycle_on_a_reused_connection assert_equal [certificate_der], chain assert_equal Encoding::BINARY, chain.first.encoding assert_predicate chain, :frozen? - assert_empty Wreq::TlsInfo.instance_methods(false) & %i[inspect to_h to_s] + assert_equal( + "#", + read_tls.inspect + ) + assert_empty Wreq::TlsInfo.instance_methods(false) & %i[to_h to_s] certificate.clear assert_equal certificate_der, read_tls.peer_certificate