diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5feb172..adcd6a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,12 +43,14 @@ jobs: steps: - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: ruby/setup-ruby@v1 with: ruby-version: "3.4" bundler-cache: true - - run: bundle exec rb-sys-dock --ruby-versions 4.0,3.4,3.3 --platform ${{ matrix.platform }} --build + - run: bundle exec rb-sys-dock --ruby-versions 4.0,3.4,3.3 --platform ${{ matrix.platform }} --mount-toolchains --build - uses: actions/upload-artifact@v7 with: 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/docs/interrupt-handling.md b/docs/interrupt-handling.md new file mode 100644 index 0000000..660be00 --- /dev/null +++ b/docs/interrupt-handling.md @@ -0,0 +1,133 @@ +# Interrupt handling policy + +wreq-ruby must not construct or raise Ruby's built-in `Interrupt` to report a +request cancellation. This rule applies to the Rust extension and to Ruby +wrappers in this repository. A pull request that turns a wreq-owned +cancellation into the built-in class must not be merged. + +Represent native cancellation as a Rust value until the Ruby-owned calling +thread has reacquired the GVL. Then map a wreq-owned request cancellation to +`Wreq::InterruptError`: + +```ruby +Wreq::InterruptError < Interrupt +``` + +Keep this class outside `StandardError`. A broad transport rescue such as +`rescue StandardError` must not swallow an interruption. + +## Why `Interrupt` is reserved + +Ruby documents `Interrupt` as the exception raised for an interrupt signal, +usually when the user presses Control-C. Its hierarchy is: + +```text +Exception +└── SignalException + └── Interrupt +``` + +`Interrupt` is not a `StandardError`. Ruby's default `rescue` catches +`StandardError`, so it does not catch `Interrupt` or `Wreq::InterruptError`. +Code that explicitly uses `rescue Interrupt` catches both because +`Wreq::InterruptError` is a subclass. + +The exact built-in class therefore carries Ruby-level control-flow meaning. If +wreq creates that class for its own cancellation, callers cannot tell whether +Ruby delivered an interrupt or the HTTP library cancelled a request. A +library-specific subclass preserves that distinction while keeping the +interruption outside ordinary transport errors. + +## Required behavior + +| Event | wreq-ruby behavior | +| --- | --- | +| Ruby raises its built-in `Interrupt`, including an exception supplied through `Thread#raise` | Propagate the original exception. Do not replace or wrap it. | +| `Thread#kill`, `Thread#terminate`, or `Thread#exit` stops a thread | Let Ruby perform the fatal thread termination. The native unblock callback may request cancellation, but wreq must not translate the event into `Interrupt`. | +| wreq's native cancellation path finishes without a pending Ruby exception | Raise `Wreq::InterruptError`. | +| A connection, timeout, protocol, or other transport operation fails | Raise the matching wreq transport error under `StandardError`. | + +Ruby's implementation also makes an important distinction here. +`Thread#raise` queues the exception chosen by the caller. `Thread#kill` queues +Ruby's internal fatal thread-kill event instead of an `Interrupt` object, and +its termination is asynchronous. Once a no-GVL callback returns, Ruby handles +that fatal event after reacquiring the GVL and before the native call can return +normally to wreq's error mapper. + +## Native no-GVL boundary + +There are two separate rules at this boundary: + +1. A Tokio worker, other Rust background thread, no-GVL callback, or UBF must + not construct or raise any Ruby exception. +2. Rust code running on the Ruby-owned calling thread with the GVL may construct + Ruby exceptions, but it must not turn a wreq-owned cancellation into Ruby's + built-in `Interrupt`. + +Requests run through `rb_thread_call_without_gvl`. Ruby's C API documents this +sequence: + +1. Handle pending interrupts. +2. Release the GVL. +3. Run the native callback. +4. Reacquire the GVL. +5. Handle interrupts received while the callback was running. + +Ruby may call the unblock function, or UBF, when another thread interacts with +the blocked thread. The UBF is a request to stop the native operation. It does +not identify which Ruby exception, if any, is pending. + +The UBF in [`src/gvl.rs`](../src/gvl.rs) must only signal cancellation. It must +not call Ruby APIs or raise an exception while the GVL is released. The request +future returns its result as a Rust value. Only after the no-GVL call returns +to the Ruby-owned thread with the GVL may [`src/rt.rs`](../src/rt.rs) map a +wreq-owned cancellation to the `Wreq::InterruptError` defined in +[`src/error.rs`](../src/error.rs). + +Keep this conversion centralized in `rt::try_block_on`. Request, response, and +body operations may call `try_block_on`, but they must not construct their own +Ruby cancellation exception. + +These forms are forbidden for wreq-owned cancellation: + +```rust +MagnusError::new(ruby.exception_interrupt(), "request interrupted") +``` + +```ruby +raise Interrupt, "request interrupted" +``` + +Using `exception_interrupt` as the parent when defining +`Wreq::InterruptError` is still required. Using it as the class passed to +`MagnusError::new` is not. + +## Review checklist + +- Reject direct construction or raising of Ruby's built-in `Interrupt` for a + wreq-owned cancellation. +- Keep `Wreq::InterruptError` as a direct subclass of `Interrupt`. +- Keep Ruby API calls and exception construction out of the no-GVL callback + and UBF. +- Preserve an exception supplied by Ruby through `Thread#raise`. +- Do not turn `Thread#kill`, `Thread#terminate`, or `Thread#exit` into a new + exception. +- Test the real cancellation path, the exception hierarchy, and the + `StandardError` boundary when changing this code. + +## Ruby references + +- [Ruby `Interrupt`](https://docs.ruby-lang.org/en/3.4/Interrupt.html) explains + that the class represents an interrupt signal, usually Control-C, and + inherits from `SignalException`. +- [Ruby's built-in exception hierarchy](https://docs.ruby-lang.org/en/4.0/Exception.html#class-Exception-label-Built-In+Exception+Class+Hierarchy) + shows that `SignalException` and `StandardError` are separate branches. +- [`Thread#raise`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-raise) + raises the caller-supplied exception in another thread. +- [`Thread#kill`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-kill) + documents asynchronous termination and its `terminate` and `exit` aliases. +- [`rb_thread_call_without_gvl`](https://docs.ruby-lang.org/capi/en/master/d6/dfb/include_2ruby_2thread_8h.html) + documents interrupt checks, GVL reacquisition, UBF cancellation, and the + restriction on Ruby API calls from no-GVL callbacks. +- [Issue #111](https://github.com/SearchApi/wreq-ruby/issues/111) contains the + original error-hierarchy discussion. diff --git a/examples/error.rb b/examples/error.rb new file mode 100644 index 0000000..597584a --- /dev/null +++ b/examples/error.rb @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +require_relative "../lib/wreq" + +begin + Wreq.get("not-a-valid-url") +rescue Wreq::Error => error + puts "#{error.class}: #{error.message}" + puts "builder: #{error.is_builder}" + puts "uri: #{error.uri.inspect}" + puts "status: #{error.status.inspect}" +end 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.rb b/lib/wreq.rb index 18ccb36..8c5d29c 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" @@ -50,8 +51,12 @@ module Wreq # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -84,8 +89,12 @@ def self.request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -118,8 +127,12 @@ def self.get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -152,8 +165,12 @@ def self.head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -186,8 +203,12 @@ def self.post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -220,8 +241,12 @@ def self.put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -254,8 +279,12 @@ def self.delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -288,8 +317,12 @@ def self.options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -322,8 +355,12 @@ def self.trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index e2ff2a3..72302de 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -70,31 +70,40 @@ class Client # used to store and retrieve cookies for all requests made by this # client. Typically used together with `cookie_store: true`. # - # @param timeout [Integer, nil] Overall timeout for the entire request + # @param timeout [Numeric, nil] Overall timeout for the entire request # in seconds, including connection establishment, request transmission, - # and response reading. If not set, requests may wait indefinitely. - # - # @param connect_timeout [Integer, nil] Maximum time in seconds to wait - # when establishing a connection to the remote server. This is separate - # from the overall timeout. - # - # @param read_timeout [Integer, nil] Maximum time in seconds to wait - # between reading chunks of data from the server. Applies to each - # read operation, not the entire response. - # - # @param tcp_keepalive [Integer, nil] Time in seconds that a connection - # must be idle before TCP keepalive probes are sent. Helps detect - # broken connections. - # - # @param tcp_keepalive_interval [Integer, nil] Time in seconds between - # individual TCP keepalive probes. Only relevant if tcp_keepalive is set. + # and response reading. Fractional seconds are accepted. The value must + # be finite and non-negative; 0 expires immediately. Nil or omission + # leaves the timeout unset. + # + # @param connect_timeout [Numeric, nil] Maximum time in seconds to wait + # when establishing a connection to the remote server. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param read_timeout [Numeric, nil] Maximum time in seconds to wait + # between reading chunks of data from the server. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param tcp_keepalive [Numeric, nil] Time in seconds that a connection + # must be idle before TCP keepalive probes are sent. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 is passed + # through as a zero duration. Nil or omission leaves the option unset. + # + # @param tcp_keepalive_interval [Numeric, nil] Time in seconds between + # individual TCP keepalive probes. Fractional seconds are accepted. The + # value must be finite and non-negative; 0 is passed through as a zero + # duration. Nil or omission leaves the option unset. # # @param tcp_keepalive_retries [Integer, nil] Number of failed keepalive # probes before the connection is considered dead and closed. # - # @param tcp_user_timeout [Integer, nil] Maximum time in seconds that + # @param tcp_user_timeout [Numeric, nil] Maximum time in seconds that # transmitted data may remain unacknowledged before the connection is - # forcibly closed. Available on Android, Fuchsia, and Linux only. + # forcibly closed. Fractional seconds are accepted. The value must be + # finite and non-negative; 0 is passed through as a zero duration. Nil or + # omission leaves the option unset. Available on Android, Fuchsia, and Linux only. # # @param tcp_nodelay [Boolean, nil] Enable TCP_NODELAY socket option, # which disables Nagle's algorithm. When true, small packets are sent @@ -105,9 +114,10 @@ class Client # allowing the reuse of local addresses in TIME_WAIT state. Useful for # reducing port exhaustion in high-throughput scenarios. # - # @param pool_idle_timeout [Integer, nil] Time in seconds before idle - # connections in the pool are evicted and closed. Helps free up - # resources for long-running applications. + # @param pool_idle_timeout [Numeric, nil] Time in seconds before idle + # connections in the pool are evicted and closed. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires idle + # entries immediately. Nil or omission leaves the timeout unset. # # @param pool_max_idle_per_host [Integer, nil] Maximum number of idle # connections to maintain per host in the connection pool. Connections @@ -134,6 +144,11 @@ class Client # including self-signed or expired ones. Should only be disabled # for testing purposes. # + # @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 # environment. @@ -270,8 +285,12 @@ def self.new(**options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -306,8 +325,12 @@ def request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -340,8 +363,12 @@ def get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -374,8 +401,12 @@ def head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -408,8 +439,12 @@ def post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -442,8 +477,12 @@ def put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -476,8 +515,12 @@ def delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -510,8 +553,12 @@ def options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -544,8 +591,12 @@ def trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. diff --git a/lib/wreq_ruby/error.rb b/lib/wreq_ruby/error.rb index f1e42f1..c98cd3e 100644 --- a/lib/wreq_ruby/error.rb +++ b/lib/wreq_ruby/error.rb @@ -2,171 +2,263 @@ unless defined?(Wreq) module Wreq + # Base class for wreq-ruby runtime errors. + # + # Error remains a RuntimeError so existing rescue handlers keep working. + # The `is_*` methods mirror predicates on the captured native `wreq::Error`. + # One error can match more than one predicate. Errors created by the binding + # itself return false for all of them. + # + # @example Rescue any wreq-ruby runtime error + # begin + # Wreq.get("not-a-valid-url") + # rescue Wreq::Error => error + # warn "#{error.class}: #{error.message}" + # warn "invalid request" if error.is_builder + # end + class Error < RuntimeError + # Get the URI recorded by the native error. + # + # This value may contain credentials, query parameters, or fragments. + # Error messages and `inspect` omit the URI. Redact it before logging it + # explicitly. + # + # @return [String, nil] Frozen URI string, if one was recorded + attr_reader :uri + + # Get the HTTP status recorded by the native error. + # + # @return [Integer, nil] HTTP status code, if one was recorded + attr_reader :status + + # @return [Boolean] Whether the native error came from a builder + def is_builder + end + + # @return [Boolean] Whether the native error came from redirect handling + def is_redirect + end + + # @return [Boolean] Whether the native error represents an HTTP status + def is_status + end + + # @return [Boolean] Whether the native error is related to a timeout + def is_timeout + end + + # @return [Boolean] Whether the native error is related to a request + def is_request + end + + # @return [Boolean] Whether the native error is related to connecting + def is_connect + end + + # @return [Boolean] Whether the native error is related to a proxy connection + def is_proxy_connect + end + + # @return [Boolean] Whether the native error is a connection reset + def is_connection_reset + end + + # @return [Boolean] Whether the native error is related to a body + def is_body + end + + # @return [Boolean] Whether the native error is related to TLS + def is_tls + end + + # @return [Boolean] Whether the native error is related to decoding + def is_decode + end + + # @return [Boolean] Whether the native error is related to an upgrade + def is_upgrade + end + end + + # Raised when Ruby interrupts a native request wait. + # + # This inherits from Interrupt instead of Error, so `rescue StandardError` + # does not swallow the interrupt. + # + # @example Handle an interrupted request separately + # begin + # Wreq.get("https://example.com", timeout: 30) + # rescue Wreq::InterruptError + # warn "request interrupted" + # rescue Wreq::Error => error + # warn error.message + # end # Keep interruption outside StandardError so a broad transport rescue # never swallows a Ruby interrupt. class InterruptError < Interrupt; end - # System-level and runtime errors - - # Memory allocation failed. - class MemoryError < StandardError; end + # Raised when single-use native state was already consumed or is borrowed. + # + # @example A closed response no longer has a readable body + # response = Wreq.get("https://example.com") + # response.close + # response.bytes # Raises Wreq::MemoryError + class MemoryError < Error; end - # The child process inherited wreq-ruby from its parent. + # Raised when a forked child tries to use inherited native state. # - # Tokio worker threads do not survive fork, and inherited pooled - # connections are not safe to reuse. This error is raised before a child - # can access that state. + # Tokio worker threads and pooled connections cannot be reused after fork. # - # @example - # Process.fork do - # Wreq::Client.new # Raises if the parent loaded wreq-ruby. + # @example Native operations are rejected in an inherited child + # pid = Process.fork do + # begin + # Wreq::Client.new + # rescue Wreq::ForkError => error + # warn error.message + # end # end + # Process.wait(pid) + # # @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/fork-safety.md - class ForkError < RuntimeError; end - - # Network connection errors + class ForkError < Error; end - # Connection to the server failed. + # Raised when the client cannot connect to the destination server. # - # Raised when the client cannot establish a connection to the server. + # The error reflects the layer that actually fails. If a system proxy or + # VPN accepts the connection but does not return a response, the request + # raises Wreq::TimeoutError instead. # - # @example + # @example Handle a destination connection failure + # client = Wreq::Client.new(no_proxy: true) # begin - # client.get("http://localhost:9999") - # rescue Wreq::ConnectionError => e - # puts "Connection failed: #{e.message}" - # retry_with_backoff + # client.get("http://127.0.0.1:1") + # rescue Wreq::ConnectionError => error + # warn "connection failed: #{error.message}" # end - class ConnectionError < StandardError; end + class ConnectionError < Error; end - # Proxy Connection to the server failed. + # Raised when the client cannot connect to the configured proxy. # - # Raised when the client cannot establish a connection to the proxy server. - # @example + # @example Handle a proxy connection failure # begin - # client.get("http://example.com", proxy: "http://invalid-proxy:8080") - # rescue Wreq::ProxyConnectionError => e - # puts "Proxy connection failed: #{e.message}" - # retry_with_different_proxy + # Wreq.get( + # "https://example.com", + # proxy: "http://127.0.0.1:1" + # ) + # rescue Wreq::ProxyConnectionError => error + # warn "proxy connection failed: #{error.message}" # end - class ProxyConnectionError < StandardError; end + class ProxyConnectionError < Error; end - # Connection was reset by the server. + # Raised when a peer resets the connection. # - # Raised when the server closes the connection unexpectedly. - # - # @example - # rescue Wreq::ConnectionResetError => e - # puts "Connection reset: #{e.message}" + # @example Handle a reset while streaming a response + # response = Wreq.get("https://example.com") + # begin + # File.open("response.bin", "wb") do |file| + # response.chunks { |chunk| file.write(chunk) } + # end + # rescue Wreq::ConnectionResetError => error + # warn "connection reset: #{error.message}" # end - class ConnectionResetError < StandardError; end + class ConnectionResetError < Error; end - # TLS/SSL error occurred. + # Raised when native TLS setup fails while constructing a client. # - # Raised when there's an error with TLS/SSL, such as certificate - # verification failure or protocol mismatch. + # The current Ruby API does not expose certificate or identity inputs that + # can deliberately trigger this error. TLS handshake and certificate + # verification failures happen while connecting and normally raise + # Wreq::ConnectionError instead. # - # @example + # @example Distinguish TLS setup errors from connection errors # begin - # client.get("https://self-signed.badssl.com") - # rescue Wreq::TlsError => e - # puts "TLS error: #{e.message}" + # Wreq::Client.new(verify: true).get("https://example.com") + # rescue Wreq::TlsError => error + # warn "TLS setup failed: #{error.message}" + # rescue Wreq::ConnectionError => error + # warn "TLS connection failed: #{error.message}" # end - class TlsError < StandardError; end - - # HTTP protocol and request/response errors + class TlsError < Error; end - # Request failed. - # - # Generic error for request failures that don't fit other categories. + # Raised for a request failure without a more specific error subclass. # - # @example - # rescue Wreq::RequestError => e - # puts "Request failed: #{e.message}" + # @example Rescue the native fallback request category + # client = Wreq::Client.new + # begin + # client.get("https://example.com") + # rescue Wreq::RequestError => error + # warn "request failed: #{error.message}" # end - class RequestError < StandardError; end + class RequestError < Error; end - # HTTP status code indicates an error. + # Raised when Response#raise_for_status! sees a 4xx or 5xx response. # - # Raised when the server returns an error status code (4xx or 5xx). + # Requests return error responses normally until this opt-in check is made. + # The inherited `status` reader returns the integer HTTP status. # # @example + # client = Wreq::Client.new # begin - # response = client.get("https://httpbin.io/status/404") - # rescue Wreq::StatusError => e - # puts "HTTP error: #{e.message}" - # # e.response contains the full response + # client.get("https://httpbin.io/status/404").raise_for_status! + # rescue Wreq::StatusError => error + # warn "HTTP #{error.status}: #{error.message}" # end - class StatusError < StandardError; end + class StatusError < Error; end - # Redirect handling failed. - # - # Raised when too many redirects occur or redirect logic fails. + # Raised when redirect handling fails, such as after too many redirects. # - # @example + # @example Limit the number of redirects + # client = Wreq::Client.new(allow_redirects: true, max_redirects: 3) # begin - # client = Wreq::Client.new(allow_redirects: true, max_redirects: 3) # client.get("https://httpbin.io/redirect/10") - # rescue Wreq::RedirectError => e - # puts "Too many redirects: #{e.message}" + # rescue Wreq::RedirectError => error + # warn "redirect failed: #{error.message}" # end - class RedirectError < StandardError; end + class RedirectError < Error; end - # Request timed out. - # - # Raised when the request exceeds the configured timeout. + # Raised when a request operation exceeds its timeout. # - # @example + # @example Handle a request timeout + # client = Wreq::Client.new(timeout: 1) # begin - # client = Wreq::Client.new(timeout: 5) # client.get("https://httpbin.io/delay/10") - # rescue Wreq::TimeoutError => e - # puts "Request timed out: #{e.message}" - # retry_with_longer_timeout + # rescue Wreq::TimeoutError => error + # warn "request timed out: #{error.message}" # end - class TimeoutError < StandardError; end + class TimeoutError < Error; end - # Data processing and encoding errors - - # Response body processing failed. - # - # Raised when there's an error reading or processing the response body. + # Raised while sending, reading, or streaming an HTTP body. # - # @example - # rescue Wreq::BodyError => e - # puts "Body error: #{e.message}" + # @example Handle a body error while streaming + # response = Wreq.get("https://example.com") + # begin + # File.open("response.bin", "wb") do |file| + # response.chunks { |chunk| file.write(chunk) } + # end + # rescue Wreq::BodyError => error + # warn "body failed: #{error.message}" # end - class BodyError < StandardError; end + class BodyError < Error; end - # Decoding response failed. - # - # Raised when response content cannot be decoded (e.g., invalid UTF-8, - # malformed JSON, corrupted compression). + # Raised when a response body cannot be decoded or parsed. # - # @example + # @example Fall back to bytes when a response is not valid JSON + # response = Wreq.get("https://example.com") # begin - # response = client.get("https://example.com/invalid-utf8") - # response.text # May raise DecodingError - # rescue Wreq::DecodingError => e - # puts "Decoding error: #{e.message}" - # # Fall back to binary data - # data = response.body + # data = response.json + # rescue Wreq::DecodingError + # data = response.bytes # end - class DecodingError < StandardError; end + class DecodingError < Error; end - # Configuration and builder errors - - # A native client or request configuration could not be built. - # - # Raised when validated Ruby options cannot be represented by the native - # builder or request body. + # Raised when client, request, header, or body configuration is invalid. # - # @example + # @example Handle an invalid request URL # begin - # client = Wreq::Client.new(proxy: "invalid://") - # rescue Wreq::BuilderError => e - # puts "Invalid configuration: #{e.message}" + # Wreq.get("not-a-valid-url") + # rescue Wreq::BuilderError => error + # warn "invalid request: #{error.message}" # end - class BuilderError < StandardError; end + class BuilderError < Error; end end end diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index 8a497db..c107359 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -2,14 +2,12 @@ unless defined?(Wreq) module Wreq - # HTTP response object containing status, headers, and body. + # An HTTP response returned by wreq-ruby. # - # This class wraps a native Rust implementation providing efficient - # access to HTTP response data including status codes, headers, body - # content, and streaming capabilities. - # - # Body methods raise Wreq::ForkError if the child inherited wreq-ruby from - # its parent. + # Response metadata can be read repeatedly. Body helpers either buffer the + # body for reuse or stream it once. Body methods and {#raise_for_status!} + # raise Wreq::ForkError in a child that inherited the extension from its + # parent. # # @example Basic response handling # response = client.get("https://api.example.com") @@ -22,8 +20,8 @@ module Wreq # # @example Streaming response # response = client.get("https://example.com/large-file") - # response.stream.each do |chunk| - # # Process chunk + # File.open("download.bin", "wb") do |file| + # response.chunks { |chunk| file.write(chunk) } # end class Response # Get the HTTP status code as an integer. @@ -43,6 +41,20 @@ def code def status end + # Return this response or raise for a 4xx or 5xx status. + # + # Requests do not raise for HTTP status codes by default. This opt-in + # check leaves the response body available. + # + # @return [Wreq::Response] The same response for a non-error status + # @raise [Wreq::StatusError] If the status is in the 4xx or 5xx range + # @raise [Wreq::ForkError] If the child inherited wreq-ruby from its parent + # @example + # response = client.get("https://example.com/missing") + # response.raise_for_status! + def raise_for_status! + end + # Get the HTTP protocol version used. # # @return [Wreq::Version] HTTP version (HTTP/1.1, HTTP/2, etc.) @@ -177,6 +189,26 @@ def chunks # response.close def close end + + # Return TLS information captured for this response. + # + # 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 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 + # + # 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 end diff --git a/lib/wreq_ruby/tls.rb b/lib/wreq_ruby/tls.rb new file mode 100644 index 0000000..65b4682 --- /dev/null +++ b/lib/wreq_ruby/tls.rb @@ -0,0 +1,73 @@ +# 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 + +# ======================== 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/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 6f8bf85..216a51a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,7 +4,7 @@ mod query; mod req; pub mod resp; -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use magnus::{Module, Object, RModule, Ruby, TryConvert, Value, function, method, typed_data::Obj}; @@ -22,6 +22,7 @@ use crate::{ http::Method, options::{NativeOption, Options}, rt, + time::Duration, }; /// A builder for `Client`. @@ -54,31 +55,38 @@ struct Builder { cookie_provider: NativeOption, // ========= Timeout options ========= - /// The timeout to use for the client. (in seconds) - timeout: Option, - /// The connect timeout to use for the client. (in seconds) - connect_timeout: Option, - /// The read timeout to use for the client. (in seconds) - read_timeout: Option, + /// Overall timeout for a request, including connection and response body. + #[serde(default)] + timeout: NativeOption, + /// Maximum duration allowed to establish a connection. + #[serde(default)] + connect_timeout: NativeOption, + /// Maximum idle duration between response body reads. + #[serde(default)] + read_timeout: NativeOption, // ========= TCP options ========= - /// Set that all sockets have `SO_KEEPALIVE` set with the supplied duration. (in seconds) - tcp_keepalive: Option, - /// Set the interval between TCP keepalive probes. (in seconds) - tcp_keepalive_interval: Option, + /// Idle duration before TCP keepalive probes begin. + #[serde(default)] + tcp_keepalive: NativeOption, + /// Interval between TCP keepalive probes. + #[serde(default)] + tcp_keepalive_interval: NativeOption, /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, - /// Set an optional user timeout for TCP sockets. (in seconds) + /// Maximum duration for which transmitted data may remain unacknowledged. + #[serde(default)] #[allow(dead_code)] - tcp_user_timeout: Option, + tcp_user_timeout: NativeOption, /// Set that all sockets have `NO_DELAY` set. tcp_nodelay: Option, /// Set that all sockets have `SO_REUSEADDR` set. tcp_reuse_address: Option, // ========= Connection pool options ========= - /// Set an optional timeout for idle sockets being kept-alive. (in seconds) - pool_idle_timeout: Option, + /// Maximum idle duration before a pooled connection is evicted. + #[serde(default)] + pool_idle_timeout: NativeOption, /// Sets the maximum idle connection per host allowed in the pool. pool_max_idle_per_host: Option, /// Sets the maximum number of connections in the pool. @@ -95,6 +103,8 @@ struct Builder { // ========= TLS options ========= /// Whether to verify TLS certificates. verify: Option, + /// Whether to retain peer certificate data on responses. + tls_info: Option, // ========= Network options ========= /// Whether to disable the proxy for the client. @@ -175,6 +185,16 @@ impl Builder { cookie_provider, Obj => |value| (*value).clone() ); + + // Duration options. + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, connect_timeout); + extract_native_option!(options, builder, read_timeout); + extract_native_option!(options, builder, tcp_keepalive); + extract_native_option!(options, builder, tcp_keepalive_interval); + extract_native_option!(options, builder, tcp_user_timeout); + extract_native_option!(options, builder, pool_idle_timeout); + builder .proxy .set(Extractor::::try_convert(options.as_value())?.into_inner()); @@ -277,18 +297,16 @@ impl Client { // TCP options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive, - tcp_keepalive, - Duration::from_secs + tcp_keepalive ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive_interval, - tcp_keepalive_interval, - Duration::from_secs + tcp_keepalive_interval ); apply_option!( set_if_some, @@ -298,11 +316,10 @@ impl Client { ); #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_user_timeout, - tcp_user_timeout, - Duration::from_secs + tcp_user_timeout ); apply_option!(set_if_some, builder, params.tcp_nodelay, tcp_nodelay); apply_option!( @@ -313,35 +330,26 @@ impl Client { ); // Timeout options. + apply_option!(set_if_some_inner, builder, params.timeout, timeout); apply_option!( - set_if_some_map, - builder, - params.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.connect_timeout, - connect_timeout, - Duration::from_secs + connect_timeout ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Pool options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.pool_idle_timeout, - pool_idle_timeout, - Duration::from_secs + pool_idle_timeout ); apply_option!( set_if_some, @@ -358,6 +366,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/req.rs b/src/client/req.rs index 0603d9e..353bb63 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -1,4 +1,4 @@ -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use http::header; @@ -17,6 +17,7 @@ use crate::{ http::{Method, Version}, options::{NativeOption, Options}, rt, + time::Duration, }; /// The parameters for a request. @@ -38,11 +39,13 @@ pub struct Request { #[allow(dead_code)] interface: Option, - /// The timeout to use for the request. - timeout: Option, + /// Overall timeout for this request, overriding the client default. + #[serde(default)] + timeout: NativeOption, - /// The read timeout to use for the request. - read_timeout: Option, + /// Maximum idle duration between body reads, overriding the client default. + #[serde(default)] + read_timeout: NativeOption, /// The HTTP version to use for the request. #[serde(default)] @@ -133,6 +136,8 @@ impl Request { Obj => |value| (*value).clone() ); extract_native_option!(options, builder, version); + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, read_timeout); extract_native_option!(options, builder, headers); extract_native_option!(options, builder, orig_headers); extract_native_option!(options, builder, cookies); @@ -202,19 +207,12 @@ pub fn execute_request>( ); // Timeout options. + apply_option!(set_if_some_inner, builder, request.timeout, timeout); apply_option!( - set_if_some_map, - builder, - request.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, request.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Network options. diff --git a/src/client/resp.rs b/src/client/resp.rs index 9cc2f98..db2223a 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -5,7 +5,7 @@ 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, Ruby, Value, scan_args::scan_args, typed_data::Obj}; use wreq::Uri; use crate::{ @@ -17,6 +17,7 @@ use crate::{ header::Headers, http::{StatusCode, Version}, rt, + tls::TlsInfo, }; /// A response from a request. @@ -71,6 +72,14 @@ impl Response { } } + /// Build a body-free native response for its status classification logic. + fn response_for_status(&self) -> wreq::Response { + let mut response = HttpResponse::new(Bytes::new()); + *response.status_mut() = self.status.0; + *response.extensions_mut() = self.state.as_ref().extensions.clone(); + wreq::Response::from(response) + } + /// Internal method to get the wreq::Response, optionally streaming the body. fn response(&self, ruby: &Ruby, stream: bool) -> Result { rt::ensure_current(ruby)?; @@ -134,6 +143,19 @@ impl Response { self.status } + /// Return this response unless its status is a client or server error. + /// + /// Delegates classification to [`wreq::Response::error_for_status_ref`] + /// without consuming the response body. + pub fn raise_for_status(ruby: &Ruby, rb_self: Obj) -> Result, Error> { + rt::ensure_current(ruby)?; + rb_self + .response_for_status() + .error_for_status_ref() + .map_err(|err| wreq_error(ruby, err))?; + Ok(rb_self) + } + /// Get the response HTTP version. #[inline] pub fn version(&self) -> Version { @@ -180,6 +202,16 @@ impl Response { self.remote_addr.map(|addr| addr.to_string()) } + /// Return peer certificate data retained for this response. + fn tls_info(&self) -> Option { + self.state + .as_ref() + .extensions + .get::() + .cloned() + .map(TlsInfo) + } + /// Get the response body as bytes. pub fn bytes(ruby: &Ruby, rb_self: &Self) -> Result { let response = rb_self.response(ruby, false)?; @@ -243,6 +275,10 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { let response = gem_module.define_class("Response", ruby.class_object())?; response.define_method("code", magnus::method!(Response::code, 0))?; response.define_method("status", magnus::method!(Response::status, 0))?; + response.define_method( + "raise_for_status!", + magnus::method!(Response::raise_for_status, 0), + )?; response.define_method("version", magnus::method!(Response::version, 0))?; response.define_method("url", magnus::method!(Response::url, 0))?; response.define_method( @@ -258,5 +294,6 @@ 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))?; Ok(()) } diff --git a/src/error.rs b/src/error.rs index 1620a40..33ee2f1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,14 +1,17 @@ use std::{ + borrow::Cow, cell::{BorrowError, BorrowMutError}, fmt, }; use magnus::{ - Error as MagnusError, RModule, Ruby, error::ErrorType, exception::ExceptionClass, prelude::*, - value::Lazy, + Attr, Class, Error as MagnusError, Exception, RModule, RObject, Ruby, TryConvert, + error::ErrorType, exception::ExceptionClass, prelude::*, value::Lazy, }; use tokio::sync::mpsc::error::SendError; +const ERROR_PREDICATES_IVAR: &str = "wreq_error_predicates"; + const RACE_CONDITION_ERROR_MSG: &str = r#"Due to Rust's memory management with borrowing, you cannot use certain instances multiple times as they may be consumed. @@ -35,60 +38,157 @@ macro_rules! define_exception { } macro_rules! initialize_exception { - ($ruby:expr, $module:expr, $name:ident, $ruby_name:literal, $parent_method:ident) => {{ - $module.define_error($ruby_name, $ruby.$parent_method())?; + ($ruby:expr, $module:expr, $name:ident, $ruby_name:literal, $parent:expr) => {{ + $module.define_error($ruby_name, $parent)?; Lazy::force(&$name, $ruby); }}; } -macro_rules! map_wreq_error { - ($ruby:expr, $err:expr, $msg:expr, $($check_method:ident => $exception:ident),* $(,)?) => { - { - $( - if $err.$check_method() { - return MagnusError::new($ruby.get_inner(&$exception), $msg); +macro_rules! define_error_mapping { + ($($predicate:ident: $method:ident => $class:ident $(($ruby_name:literal))?),+ $(,)?) => { + /// Native predicates ordered by Ruby exception class priority. + #[derive(Clone, Copy)] + #[repr(u8)] + enum ErrorPredicate { + $($predicate),+ + } + + impl ErrorPredicate { + const CLASSIFICATION_ORDER: &'static [Self] = &[$(Self::$predicate),+]; + + /// Return this predicate's position in the compact Ruby metadata. + const fn mask(self) -> u16 { + 1_u16 << (self as u8) + } + + /// Evaluate this predicate before the native error is consumed. + fn matches_wreq(self, error: &wreq::Error) -> bool { + match self { + $(Self::$predicate => error.$method(),)+ + } + } + + /// Return the Ruby class selected when this predicate has priority. + fn error_class(self) -> &'static Lazy { + match self { + $(Self::$predicate => &$class,)+ } - )* - MagnusError::new($ruby.exception_runtime_error(), $msg) + } + } + + const _: () = + assert!(ErrorPredicate::CLASSIFICATION_ORDER.len() <= u16::BITS as usize); + + $( + $(define_exception!($class, $ruby_name, exception_runtime_error);)? + )+ + + $( + fn $method(rb_self: RObject) -> Result { + error_has_predicate(rb_self, ErrorPredicate::$predicate) + } + )+ + + /// Define the native wreq predicate methods on Wreq::Error. + fn include_error_predicates(class: ExceptionClass) -> Result<(), MagnusError> { + $( + class.define_method(stringify!($method), magnus::method!($method, 0))?; + )+ + Ok(()) + } + + /// Define and retain every mapped Ruby exception class. + fn initialize_mapped_errors( + ruby: &Ruby, + gem_module: &RModule, + parent: ExceptionClass, + ) -> Result<(), MagnusError> { + $( + $( + initialize_exception!(ruby, gem_module, $class, $ruby_name, parent); + )? + )+ + Ok(()) } }; } -// System-level and runtime errors -define_exception!(MEMORY, "MemoryError", exception_runtime_error); -define_exception!(FORK_ERROR, "ForkError", exception_runtime_error); +// The first matching entry determines the Ruby exception class. +define_error_mapping! { + Builder: is_builder => BUILDER_ERROR("BuilderError"), + Body: is_body => BODY_ERROR("BodyError"), + Tls: is_tls => TLS_ERROR("TlsError"), + ConnectionReset: is_connection_reset => CONNECTION_RESET_ERROR("ConnectionResetError"), + Connect: is_connect => CONNECTION_ERROR("ConnectionError"), + ProxyConnect: is_proxy_connect => PROXY_CONNECTION_ERROR("ProxyConnectionError"), + Decode: is_decode => DECODING_ERROR("DecodingError"), + Redirect: is_redirect => REDIRECT_ERROR("RedirectError"), + Timeout: is_timeout => TIMEOUT_ERROR("TimeoutError"), + Status: is_status => STATUS_ERROR("StatusError"), + Request: is_request => REQUEST_ERROR("RequestError"), + Upgrade: is_upgrade => WREQ_ERROR, +} + +/// Native predicates retained after consuming a wreq error. +#[derive(Clone, Copy, Default)] +struct ErrorPredicates(u16); + +impl ErrorPredicates { + /// Restore predicates from compact Ruby metadata. + const fn from_bits(bits: u16) -> Self { + Self(bits) + } + + /// Return the compact representation stored on the Ruby exception. + const fn bits(self) -> u16 { + self.0 + } + + /// Return whether the set contains a native predicate. + const fn contains(self, predicate: ErrorPredicate) -> bool { + self.0 & predicate.mask() != 0 + } + + /// Include a predicate when its native check succeeds. + #[must_use] + const fn include_if(mut self, predicate: ErrorPredicate, include: bool) -> Self { + if include { + self.0 |= predicate.mask(); + } + self + } +} + +impl From<&wreq::Error> for ErrorPredicates { + /// Snapshot every native predicate before consuming the wreq error. + fn from(error: &wreq::Error) -> Self { + ErrorPredicate::CLASSIFICATION_ORDER + .iter() + .copied() + .fold(Self::default(), |predicates, predicate| { + predicates.include_if(predicate, predicate.matches_wreq(error)) + }) + } +} -// Network connection errors -define_exception!(CONNECTION_ERROR, "ConnectionError", exception_runtime_error); -define_exception!( - PROXY_CONNECTION_ERROR, - "ProxyConnectionError", - exception_runtime_error -); -define_exception!( - CONNECTION_RESET_ERROR, - "ConnectionResetError", - exception_runtime_error -); -define_exception!(TLS_ERROR, "TlsError", exception_runtime_error); - -// HTTP protocol and request/response errors -define_exception!(REQUEST_ERROR, "RequestError", exception_runtime_error); -define_exception!(STATUS_ERROR, "StatusError", exception_runtime_error); -define_exception!(REDIRECT_ERROR, "RedirectError", exception_runtime_error); -define_exception!(TIMEOUT_ERROR, "TimeoutError", exception_runtime_error); - -// Data processing and encoding errors -define_exception!(BODY_ERROR, "BodyError", exception_runtime_error); -define_exception!(DECODING_ERROR, "DecodingError", exception_runtime_error); - -// Configuration and builder errors -define_exception!(BUILDER_ERROR, "BuilderError", exception_runtime_error); +/// Native error details retained after converting a wreq error to Ruby. +struct ErrorMetadata<'a> { + uri: Option<&'a str>, + status: Option, + predicates: ErrorPredicates, +} + +// Stable roots for native errors. +define_exception!(WREQ_ERROR, "Error", exception_runtime_error); // Keep interruption outside StandardError so a broad transport rescue // never swallows a Ruby interrupt. define_exception!(INTERRUPT_ERROR, "InterruptError", exception_interrupt); +// System-level and runtime errors +define_exception!(MEMORY, "MemoryError", exception_runtime_error); +define_exception!(FORK_ERROR, "ForkError", exception_runtime_error); + /// Memory error constant pub fn memory_error(ruby: &Ruby) -> MagnusError { MagnusError::new(ruby.get_inner(&MEMORY), RACE_CONDITION_ERROR_MSG) @@ -190,19 +290,17 @@ pub fn json_serialization_error(ruby: &Ruby, err: MagnusError) -> MagnusError { ) } -/// Prefix a Magnus error while preserving its original Ruby exception class. -pub(crate) fn contextualize_magnus_error( - err: MagnusError, - context: fmt::Arguments<'_>, -) -> MagnusError { +/// Prefix a Magnus error while preserving its Ruby exception class and cause. +pub fn contextualize_magnus_error(err: MagnusError, context: fmt::Arguments<'_>) -> MagnusError { match err.error_type() { ErrorType::Error(class, message) => { MagnusError::new(*class, format!("{context}: {message}")) } - ErrorType::Exception(exception) => MagnusError::new( - exception.exception_class(), - format!("{context}: {exception}"), - ), + ErrorType::Exception(exception) => { + let contextualized: Result = + exception.funcall("exception", (format!("{context}: {exception}"),)); + contextualized.map_or_else(|error| error, MagnusError::from) + } ErrorType::Jump(_) => err, } } @@ -213,37 +311,83 @@ pub fn option_value_error(option: &str, err: MagnusError) -> MagnusError { } /// Build an `ArgumentError` from a validation message. -pub fn argument_error(ruby: &Ruby, message: impl Into) -> MagnusError { - MagnusError::new(ruby.exception_arg_error(), message.into()) +pub fn argument_error(ruby: &Ruby, message: impl Into>) -> MagnusError { + MagnusError::new(ruby.exception_arg_error(), message) } /// Builds a Ruby `RangeError` from a validation message. -pub fn range_error(ruby: &Ruby, message: impl Into) -> MagnusError { - MagnusError::new(ruby.exception_range_error(), message.into()) +pub fn range_error(ruby: &Ruby, message: impl Into>) -> MagnusError { + MagnusError::new(ruby.exception_range_error(), message) } /// Build a `TypeError` from a conversion message. -pub fn type_error(ruby: &Ruby, message: impl Into) -> MagnusError { - MagnusError::new(ruby.exception_type_error(), message.into()) +pub fn type_error(ruby: &Ruby, message: impl Into>) -> MagnusError { + MagnusError::new(ruby.exception_type_error(), message) +} + +/// Select the most specific Ruby exception class for native predicates. +fn wreq_error_class(ruby: &Ruby, predicates: ErrorPredicates) -> ExceptionClass { + for &predicate in ErrorPredicate::CLASSIFICATION_ORDER { + if predicates.contains(predicate) { + return ruby.get_inner(predicate.error_class()); + } + } + + ruby.get_inner(&WREQ_ERROR) +} + +/// Read one native predicate from a Ruby error, defaulting to false. +fn error_has_predicate(rb_self: RObject, predicate: ErrorPredicate) -> Result { + rb_self + .ivar_get::<_, Option>(ERROR_PREDICATES_IVAR) + .map(|bits| bits.is_some_and(|bits| ErrorPredicates::from_bits(bits).contains(predicate))) +} + +/// Construct a Ruby exception and attach captured native error metadata. +fn error_with_metadata( + ruby: &Ruby, + class: ExceptionClass, + message: String, + metadata: ErrorMetadata<'_>, +) -> MagnusError { + match class.new_instance((message,)).and_then(|exception| { + let object = RObject::try_convert(exception.as_value())?; + object.ivar_set(ERROR_PREDICATES_IVAR, metadata.predicates.bits())?; + + if let Some(uri) = metadata.uri { + let uri = ruby.str_new(uri); + uri.freeze(); + object.ivar_set("@uri", uri)?; + } + + if let Some(status) = metadata.status { + object.ivar_set("@status", status.as_u16())?; + } + + Ok(exception) + }) { + Ok(exception) => exception.into(), + Err(error) => error, + } } -/// Map [`wreq::Error`] to corresponding [`magnus::Error`] + +/// Map [`wreq::Error`] to corresponding [`magnus::Error`]. pub fn wreq_error(ruby: &Ruby, err: wreq::Error) -> MagnusError { - let error_msg = err.to_string(); - map_wreq_error!( + let predicates = ErrorPredicates::from(&err); + let class = wreq_error_class(ruby, predicates); + let uri = err.uri().map(ToString::to_string); + let status = err.status(); + let message = err.without_uri().to_string(); + + error_with_metadata( ruby, - err, - error_msg, - is_builder => BUILDER_ERROR, - is_body => BODY_ERROR, - is_tls => TLS_ERROR, - is_connection_reset => CONNECTION_RESET_ERROR, - is_connect => CONNECTION_ERROR, - is_proxy_connect => PROXY_CONNECTION_ERROR, - is_decode => DECODING_ERROR, - is_redirect => REDIRECT_ERROR, - is_timeout => TIMEOUT_ERROR, - is_status => STATUS_ERROR, - is_request => REQUEST_ERROR, + class, + message, + ErrorMetadata { + uri: uri.as_deref(), + status, + predicates, + }, ) } @@ -253,103 +397,43 @@ pub fn wreq_error(ruby: &Ruby, err: wreq::Error) -> MagnusError { /// /// Returns the Ruby exception raised while defining an error class. pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), MagnusError> { - initialize_exception!( - ruby, - gem_module, - MEMORY, - "MemoryError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - FORK_ERROR, - "ForkError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - CONNECTION_ERROR, - "ConnectionError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - PROXY_CONNECTION_ERROR, - "ProxyConnectionError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - CONNECTION_RESET_ERROR, - "ConnectionResetError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - TLS_ERROR, - "TlsError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - REQUEST_ERROR, - "RequestError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - STATUS_ERROR, - "StatusError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - REDIRECT_ERROR, - "RedirectError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - TIMEOUT_ERROR, - "TimeoutError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - BODY_ERROR, - "BodyError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - DECODING_ERROR, - "DecodingError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - BUILDER_ERROR, - "BuilderError", - exception_runtime_error - ); - initialize_exception!( - ruby, - gem_module, - INTERRUPT_ERROR, - "InterruptError", - exception_interrupt - ); + let error_class = gem_module.define_error("Error", ruby.exception_runtime_error())?; + error_class.define_attr("uri", Attr::Read)?; + error_class.define_attr("status", Attr::Read)?; + include_error_predicates(error_class)?; + Lazy::force(&WREQ_ERROR, ruby); + + gem_module.define_error("InterruptError", ruby.exception_interrupt())?; + Lazy::force(&INTERRUPT_ERROR, ruby); + + initialize_exception!(ruby, gem_module, MEMORY, "MemoryError", error_class); + initialize_exception!(ruby, gem_module, FORK_ERROR, "ForkError", error_class); + initialize_mapped_errors(ruby, gem_module, error_class)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ErrorPredicate, ErrorPredicates}; + + #[test] + fn error_predicate_bits_are_unique_and_round_trip() { + let predicates = ErrorPredicate::CLASSIFICATION_ORDER.iter().copied().fold( + ErrorPredicates::default(), + |predicates, predicate| { + assert!(!predicates.contains(predicate)); + predicates.include_if(predicate, true) + }, + ); + + assert_eq!( + ErrorPredicate::CLASSIFICATION_ORDER.len(), + predicates.bits().count_ones() as usize + ); + + let restored = ErrorPredicates::from_bits(predicates.bits()); + for &predicate in ErrorPredicate::CLASSIFICATION_ORDER { + assert!(restored.contains(predicate)); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 82d852d..5f0ed80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,8 @@ mod http; mod options; mod rt; mod serde; +mod time; +mod tls; use magnus::{Error, Module, Ruby, Value}; @@ -98,6 +100,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/time.rs b/src/time.rs new file mode 100644 index 0000000..a2ae8c6 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,56 @@ +//! Ruby time-value conversions shared by native binding modules. + +use std::time::Duration as StdDuration; + +use magnus::numeric::NumericValue; +use magnus::{Error, Integer, Ruby, TryConvert, Value, value::ReprValue}; + +use crate::error::argument_error; + +/// A crate-wide duration converted from non-negative Ruby `Numeric` seconds. +/// +/// Ruby integers are converted directly so the full `u64` seconds range is +/// retained. Other numeric values are converted through `f64` and then rounded +/// to the nanosecond precision of [`StdDuration`]. +pub(crate) struct Duration(pub(crate) StdDuration); + +impl TryConvert for Duration { + /// Convert integer or fractional Ruby seconds without string coercion. + /// + /// # Errors + /// + /// Returns `TypeError` for non-numeric values and `ArgumentError` for + /// negative, non-finite, or out-of-range durations. + fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); + let numeric = NumericValue::try_convert(value)?; + + // `u64::try_convert` starts with `Integer::try_convert`. Probe with + // `from_value` so a fractional Numeric takes the fallback without + // using `TypeError` as control flow or repeating the Integer check. + if let Some(integer) = Integer::from_value(numeric.as_value()) { + return integer + .to_u64() + .map(StdDuration::from_secs) + .map(Self) + .map_err(|_| invalid_duration(&ruby)); + } + + // `Float::try_convert(...).to_f64()` takes a Ruby Float detour. The + // `rb_num2dbl`-backed conversion yields the primitive required by + // `StdDuration` directly from the already-checked Numeric. + f64::try_convert(numeric.as_value()) + .and_then(|seconds| { + StdDuration::try_from_secs_f64(seconds).map_err(|_| invalid_duration(&ruby)) + }) + .map(Self) + } +} + +/// Build the shared Ruby error for a numeric value outside `Duration`'s domain. +fn invalid_duration(ruby: &Ruby) -> Error { + argument_error( + ruby, + "duration must be finite, non-negative, and within the supported range", + ) +} 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/error_hierarchy_test.rb b/test/error_hierarchy_test.rb new file mode 100644 index 0000000..0e4c1d9 --- /dev/null +++ b/test/error_hierarchy_test.rb @@ -0,0 +1,276 @@ +require "test_helper" +require "socket" +require "timeout" + +class ErrorHierarchyTest < Minitest::Test + REGULAR_ERROR_NAMES = %i[ + MemoryError + ForkError + ConnectionError + ProxyConnectionError + ConnectionResetError + TlsError + RequestError + StatusError + RedirectError + TimeoutError + BodyError + DecodingError + BuilderError + ].freeze + NATIVE_ERROR_PREDICATES = %i[ + is_builder + is_redirect + is_status + is_timeout + is_request + is_connect + is_proxy_connect + is_connection_reset + is_body + is_tls + is_decode + is_upgrade + ].freeze + + def test_regular_errors_share_stable_root + assert_equal RuntimeError, Wreq::Error.superclass + assert_operator Wreq::Error, :<, StandardError + + REGULAR_ERROR_NAMES.each do |name| + assert_equal Wreq::Error, Wreq.const_get(name).superclass + end + + error = Wreq::MemoryError.new + assert_nil error.uri + assert_nil error.status + NATIVE_ERROR_PREDICATES.each do |predicate| + assert_equal false, error.public_send(predicate) + end + end + + def test_root_and_specific_errors_can_be_rescued + root_error = begin + Wreq.get("not-a-valid-url") + rescue Wreq::Error => error + error + end + + assert_instance_of Wreq::BuilderError, root_error + assert root_error.is_builder + assert_nil root_error.status + assert_equal [:is_builder], active_native_predicates(root_error) + assert_raises(Wreq::BuilderError) { Wreq.get("not-a-valid-url") } + end + + def test_binding_generated_errors_have_no_native_predicates + error = assert_raises(Wreq::BuilderError) { Wreq::Headers.new(Object.new) } + + assert_empty active_native_predicates(error) + end + + def test_native_error_predicates_are_not_mutually_exclusive + with_hanging_server do |url, _accepted| + error = assert_raises(Wreq::TimeoutError) { Wreq.get(url, timeout: 1) } + + assert error.is_timeout + assert error.is_request + end + end + + def test_interrupt_error_stays_outside_standard_error + assert_equal Interrupt, Wreq::InterruptError.superclass + refute_operator Wreq::InterruptError, :<, StandardError + + error = assert_raises(Interrupt) do + raise Wreq::InterruptError, "request interrupted" + end + assert_instance_of Wreq::InterruptError, error + end + + def test_request_interruption_raises_interrupt_error + request_thread = nil + with_hanging_server do |url, accepted| + request_thread = Thread.new do + Wreq.get(url, timeout: 60) + rescue Interrupt, StandardError => error + error + end + request_thread.report_on_exception = false + + Timeout.timeout(5) { accepted.pop } + request_thread.wakeup + + assert request_thread.join(5), "Interrupted request thread should stop" + + error = request_thread.value + assert_instance_of Wreq::InterruptError, error + refute_kind_of StandardError, error + end + ensure + request_thread&.kill + request_thread&.join(1) + end + + def test_raise_for_status_returns_same_non_error_response + {200 => "ok", 302 => "redirect"}.each do |status, body| + with_status_server(status, body:) do |url| + response = Wreq.get(url) + + assert_same response, response.raise_for_status! + assert_equal body, response.text + end + end + end + + def test_raise_for_status_exposes_status_without_consuming_body + {404 => "missing", 503 => "unavailable"}.each do |status, body| + with_status_server(status, body:) do |url| + response = Wreq.get("#{url}?token=response-secret#fragment-secret") + error = assert_raises(Wreq::StatusError) { response.raise_for_status! } + + assert_kind_of Wreq::Error, error + assert_instance_of Integer, error.status + assert_equal status, error.status + assert_equal response.url, error.uri + assert_predicate error.uri, :frozen? + assert error.is_status + assert_equal [:is_status], active_native_predicates(error) + refute_respond_to error, :kind + refute_respond_to error, :retryable? + refute_includes error.message, "response-secret" + refute_includes error.inspect, "response-secret" + assert_equal body, response.text + end + end + end + + def test_native_error_messages_hide_sensitive_request_data + port = closed_local_port + error = assert_raises(Wreq::Error) do + Wreq.get( + "http://uri-user:uri-password@127.0.0.1:#{port}/private?token=query-secret#fragment-secret", + proxy: "http://proxy-user:proxy-secret@127.0.0.1:#{port}", + headers: {"Authorization" => "Bearer authorization-secret"}, + cookies: {"session" => "cookie-secret"}, + timeout: 1 + ) + end + + assert_instance_of String, error.uri + assert_predicate error.uri, :frozen? + assert_includes error.uri, "query-secret" + assert NATIVE_ERROR_PREDICATES.any? { |predicate| error.public_send(predicate) } + assert_nil error.status + NATIVE_ERROR_PREDICATES.each do |predicate| + assert_includes [true, false], error.public_send(predicate) + end + + [ + "uri-user", + "uri-password", + "query-secret", + "fragment-secret", + "proxy-user", + "proxy-secret", + "authorization-secret", + "cookie-secret" + ].each do |secret| + refute_includes error.message, secret + refute_includes error.inspect, secret + end + end + + def test_option_conversion_preserves_exception_cause + source = Object.new + source.define_singleton_method(:to_a) do + raise ArgumentError, "original conversion failure" + rescue ArgumentError => cause + raise IOError, "header conversion failed", cause: cause + end + + error = assert_raises(IOError) { Wreq::Client.new(headers: source) } + + assert_includes error.message, ":headers" + assert_instance_of ArgumentError, error.cause + assert_equal "original conversion failure", error.cause.message + end + + def test_option_context_preserves_native_error_metadata + error = assert_raises(Wreq::BuilderError) { Wreq::Client.new(proxy: "://") } + + assert_includes error.message, ":proxy" + assert_equal [:is_builder], active_native_predicates(error) + end + + private + + def active_native_predicates(error) + NATIVE_ERROR_PREDICATES.select { |predicate| error.public_send(predicate) } + end + + def closed_local_port + server = TCPServer.new("127.0.0.1", 0) + server.addr[1] + ensure + server&.close + end + + def with_hanging_server + server = TCPServer.new("127.0.0.1", 0) + accepted = Queue.new + thread = Thread.new do + socket = server.accept + accepted << true + sleep + rescue IOError, SystemCallError + nil + ensure + socket&.close unless socket&.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{server.addr[1]}/", accepted + ensure + server&.close unless server&.closed? + thread&.kill + thread&.join(1) + end + + def with_status_server(status, body: "") + reason = { + 200 => "OK", + 204 => "No Content", + 302 => "Found", + 404 => "Not Found", + 503 => "Service Unavailable" + }.fetch(status) + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + thread = Thread.new do + socket = server.accept + begin + while (line = socket.gets) + break if line == "\r\n" + end + socket.write "HTTP/1.1 #{status} #{reason}\r\n" + socket.write "Content-Type: text/plain\r\n" + socket.write "Content-Length: #{body.bytesize}\r\n" + socket.write "Connection: close\r\n\r\n" + socket.write body + ensure + socket.close unless socket.closed? + end + rescue IOError, SystemCallError + nil + ensure + server.close unless server.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{port}/" + ensure + server&.close unless server&.closed? + thread&.join(5) + end +end diff --git a/test/fork_test.rb b/test/fork_test.rb index 7fc7b1d..45853dd 100644 --- a/test/fork_test.rb +++ b/test/fork_test.rb @@ -17,6 +17,7 @@ class ForkTest < Minitest::Test fresh_client inherited_client inherited_response + inherited_response_status inherited_response_text inherited_response_chunks inherited_response_close diff --git a/test/scripts/fork_safety.rb b/test/scripts/fork_safety.rb index 7593b40..8cc4819 100644 --- a/test/scripts/fork_safety.rb +++ b/test/scripts/fork_safety.rb @@ -81,6 +81,7 @@ def build_inherited_objects(client, url) expect_fork_error("fresh_client") { Wreq::Client.new } expect_fork_error("inherited_client") { client.get(url) } expect_fork_error("inherited_response") { inherited_objects[2].bytes } +expect_fork_error("inherited_response_status") { inherited_objects[2].raise_for_status! } expect_fork_error("inherited_response_text") { inherited_objects[2].text(1) } expect_fork_error("inherited_response_chunks") { inherited_objects[2].chunks } expect_fork_error("inherited_response_close") { inherited_objects[2].close } 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/timeout_test.rb b/test/timeout_test.rb new file mode 100644 index 0000000..eb734bc --- /dev/null +++ b/test/timeout_test.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "test_helper" +require "socket" + +class TimeoutTest < Minitest::Test + SUBSECOND_TIMEOUT = 0.25 + SERVER_DELAY = 1.2 + + def test_client_duration_options_accept_numeric_seconds + [1, 0.125, Rational(1, 8), 0, nil].each do |value| + options = client_duration_options.to_h { |name| [name, value] } + + assert_instance_of Wreq::Client, Wreq::Client.new(**options) + end + end + + def test_each_client_duration_option_rejects_negative_seconds + client_duration_options.each do |name| + error = assert_raises(ArgumentError) do + Wreq::Client.new(**{name => -0.25}) + end + + assert_includes error.message, ":#{name}" + end + end + + def test_duration_rejects_invalid_numeric_seconds + invalid_values = [ + -1, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256 + ] + + invalid_values.each do |value| + error = assert_raises(ArgumentError) do + Wreq::Client.new(timeout: value) + end + + assert_includes error.message, ":timeout" + end + end + + def test_duration_rejects_non_numeric_values + error = assert_raises(TypeError) do + Wreq::Client.new(timeout: "0.25") + end + + assert_includes error.message, ":timeout" + end + + def test_request_duration_options_reject_invalid_values_before_network_io + invalid_values = [ + -1, + -0.25, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256, + "0.25" + ] + + %i[timeout read_timeout].each do |name| + invalid_values.each do |value| + error_class = value.is_a?(String) ? TypeError : ArgumentError + error = assert_raises(error_class) do + Wreq.get("not a url", **{name => value}) + end + + assert_includes error.message, ":#{name}" + end + end + end + + def test_request_timeouts_accept_integer_and_nil_values + with_http_server do |url| + response = Wreq.get(url, timeout: 1, read_timeout: 1) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + + with_http_server do |url| + response = Wreq.get(url, timeout: nil, read_timeout: nil) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + end + + def test_client_fractional_timeout_preserves_subsecond_value + client = Wreq::Client.new(timeout: SUBSECOND_TIMEOUT) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout { client.get(url) } + end + end + + def test_request_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(timeout: 2) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, timeout: SUBSECOND_TIMEOUT) + end + end + end + + def test_request_read_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(read_timeout: 2) + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, read_timeout: SUBSECOND_TIMEOUT).text + end + end + end + + def test_zero_request_timeouts_expire_immediately + client = Wreq::Client.new + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_immediate_timeout { client.get(url, timeout: 0) } + end + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_immediate_timeout do + client.get(url, read_timeout: 0).text + end + end + end + + private + + def client_duration_options + options = %i[ + timeout + connect_timeout + read_timeout + tcp_keepalive + tcp_keepalive_interval + pool_idle_timeout + ] + if RUBY_PLATFORM.match?(/linux|android|fuchsia/) + options << :tcp_user_timeout + end + options + end + + def assert_fractional_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :>=, 0.1, + "Fractional timeout fired too early after #{elapsed.round(3)} seconds" + assert_operator elapsed, :<, 0.8, + "Fractional timeout fired too late after #{elapsed.round(3)} seconds" + end + + def assert_immediate_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :<, 0.5, + "Zero timeout did not expire immediately (#{elapsed.round(3)} seconds)" + end + + def measure_elapsed + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + end + + def with_http_server(response_delay: 0, body_delay: 0) + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + read_request(socket) + sleep response_delay if response_delay.positive? + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Length: 2\r\n" \ + "Connection: close\r\n\r\n" + ) + sleep body_delay if body_delay.positive? + socket.write("ok") + rescue IOError, SystemCallError + nil + ensure + socket&.close + server.close unless server.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{server.addr[1]}/" + ensure + server&.close unless server&.closed? + thread&.kill + thread&.join(1) + end + + def read_request(socket) + while (line = socket.gets) + break if line == "\r\n" + end + end +end diff --git a/test/tls_info_test.rb b/test/tls_info_test.rb new file mode 100644 index 0000000..a92b8ff --- /dev/null +++ b/test/tls_info_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "support/tls_server" + +class TlsInfoTest < Minitest::Test + 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_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 + assert_equal certificate_der, closed_tls.peer_certificate + end + + assert_equal( + {connections: 1, requests: ["GET /read HTTP/1.1\r\n", "GET /close HTTP/1.1\r\n"]}, + fixture + ) + end +end