wreq 1.2.11 → 1.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,13 @@ unless defined?(Wreq)
8
8
  # access to HTTP response data including status codes, headers, body
9
9
  # content, and streaming capabilities.
10
10
  #
11
+ # A response belongs to the process that received it. Accessing its metadata
12
+ # or body after inheriting it from a parent raises Wreq::ForkError.
13
+ #
14
+ # @note Fork safety Keep each response in the process that received it.
15
+ # Issue a new request in the worker instead of carrying a response through
16
+ # `fork`.
17
+ #
11
18
  # @example Basic response handling
12
19
  # response = client.get("https://api.example.com")
13
20
  # puts response.status.as_int # => 200
@@ -26,6 +33,7 @@ unless defined?(Wreq)
26
33
  # Get the HTTP status code as an integer.
27
34
  #
28
35
  # @return [Integer] Status code (e.g., 200, 404, 500)
36
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
29
37
  # @example
30
38
  # response.code # => 200
31
39
  def code
@@ -34,6 +42,7 @@ unless defined?(Wreq)
34
42
  # Get the HTTP status code object.
35
43
  #
36
44
  # @return [Wreq::StatusCode] Status code wrapper with helper methods
45
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
37
46
  # @example
38
47
  # status = response.status
39
48
  # status.success? # => true
@@ -43,6 +52,7 @@ unless defined?(Wreq)
43
52
  # Get the HTTP protocol version used.
44
53
  #
45
54
  # @return [Wreq::Version] HTTP version (HTTP/1.1, HTTP/2, etc.)
55
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
46
56
  # @example
47
57
  # response.version # => Wreq::Version::HTTP_11
48
58
  def version
@@ -51,6 +61,7 @@ unless defined?(Wreq)
51
61
  # Get the final URL after redirects.
52
62
  #
53
63
  # @return [String] The final URL
64
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
54
65
  # @example
55
66
  # response.url # => "https://example.com/final-page"
56
67
  def url
@@ -59,6 +70,7 @@ unless defined?(Wreq)
59
70
  # Get the content length if known.
60
71
  #
61
72
  # @return [Integer, nil] Content length in bytes, or nil if unknown
73
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
62
74
  # @example
63
75
  # response.content_length # => 1024
64
76
  def content_length
@@ -72,6 +84,7 @@ unless defined?(Wreq)
72
84
  # response or a later snapshot, and object identity is not guaranteed.
73
85
  #
74
86
  # @return [Wreq::Headers] Response headers
87
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
75
88
  # @example
76
89
  # response.headers.get("content-type") # => "application/json"
77
90
  def headers
@@ -80,6 +93,7 @@ unless defined?(Wreq)
80
93
  # Get the local socket address.
81
94
  #
82
95
  # @return [String, nil] Local address (e.g., "127.0.0.1:54321"), or nil
96
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
83
97
  # @example
84
98
  # response.local_addr # => "192.168.1.100:54321"
85
99
  def local_addr
@@ -88,6 +102,7 @@ unless defined?(Wreq)
88
102
  # Get the remote socket address.
89
103
  #
90
104
  # @return [String, nil] Remote address (e.g., "93.184.216.34:443"), or nil
105
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
91
106
  # @example
92
107
  # response.remote_addr # => "93.184.216.34:443"
93
108
  def remote_addr
@@ -98,6 +113,7 @@ unless defined?(Wreq)
98
113
  # Invalid `Set-Cookie` values are skipped.
99
114
  #
100
115
  # @return [Array<Wreq::Cookie>] Parsed response cookies
116
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
101
117
  # @example
102
118
  # response.cookies.each do |cookie|
103
119
  # puts "#{cookie.name}=#{cookie.value}"
@@ -107,6 +123,7 @@ unless defined?(Wreq)
107
123
 
108
124
  # Get the response bytes as a binary string.
109
125
  # @return [String] Response body as binary data
126
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
110
127
  # @example
111
128
  # binary_data = response.bytes
112
129
  # puts binary_data.size # => 1024
@@ -122,6 +139,7 @@ unless defined?(Wreq)
122
139
  # html = response.text("ISO-8859-1")
123
140
  # puts html
124
141
  # @raise [Wreq::DecodingError] if body cannot be decoded with the specified encoding
142
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
125
143
  def text(default_encoding = "UTF-8")
126
144
  end
127
145
 
@@ -132,6 +150,7 @@ unless defined?(Wreq)
132
150
  #
133
151
  # @return [Object] Parsed JSON (Hash, Array, String, Integer, Float, Boolean, nil)
134
152
  # @raise [Wreq::DecodingError] if body is not valid JSON
153
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
135
154
  # @example
136
155
  # data = response.json
137
156
  # puts data["key"]
@@ -149,6 +168,7 @@ unless defined?(Wreq)
149
168
  # @raise [LocalJumpError] if called without a block
150
169
  # @raise [Wreq::TimeoutError, Wreq::BodyError, Wreq::ConnectionResetError, Wreq::RequestError]
151
170
  # if streaming fails while reading the response body
171
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
152
172
  # @example Save response to file
153
173
  # File.open("output.bin", "wb") do |f|
154
174
  # response.chunks { |chunk| f.write(chunk) }
@@ -165,10 +185,32 @@ unless defined?(Wreq)
165
185
  # Close the response and free associated resources.
166
186
  #
167
187
  # @return [void]
188
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
168
189
  # @example
169
190
  # response.close
170
191
  def close
171
192
  end
193
+
194
+ # Return TLS information captured for this response.
195
+ #
196
+ # Returns +nil+ when +tls_info: true+ was not enabled, the response used
197
+ # plain HTTP, or the transport supplied no TLS information. Reading or
198
+ # closing the response body does not discard captured TLS data.
199
+ #
200
+ # @return [Wreq::TlsInfo, nil] TLS information for this response, or +nil+
201
+ # when unavailable
202
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
203
+ # @example
204
+ # client = Wreq::Client.new(tls_info: true)
205
+ # response = client.get("https://example.com")
206
+ # tls = response.tls_info
207
+ #
208
+ # if tls
209
+ # tls.peer_certificate # => DER-encoded binary String
210
+ # tls.peer_certificate_chain # => frozen Array of DER binary Strings
211
+ # end
212
+ def tls_info
213
+ end
172
214
  end
173
215
  end
174
216
  end
@@ -180,6 +222,7 @@ module Wreq
180
222
  # Returns the response body as a string.
181
223
  #
182
224
  # @return [String] Response body text
225
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
183
226
  # @example
184
227
  # puts response.to_s
185
228
  # puts response
@@ -193,6 +236,7 @@ module Wreq
193
236
  # Format: #<Wreq::Response STATUS content-type="..." body=SIZE>
194
237
  #
195
238
  # @return [String] Compact formatted response information
239
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
196
240
  # @example
197
241
  # p response
198
242
  # # => #<Wreq::Response 200 content-type="application/json" body=456B>
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ unless defined?(Wreq)
4
+ module Wreq
5
+ # Peer certificate data captured for one HTTPS response.
6
+ #
7
+ # Instances are returned by {Wreq::Response#tls_info}. Certificate bytes
8
+ # remain available after the response body is read or closed, even if the
9
+ # connection is later reused.
10
+ #
11
+ # The returned certificate Strings are Ruby-owned copies. Changing one does
12
+ # not alter the stored TLS data or values returned by later calls. The chain
13
+ # Array is frozen, but its String elements remain mutable.
14
+ #
15
+ # Certificates use the DER encoding described by the X.509 profile in
16
+ # RFC 5280.
17
+ #
18
+ # @example Parse the peer certificate with OpenSSL
19
+ # require "openssl"
20
+ #
21
+ # client = Wreq::Client.new(tls_info: true)
22
+ # response = client.get("https://example.com")
23
+ # der = response.tls_info&.peer_certificate
24
+ #
25
+ # if der
26
+ # certificate = OpenSSL::X509::Certificate.new(der)
27
+ # puts certificate.subject
28
+ # end
29
+ # @see https://www.rfc-editor.org/rfc/rfc5280#section-4.1 X.509 certificate format
30
+ class TlsInfo
31
+ # Return the peer's leaf certificate.
32
+ #
33
+ # @return [String, nil] a new DER-encoded String with
34
+ # +Encoding::BINARY+, or +nil+ when the transport did not provide one
35
+ def peer_certificate
36
+ end
37
+
38
+ # Return the peer certificate chain.
39
+ #
40
+ # The Array is frozen. Each element is a new DER-encoded binary String.
41
+ # The chain includes the leaf certificate when the transport supplies it.
42
+ #
43
+ # @return [Array<String>, nil] a frozen Array of certificate copies, or
44
+ # +nil+ when the transport did not provide a chain
45
+ def peer_certificate_chain
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # ======================== Ruby API Extensions ========================
52
+
53
+ module Wreq
54
+ class TlsInfo
55
+ # Return a compact summary for debugging.
56
+ #
57
+ # The summary reports the leaf certificate size and the number of
58
+ # certificates in the chain without printing the DER data.
59
+ #
60
+ # @return [String] TLS certificate metadata
61
+ # @example
62
+ # tls_info.inspect
63
+ # # => "#<Wreq::TlsInfo peer_certificate=781B peer_certificate_chain=1>"
64
+ def inspect
65
+ certificate = peer_certificate
66
+ chain = peer_certificate_chain
67
+ certificate_size = certificate ? "#{certificate.bytesize}B" : "nil"
68
+ chain_size = chain ? chain.length : "nil"
69
+
70
+ "#<#{self.class} peer_certificate=#{certificate_size} peer_certificate_chain=#{chain_size}>"
71
+ end
72
+ end
73
+ end
data/src/arch.rs CHANGED
@@ -6,6 +6,77 @@
6
6
  //! do not leak into the rest of the binding.
7
7
  #![allow(unsafe_code)]
8
8
 
9
+ use std::mem::ManuallyDrop;
10
+
11
+ use magnus::Ruby;
12
+
13
+ #[cfg(unix)]
14
+ use crate::error::fork_error;
15
+
16
+ /// Native state that belongs to the process where it was created.
17
+ ///
18
+ /// A forked child must not destroy inherited clients, channels, or response
19
+ /// bodies because their synchronization state may belong to threads that no
20
+ /// longer exist. The child intentionally leaks the value and lets the operating
21
+ /// system reclaim it when the process exits.
22
+ ///
23
+ /// `Send` and `Sync` only describe access between threads in one process. They
24
+ /// do not make a runtime, lock, channel, or connection pool safe after `fork`.
25
+ ///
26
+ /// [`ProcessLocal::get`] is the only access path and checks the object's own
27
+ /// process generation before exposing its value.
28
+ pub(crate) struct ProcessLocal<T> {
29
+ value: ManuallyDrop<T>,
30
+ #[cfg(unix)]
31
+ owner: unix::ProcessToken,
32
+ }
33
+
34
+ impl<T> ProcessLocal<T> {
35
+ /// Wrap native state created by the current process.
36
+ pub(crate) fn new(value: T) -> Self {
37
+ Self {
38
+ value: ManuallyDrop::new(value),
39
+ #[cfg(unix)]
40
+ owner: unix::ProcessToken::current(),
41
+ }
42
+ }
43
+
44
+ /// Borrow native state only from the process that created it.
45
+ ///
46
+ /// # Errors
47
+ ///
48
+ /// Returns `Wreq::ForkError` when the value was inherited from a parent
49
+ /// process.
50
+ #[inline]
51
+ pub(crate) fn get(&self, ruby: &Ruby) -> Result<&T, magnus::Error> {
52
+ #[cfg(unix)]
53
+ if let Some((owner_pid, current_pid)) = self.owner.forked_process_ids() {
54
+ return Err(fork_error(ruby, owner_pid, current_pid));
55
+ }
56
+
57
+ #[cfg(not(unix))]
58
+ let _ = ruby;
59
+
60
+ Ok(&self.value)
61
+ }
62
+ }
63
+
64
+ impl<T> Drop for ProcessLocal<T> {
65
+ fn drop(&mut self) {
66
+ #[cfg(unix)]
67
+ if self.owner.forked_process_ids().is_some() {
68
+ return;
69
+ }
70
+
71
+ // SAFETY: `new` initializes the value exactly once, `ManuallyDrop`
72
+ // prevents an automatic second drop, and this wrapper's `Drop`
73
+ // implementation runs at most once.
74
+ unsafe {
75
+ ManuallyDrop::drop(&mut self.value);
76
+ }
77
+ }
78
+ }
79
+
9
80
  /// Whether the native client exposes TCP user-timeout configuration.
10
81
  pub(crate) const SUPPORTS_TCP_USER_TIMEOUT: bool = cfg!(any(
11
82
  target_os = "android",
@@ -27,6 +98,82 @@ pub(crate) const SUPPORTS_INTERFACE: bool = cfg!(any(
27
98
  target_os = "watchos",
28
99
  ));
29
100
 
101
+ #[cfg(unix)]
102
+ mod unix {
103
+ use std::{io, process, sync::OnceLock};
104
+
105
+ /// Identity of the process generation that created native state.
106
+ ///
107
+ /// Forkguard's child callback only advances an atomic generation counter.
108
+ /// The PID is retained for diagnostics and as a fallback if registering
109
+ /// the callback fails.
110
+ /// https://pubs.opengroup.org/onlinepubs/9799919799/functions/pthread_atfork.html
111
+ pub(super) struct ProcessToken {
112
+ detector: Option<forkguard::Guard>,
113
+ owner_pid: u32,
114
+ }
115
+
116
+ impl ProcessToken {
117
+ /// Capture the current process and fork generation.
118
+ pub(super) fn current() -> Self {
119
+ Self::try_current().unwrap_or_else(|_| Self {
120
+ detector: None,
121
+ owner_pid: process::id(),
122
+ })
123
+ }
124
+
125
+ /// Capture the current process after registering fork detection.
126
+ fn try_current() -> io::Result<Self> {
127
+ forkguard::Guard::try_new()
128
+ .map(|detector| Self {
129
+ detector: Some(detector),
130
+ owner_pid: process::id(),
131
+ })
132
+ .map_err(|error| io::Error::from_raw_os_error(error.code().get()))
133
+ }
134
+
135
+ /// Return process IDs when this token was inherited through a fork.
136
+ pub(super) fn forked_process_ids(&self) -> Option<(u32, u32)> {
137
+ if let Some(detector) = &self.detector {
138
+ // Keep the stored generation unchanged so repeated accesses
139
+ // continue to reject the same inherited object. Cloning the
140
+ // detector copies one usize.
141
+ return detector
142
+ .clone()
143
+ .detected_fork()
144
+ .then(|| (self.owner_pid, process::id()));
145
+ }
146
+
147
+ let current_pid = process::id();
148
+ (self.owner_pid != current_pid).then_some((self.owner_pid, current_pid))
149
+ }
150
+ }
151
+
152
+ /// Runtime owner captured when Tokio first initializes.
153
+ static RUNTIME_OWNER: OnceLock<ProcessToken> = OnceLock::new();
154
+
155
+ /// Register process fork tracking before the Tokio runtime is initialized.
156
+ pub(crate) fn initialize_fork_tracking() -> io::Result<()> {
157
+ if RUNTIME_OWNER.get().is_some() {
158
+ return Ok(());
159
+ }
160
+
161
+ let owner = ProcessToken::try_current()?;
162
+ let _ = RUNTIME_OWNER.set(owner);
163
+ Ok(())
164
+ }
165
+
166
+ /// Return process IDs when this process inherited an initialized runtime.
167
+ pub(crate) fn forked_process_ids() -> Option<(u32, u32)> {
168
+ RUNTIME_OWNER
169
+ .get()
170
+ .and_then(ProcessToken::forked_process_ids)
171
+ }
172
+ }
173
+
174
+ #[cfg(unix)]
175
+ pub(crate) use unix::{forked_process_ids, initialize_fork_tracking};
176
+
30
177
  #[cfg(all(target_os = "windows", target_env = "gnu"))]
31
178
  mod windows_gnu {
32
179
  //! Windows GNU support.
@@ -53,3 +200,30 @@ mod windows_gnu {
53
200
  }
54
201
  }
55
202
  }
203
+
204
+ #[cfg(test)]
205
+ mod tests {
206
+ use std::cell::Cell;
207
+
208
+ use super::ProcessLocal;
209
+
210
+ struct DropCounter<'a>(&'a Cell<usize>);
211
+
212
+ impl Drop for DropCounter<'_> {
213
+ fn drop(&mut self) {
214
+ self.0.set(self.0.get() + 1);
215
+ }
216
+ }
217
+
218
+ #[test]
219
+ fn process_local_drops_in_its_owner_process() {
220
+ let drops = Cell::new(0);
221
+
222
+ {
223
+ let value = ProcessLocal::new(DropCounter(&drops));
224
+ assert_eq!(value.value.0.get(), 0);
225
+ }
226
+
227
+ assert_eq!(drops.get(), 1);
228
+ }
229
+ }
@@ -13,6 +13,7 @@ use magnus::{Error, Integer, RString, Ruby, Value, scan_args::scan_args};
13
13
  use tokio::sync::{Mutex, Semaphore, mpsc};
14
14
 
15
15
  use crate::{
16
+ arch::ProcessLocal,
16
17
  error::{
17
18
  argument_error, body_sender_borrow_error, body_sender_borrow_mut_error,
18
19
  body_sender_send_error, closed_body_sender_error, memory_error, type_error, wreq_error,
@@ -33,7 +34,7 @@ pub struct BodyReceiver(Mutex<Pin<Box<dyn Stream<Item = wreq::Result<Bytes>> + S
33
34
  /// receiver. Ruby's GVL protects state access; no [`RefCell`] borrow is kept
34
35
  /// while request backpressure waits without the GVL.
35
36
  #[magnus::wrap(class = "Wreq::BodySender", free_immediately, size)]
36
- pub struct BodySender(RefCell<InnerBodySender>);
37
+ pub struct BodySender(ProcessLocal<RefCell<InnerBodySender>>);
37
38
 
38
39
  /// Mutable ownership state for both halves of the body channel.
39
40
  struct InnerBodySender {
@@ -64,17 +65,14 @@ impl BodyReceiver {
64
65
 
65
66
  /// Read the next body chunk, converting stream errors into Ruby errors.
66
67
  pub fn next(&self, ruby: &Ruby) -> Result<Option<Bytes>, Error> {
67
- rt::try_block_on(
68
- ruby,
69
- async {
70
- match self.0.lock().await.as_mut().next().await {
71
- Some(Ok(data)) => Ok(Some(data)),
72
- Some(Err(err)) => Err(err),
73
- None => Ok(None),
74
- }
75
- },
76
- wreq_error,
77
- )
68
+ rt::block_on(ruby, async {
69
+ match self.0.lock().await.as_mut().next().await {
70
+ Some(Ok(data)) => Ok(Some(data)),
71
+ Some(Err(err)) => Err(err),
72
+ None => Ok(None),
73
+ }
74
+ })?
75
+ .map_err(|err| wreq_error(ruby, err))
78
76
  }
79
77
  }
80
78
 
@@ -100,10 +98,12 @@ impl BodySender {
100
98
  let (tx, rx) =
101
99
  catch_unwind(|| mpsc::channel(capacity)).map_err(|_| invalid_capacity_error(ruby))?;
102
100
 
103
- Ok(BodySender(RefCell::new(InnerBodySender {
104
- tx: Some(tx),
105
- rx: Some(rx),
106
- })))
101
+ Ok(BodySender(ProcessLocal::new(RefCell::new(
102
+ InnerBodySender {
103
+ tx: Some(tx),
104
+ rx: Some(rx),
105
+ },
106
+ ))))
107
107
  }
108
108
 
109
109
  /// Push a binary chunk, waiting for capacity when the channel is full.
@@ -113,7 +113,8 @@ impl BodySender {
113
113
  /// # Errors
114
114
  ///
115
115
  /// Returns `IOError` after either channel side has closed. An interrupted
116
- /// wait raises `Wreq::InterruptError`.
116
+ /// wait raises `Wreq::InterruptError`. Returns `Wreq::ForkError` before
117
+ /// reading an inherited channel.
117
118
  pub fn push(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<(), Error> {
118
119
  // Clone during the shared borrow, then release it before waiting
119
120
  // for capacity. Request attachment needs a mutable borrow.
@@ -122,7 +123,8 @@ impl BodySender {
122
123
  _ => return Err(closed_body_sender_error(ruby)),
123
124
  };
124
125
 
125
- rt::try_block_on(ruby, tx.send(data.to_bytes()), body_sender_send_error)
126
+ rt::block_on(ruby, tx.send(data.to_bytes()))?
127
+ .map_err(|err| body_sender_send_error(ruby, err))
126
128
  }
127
129
 
128
130
  /// Close the producing side while retaining the receiver and queued chunks.
@@ -131,7 +133,8 @@ impl BodySender {
131
133
  ///
132
134
  /// # Errors
133
135
  ///
134
- /// Returns `Wreq::BodyError` if the internal state is already borrowed.
136
+ /// Returns `Wreq::ForkError` before reading an inherited channel, or
137
+ /// `Wreq::BodyError` if the internal state is already borrowed.
135
138
  pub fn close(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> {
136
139
  let mut inner = rb_self.write_inner(ruby)?;
137
140
  inner.tx.take();
@@ -142,21 +145,24 @@ impl BodySender {
142
145
  ///
143
146
  /// # Errors
144
147
  ///
145
- /// Returns `Wreq::BodyError` if the internal state is already borrowed.
148
+ /// Returns `Wreq::ForkError` before reading an inherited channel, or
149
+ /// `Wreq::BodyError` if the internal state is already borrowed.
146
150
  pub fn is_closed(ruby: &Ruby, rb_self: &Self) -> Result<bool, Error> {
147
151
  rb_self.read_inner(ruby).map(|r| r.is_closed())
148
152
  }
149
153
 
150
- /// Borrow the channel state without panicking on accidental re-entry.
154
+ /// Borrow channel state only in the process that created this sender.
151
155
  fn read_inner(&self, ruby: &Ruby) -> Result<Ref<'_, InnerBodySender>, Error> {
152
156
  self.0
157
+ .get(ruby)?
153
158
  .try_borrow()
154
159
  .map_err(|err| body_sender_borrow_error(ruby, err))
155
160
  }
156
161
 
157
- /// Mutably borrow the channel state without panicking on accidental re-entry.
162
+ /// Mutably borrow channel state only in the process that created this sender.
158
163
  fn write_inner(&self, ruby: &Ruby) -> Result<RefMut<'_, InnerBodySender>, Error> {
159
164
  self.0
165
+ .get(ruby)?
160
166
  .try_borrow_mut()
161
167
  .map_err(|err| body_sender_borrow_mut_error(ruby, err))
162
168
  }