curb 1.4.1 → 1.5.0

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.
data/ext/curb_multi.c CHANGED
@@ -908,20 +908,29 @@ static VALUE call_status_handler2(VALUE ary) {
908
908
  return rb_funcall(rb_ary_entry(ary, 0), idCall, 2, rb_ary_entry(ary, 1), rb_ary_entry(ary, 2));
909
909
  }
910
910
 
911
+ /* The default body/header buffers grow geometrically, leaving up to half
912
+ * their capacity unused. Once the transfer is complete, give that back so a
913
+ * retained body_str/header_str is exact-fit. rb_str_resize only reallocates
914
+ * when the excess is significant. */
915
+ static void trim_default_buffers(ruby_curl_easy *rbce) {
916
+ VALUE body = rb_easy_get("body_data");
917
+ VALUE header = rb_easy_get("header_data");
918
+ if (RB_TYPE_P(body, T_STRING) && !OBJ_FROZEN(body)) {
919
+ rb_str_resize(body, RSTRING_LEN(body));
920
+ }
921
+ if (RB_TYPE_P(header, T_STRING) && !OBJ_FROZEN(header)) {
922
+ rb_str_resize(header, RSTRING_LEN(header));
923
+ }
924
+ }
925
+
911
926
  static void flush_stderr_if_any(ruby_curl_easy *rbce) {
912
927
  VALUE stderr_io = rb_easy_get("stderr_io");
913
- if (stderr_io != Qnil) {
914
- /* Flush via Ruby IO API */
928
+ /* The caller may have closed their IO; curb's own stream stays valid. */
929
+ if (stderr_io != Qnil && !RTEST(rb_funcall(stderr_io, rb_intern("closed?"), 0))) {
915
930
  rb_funcall(stderr_io, rb_intern("flush"), 0);
916
- #ifdef HAVE_RUBY_IO_H
917
- /* Additionally flush underlying FILE* to be extra safe. */
918
- rb_io_t *open_f_ptr;
919
- if (RB_TYPE_P(stderr_io, T_FILE)) {
920
- GetOpenFile(stderr_io, open_f_ptr);
921
- FILE *fp = rb_io_stdio_file(open_f_ptr);
922
- if (fp) fflush(fp);
923
- }
924
- #endif
931
+ }
932
+ if (rbce->stderr_file) {
933
+ fflush(rbce->stderr_file);
925
934
  }
926
935
  }
927
936
 
@@ -1203,6 +1212,7 @@ static void rb_curl_mutli_handle_complete(VALUE self, CURL *easy_handle, int res
1203
1212
 
1204
1213
  /* Flush again after removal to cover any last buffered data. */
1205
1214
  flush_stderr_if_any(rbce);
1215
+ trim_default_buffers(rbce);
1206
1216
 
1207
1217
  struct multi_complete_callback_args args = {
1208
1218
  self,
@@ -1788,6 +1798,9 @@ static int collect_io_select_ready_i(st_data_t key, st_data_t val, st_data_t arg
1788
1798
 
1789
1799
  static void rb_curl_multi_socket_drive(VALUE self, ruby_curl_multi *rbcm, multi_socket_ctx *ctx, VALUE block) {
1790
1800
  CURLMcode mrc;
1801
+ /* Looking up the Ractor-local default costs two method calls; read it once
1802
+ * per drive rather than on every wait. */
1803
+ const long default_timeout_ms = curb_multi_default_timeout();
1791
1804
 
1792
1805
  do {
1793
1806
  /* Prime the state: let libcurl act on timeouts to set up sockets. */
@@ -1799,7 +1812,7 @@ static void rb_curl_multi_socket_drive(VALUE self, ruby_curl_multi *rbcm, multi_
1799
1812
 
1800
1813
  while (rbcm->running) {
1801
1814
  struct timeval tv = {0, 0};
1802
- long wait_ms = curb_multi_default_timeout();
1815
+ long wait_ms = default_timeout_ms;
1803
1816
 
1804
1817
  if (multi_socket_timer_due(ctx)) {
1805
1818
  ctx->timeout_deadline_ms = -1;
@@ -2289,6 +2302,7 @@ static VALUE ruby_curl_multi_perform_impl(int argc, VALUE *argv, VALUE self) {
2289
2302
  CURLMcode mcode;
2290
2303
  ruby_curl_multi *rbcm;
2291
2304
  long timeout_milliseconds;
2305
+ long default_timeout_ms;
2292
2306
  VALUE block = Qnil;
2293
2307
  #ifndef CURB_USE_MULTI_POLL
2294
2308
  int maxfd, rc = -1;
@@ -2311,7 +2325,10 @@ static VALUE ruby_curl_multi_perform_impl(int argc, VALUE *argv, VALUE self) {
2311
2325
  clear_multi_deferred_exception_source_id_if_any(self);
2312
2326
  }
2313
2327
 
2314
- timeout_milliseconds = curb_multi_default_timeout();
2328
+ /* Looking up the Ractor-local default costs two method calls; read it once
2329
+ * per perform rather than up to twice per wait-loop iteration. */
2330
+ default_timeout_ms = curb_multi_default_timeout();
2331
+ timeout_milliseconds = default_timeout_ms;
2315
2332
 
2316
2333
  // Run curl_multi_perform for the first time to get the ball rolling
2317
2334
  rb_curl_multi_run( self, rbcm->handle, &(rbcm->running) );
@@ -2351,8 +2368,8 @@ static VALUE ruby_curl_multi_perform_impl(int argc, VALUE *argv, VALUE self) {
2351
2368
  continue;
2352
2369
  }
2353
2370
 
2354
- if (timeout_milliseconds < 0 || timeout_milliseconds > curb_multi_default_timeout()) {
2355
- timeout_milliseconds = curb_multi_default_timeout(); /* libcurl doesn't know how long to wait, use a default timeout */
2371
+ if (timeout_milliseconds < 0 || timeout_milliseconds > default_timeout_ms) {
2372
+ timeout_milliseconds = default_timeout_ms; /* libcurl doesn't know how long to wait, use a default timeout */
2356
2373
  /* or buggy versions libcurl sometimes reports huge timeouts... let's cap it */
2357
2374
  }
2358
2375
 
data/ext/extconf.rb CHANGED
@@ -699,6 +699,7 @@ have_header('ruby/thread.h') && have_func('rb_thread_call_without_gvl', 'ruby/th
699
699
  have_header('ruby/io.h')
700
700
  # Ruby 4.x exports rb_thread_fd_select without declaring it in ruby/io.h.
701
701
  have_func('rb_thread_fd_select')
702
+ have_func('rb_io_descriptor', 'ruby/io.h')
702
703
  have_func('rb_wait_for_single_fd', 'ruby/io.h')
703
704
  have_header('ruby/fiber/scheduler.h')
704
705
  have_func('rb_fiber_scheduler_current', 'ruby/fiber/scheduler.h')
data/lib/curl/easy.rb CHANGED
@@ -53,6 +53,11 @@ module Curl
53
53
 
54
54
  def flush_deferred_multi_closes(all_threads: false)
55
55
  state = deferred_multi_close_state
56
+ # Fast path: nothing queued, so skip the mutex and list partitioning.
57
+ # A close queued concurrently by another thread is picked up next time.
58
+ queued = state[:deferred_multi_closes]
59
+ return if queued.nil? || queued.empty?
60
+
56
61
  pending = deferred_multi_close_mutex.synchronize do
57
62
  deferred_closes = (state[:deferred_multi_closes] ||= [])
58
63
 
@@ -189,11 +194,15 @@ module Curl
189
194
  config = Curl.__send__(:ractor_local_state)[:safety_config] || Curl::SafetyConfig.new
190
195
  limit = Curl::SafetyConfig.new
191
196
  limit.max_body_bytes = options.fetch(:max_body_bytes, config.max_body_bytes)
197
+ limit.timeout = options.fetch(:timeout, config.timeout)
198
+ limit.max_redirects = options.fetch(:max_redirects, config.max_redirects)
192
199
  __curb_set_safety_override!(
193
200
  :protocols => [:http, :https],
194
201
  :redirect_protocols => [:http, :https],
195
202
  :network_policy => :public,
196
- :max_body_bytes => limit.max_body_bytes
203
+ :max_body_bytes => limit.max_body_bytes,
204
+ :timeout => limit.timeout,
205
+ :max_redirects => limit.max_redirects
197
206
  )
198
207
  end
199
208
 
@@ -205,6 +214,8 @@ module Curl
205
214
  @__curb_safety_override[:redirect_protocols] = Array(options[:redirect_protocols]).map { |protocol| protocol.to_s.downcase.to_sym } if options.key?(:redirect_protocols)
206
215
  @__curb_safety_override[:max_body_bytes] = options[:max_body_bytes] if options.key?(:max_body_bytes)
207
216
  @__curb_safety_override[:network_policy] = options[:network_policy] if options.key?(:network_policy)
217
+ @__curb_safety_override[:timeout] = options[:timeout] if options.key?(:timeout)
218
+ @__curb_safety_override[:max_redirects] = options[:max_redirects] if options.key?(:max_redirects)
208
219
  @__curb_safety_override_generation = __curb_safety_override_generation + 1
209
220
  Curl.__send__(:apply_safety!, self) if Curl.respond_to?(:apply_safety!, true)
210
221
  self
@@ -270,7 +281,9 @@ module Curl
270
281
  # the configured HTTP Verb.
271
282
  #
272
283
  def perform
273
- Curl.__send__(:apply_safety!, self) if Curl.respond_to?(:apply_safety!, true)
284
+ # Validate eagerly so an unsafe request raises in the caller (and not in
285
+ # a shared scheduler driver fiber). Multi#add re-applies it as well.
286
+ Curl.__send__(:apply_safety!, self) if Curl.__send__(:safety_active_for?, self)
274
287
  self.class.flush_deferred_multi_closes
275
288
 
276
289
  if Curl.scheduler_active? && self.multi.nil?
data/lib/curl/multi.rb CHANGED
@@ -353,6 +353,15 @@ module Curl
353
353
  ]
354
354
  end
355
355
 
356
+ # True when no per-easy safety state (override or native policy) is set.
357
+ # Callers must separately check that no global Curl.safe! config exists.
358
+ def __curb_safety_unconstrained?(easy)
359
+ easy.instance_variable_get(:@__curb_safety_override).nil? &&
360
+ easy.network_policy == :none &&
361
+ easy.allowed_hosts.nil? &&
362
+ easy.allowed_cidrs.nil?
363
+ end
364
+
356
365
  def __record_native_safety_signature(easy)
357
366
  __curb_native_safety_signatures[easy.object_id] = __curb_safety_signature_for(easy)
358
367
  end
@@ -419,24 +428,33 @@ module Curl
419
428
 
420
429
  private :__idle_easy_references, :__curb_native_safety_signatures,
421
430
  :__curb_safety_signature_for, :__record_native_safety_signature,
431
+ :__curb_safety_unconstrained?,
422
432
  :__register_idle_easy_reference,
423
433
  :__unregister_idle_easy_reference, :__clear_idle_easy_references,
424
434
  :__new_idle_easy_references
425
435
 
426
436
  alias_method :_curb_native_perform, :perform
427
437
 
428
- def perform(*args, &block)
438
+ def perform(&block)
439
+ safety_config_active = !Curl.__send__(:ractor_local_state)[:safety_config].nil?
440
+ signatures = __curb_native_safety_signatures
441
+
429
442
  requests.each_value do |easy|
430
- Curl.__send__(:apply_safety!, easy) if Curl.respond_to?(:apply_safety!, true)
443
+ # An easy added with no safety state recorded no signature. If it is
444
+ # still unconstrained its signature would be unchanged, so there is
445
+ # nothing to apply and no need to re-run native setup.
446
+ next if !safety_config_active && !signatures.key?(easy.object_id) &&
447
+ __curb_safety_unconstrained?(easy)
448
+
449
+ Curl.__send__(:apply_safety!, easy)
431
450
  signature = __curb_safety_signature_for(easy)
432
- if __curb_native_safety_signatures[easy.object_id] != signature &&
433
- easy.respond_to?(:__curb_native_setup!, true)
451
+ if signatures[easy.object_id] != signature
434
452
  easy.__send__(:__curb_native_setup!)
435
- __curb_native_safety_signatures[easy.object_id] = signature
453
+ signatures[easy.object_id] = signature
436
454
  end
437
455
  end
438
456
 
439
- _curb_native_perform(*args, &block)
457
+ _curb_native_perform(&block)
440
458
  end
441
459
 
442
460
  def add(easy)
@@ -444,11 +462,15 @@ module Curl
444
462
  # Once a deferred callback exception is pending, Multi#perform is
445
463
  # draining existing transfers only and must not start replacement work.
446
464
  return self if instance_variable_defined?(:@__curb_deferred_exception)
447
- Curl.__send__(:apply_safety!, easy) if Curl.respond_to?(:apply_safety!, true)
465
+ unconstrained = Curl.__send__(:ractor_local_state)[:safety_config].nil? &&
466
+ __curb_safety_unconstrained?(easy)
467
+ Curl.__send__(:apply_safety!, easy) unless unconstrained
448
468
  _add(easy)
449
469
  __unregister_idle_easy_reference(easy)
450
470
  requests[easy.object_id] = easy
451
- __record_native_safety_signature(easy)
471
+ # Unconstrained easies record no signature; #perform treats a missing
472
+ # signature as "added with no safety state" (see #perform).
473
+ __record_native_safety_signature(easy) unless unconstrained
452
474
  self
453
475
  end
454
476
 
data/lib/curl.rb CHANGED
@@ -12,9 +12,14 @@ module Curl
12
12
  # Ractors, even when the value itself would otherwise be safe. Keep curb's
13
13
  # mutable Ruby-side configuration in storage owned by the current Ractor.
14
14
  # Older Rubies retain the historical process-wide state.
15
+ # Ractor capabilities can't change at runtime, so probe them once instead of
16
+ # on every call: this lookup sits on the per-request path.
17
+ RACTOR_LOCAL_STORAGE = !!(defined?(Ractor) && Ractor.respond_to?(:current) &&
18
+ Ractor.current.respond_to?(:[]) && Ractor.current.respond_to?(:[]=))
19
+ private_constant :RACTOR_LOCAL_STORAGE
20
+
15
21
  def self.ractor_local_state
16
- if defined?(Ractor) && Ractor.respond_to?(:current) &&
17
- Ractor.current.respond_to?(:[]) && Ractor.current.respond_to?(:[]=)
22
+ if RACTOR_LOCAL_STORAGE
18
23
  Ractor.current[:__curb_ractor_local_state] ||= {}
19
24
  else
20
25
  @legacy_local_state ||= {}
@@ -24,14 +29,18 @@ module Curl
24
29
  class SafetyConfig
25
30
  DEFAULT_PROTOCOLS = [:http, :https].freeze
26
31
  DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
32
+ DEFAULT_TIMEOUT = 60
33
+ DEFAULT_MAX_REDIRECTS = 20
27
34
 
28
- attr_reader :max_body_bytes, :network_policy
35
+ attr_reader :max_body_bytes, :network_policy, :timeout, :max_redirects
29
36
  attr_accessor :allow_proxies, :allow_resolve, :allow_connect_to, :allow_doh, :allow_unix_socket
30
37
 
31
38
  def initialize
32
39
  @protocols = DEFAULT_PROTOCOLS
33
40
  @redirect_protocols = nil
34
41
  @max_body_bytes = DEFAULT_MAX_BODY_BYTES
42
+ @timeout = DEFAULT_TIMEOUT
43
+ @max_redirects = DEFAULT_MAX_REDIRECTS
35
44
  @network_policy = :public
36
45
  @allowed_hosts = nil
37
46
  @allowed_proxy_hosts = nil
@@ -83,6 +92,32 @@ module Curl
83
92
  @max_body_bytes = bytes.zero? ? nil : bytes
84
93
  end
85
94
 
95
+ # Seconds; applied to handles with no timeout of their own. nil disables.
96
+ def timeout=(seconds)
97
+ if seconds.nil?
98
+ @timeout = nil
99
+ return
100
+ end
101
+
102
+ seconds = Float(seconds)
103
+ raise ArgumentError, "timeout must be greater than zero" unless seconds > 0
104
+
105
+ @timeout = seconds
106
+ end
107
+
108
+ # Applied to handles whose max_redirects is unset or unlimited. nil disables.
109
+ def max_redirects=(count)
110
+ if count.nil?
111
+ @max_redirects = nil
112
+ return
113
+ end
114
+
115
+ count = Integer(count)
116
+ raise ArgumentError, "max_redirects must be greater than or equal to zero" if count < 0
117
+
118
+ @max_redirects = count
119
+ end
120
+
86
121
  def network_policy=(policy)
87
122
  @network_policy = normalize_network_policy(policy)
88
123
  end
@@ -227,9 +262,26 @@ module Curl
227
262
 
228
263
  apply_max_body_bytes!(easy, config.max_body_bytes) if config&.max_body_bytes
229
264
  apply_max_body_bytes!(easy, override[:max_body_bytes]) if override && override.key?(:max_body_bytes)
265
+ apply_default_timeout!(easy, override && override.key?(:timeout) ? override[:timeout] : config&.timeout)
266
+ apply_default_max_redirects!(easy, override && override.key?(:max_redirects) ? override[:max_redirects] : config&.max_redirects)
230
267
  easy
231
268
  end
232
269
 
270
+ # Only fills in a limit the handle has not chosen itself.
271
+ def self.apply_default_timeout!(easy, seconds)
272
+ return unless seconds
273
+ return unless easy.timeout_ms.to_i.zero?
274
+
275
+ easy.timeout = seconds
276
+ end
277
+
278
+ def self.apply_default_max_redirects!(easy, count)
279
+ return unless count
280
+
281
+ current = easy.max_redirects
282
+ easy.max_redirects = count if current.nil? || current < 0
283
+ end
284
+
233
285
  def self.clear_safe!
234
286
  ractor_local_state[:safety_config] = nil
235
287
  bump_safety_generation!
@@ -250,9 +302,7 @@ module Curl
250
302
  end
251
303
 
252
304
  def self.safety_override_for(easy)
253
- if easy.respond_to?(:__curb_safety_override, true)
254
- easy.__send__(:__curb_safety_override)
255
- end
305
+ easy.instance_variable_get(:@__curb_safety_override)
256
306
  end
257
307
 
258
308
  def self.safety_generation
@@ -417,6 +467,7 @@ module Curl
417
467
  :safety_generation, :bump_safety_generation!,
418
468
  :safety_protocol_intersection,
419
469
  :apply_max_body_bytes!, :apply_public_network_policy_controls!,
470
+ :apply_default_timeout!, :apply_default_max_redirects!,
420
471
  :apply_allowed_hosts!, :apply_allowed_cidrs!,
421
472
  :apply_allowed_proxy!, :normalize_proxy_host,
422
473
  :reject_resolve_override!, :reject_connect_to_override!,
@@ -424,6 +475,12 @@ module Curl
424
475
  :reject_unix_socket_override!,
425
476
  :disable_proxy!
426
477
 
478
+ # Multi#perform under a fiber scheduler uses the socket-action loop, which
479
+ # waits through the scheduler, exactly when this native method exists
480
+ # (same build condition). Otherwise it falls back to a thread-blocking wait.
481
+ SCHEDULER_COOPERATIVE_PERFORM = Curl::Multi.private_method_defined?(:_socket_perform)
482
+ private_constant :SCHEDULER_COOPERATIVE_PERFORM
483
+
427
484
  def self.scheduler_active?
428
485
  Fiber.respond_to?(:scheduler) && !Fiber.scheduler.nil?
429
486
  end
@@ -603,7 +660,11 @@ module Curl
603
660
  state[:multi].perform do
604
661
  drain_scheduler_pending(state)
605
662
  release_scheduler_waiters(state)
606
- scheduler_yield
663
+ # The socket-action loop already waits through the scheduler on
664
+ # every pass, so sibling fibers run without an extra yield (which
665
+ # cost ~4 yields per request). The fallback loop blocks the thread
666
+ # while waiting, so it still needs one.
667
+ scheduler_yield unless SCHEDULER_COOPERATIVE_PERFORM
607
668
  end
608
669
  ensure
609
670
  # Release any siblings that completed just before a deferred
@@ -757,7 +818,7 @@ module Curl
757
818
  end
758
819
 
759
820
  def self.safe_http_option_keys
760
- [:max_body_bytes]
821
+ [:max_body_bytes, :timeout, :max_redirects]
761
822
  end
762
823
 
763
824
  def self.reset
@@ -4,7 +4,7 @@ class BugTestInstancePostDiffersFromClassPost < Test::Unit::TestCase
4
4
  include BugTestServerSetupTeardown
5
5
 
6
6
  def setup
7
- @port = unused_local_port
7
+ @port = :auto
8
8
  @response_proc = lambda do|res|
9
9
  sleep 0.5
10
10
  res.body = "hi"
@@ -4,7 +4,7 @@ class BugFollowRedirect288 < Test::Unit::TestCase
4
4
  include BugTestServerSetupTeardown
5
5
 
6
6
  def setup
7
- @port = unused_local_port
7
+ @port = :auto
8
8
  super
9
9
  @server.mount_proc("/redirect_to_test") do|req,res|
10
10
  res.set_redirect(WEBrick::HTTPStatus::TemporaryRedirect, "/test")
data/tests/bug_poison.rb CHANGED
@@ -5,7 +5,7 @@ class BugTestPoisonResponse < Test::Unit::TestCase
5
5
  include BugTestServerSetupTeardown
6
6
 
7
7
  def setup
8
- @port = unused_local_port
8
+ @port = :auto
9
9
  @response_proc = lambda do|res|
10
10
  sleep 0.5
11
11
  res.body = "hi"
@@ -4,7 +4,7 @@ class BugRaiseOnCallback < Test::Unit::TestCase
4
4
  include BugTestServerSetupTeardown
5
5
 
6
6
  def setup
7
- @port = unused_local_port
7
+ @port = :auto
8
8
  super
9
9
  end
10
10
 
data/tests/helper.rb CHANGED
@@ -206,16 +206,17 @@ class TestServlet < WEBrick::HTTPServlet::AbstractServlet
206
206
  end
207
207
 
208
208
  module BugTestServerSetupTeardown
209
- def unused_local_port
210
- socket = TCPServer.new('127.0.0.1', 0)
211
- socket.addr[1]
212
- ensure
213
- socket.close if socket
214
- end
215
-
216
209
  def setup
217
210
  @port ||= 9992
218
- @server = WEBrick::HTTPServer.new(:Port => @port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
211
+ # Bind 127.0.0.1 explicitly: with no bind address, WEBrick silently keeps
212
+ # just the IPv6 listener if the IPv4 bind fails, leaving 127.0.0.1:@port
213
+ # refusing connections. :auto lets the kernel pick a free port at bind
214
+ # time instead of reserving one and re-binding it later, which could race
215
+ # with client connections reusing that ephemeral port (seen on macOS).
216
+ @server = WEBrick::HTTPServer.new(:Port => (@port == :auto ? 0 : @port),
217
+ :BindAddress => '127.0.0.1',
218
+ :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
219
+ @port = @server.config[:Port]
219
220
  @server.mount_proc("/test") do|req,res|
220
221
  if @response_proc
221
222
  @response_proc.call(res)
@@ -179,6 +179,32 @@ class TestCurbCurlEasy < Test::Unit::TestCase
179
179
  assert_match(/GET/, http.body)
180
180
  end
181
181
 
182
+ # The default body/header buffers used to reserve 32 KB/16 KB up front, and
183
+ # that capacity stayed attached to every returned string.
184
+ def test_small_response_strings_do_not_retain_large_buffers
185
+ require 'objspace'
186
+ easy = Curl::Easy.new(TestServlet.url)
187
+ easy.perform
188
+
189
+ body = easy.body_str
190
+ header = easy.header_str
191
+ assert_operator body.bytesize, :>, 0
192
+ assert_operator ObjectSpace.memsize_of(body), :<, 4096, 'body_str keeps excess capacity'
193
+ assert_operator ObjectSpace.memsize_of(header), :<, 4096, 'header_str keeps excess capacity'
194
+ end
195
+
196
+ def test_large_response_body_is_complete
197
+ require 'objspace'
198
+ server = CurbTinyHTTPServer.new
199
+ expected = CurbTinyHTTPServer.stream_body(64, 4096)
200
+ easy = Curl::Easy.new(server.url('/stream/64/4096'))
201
+ easy.perform
202
+ assert_equal expected, easy.body_str
203
+ assert_operator ObjectSpace.memsize_of(easy.body_str), :<, expected.bytesize + 4096
204
+ ensure
205
+ server.close if server
206
+ end
207
+
182
208
  def test_curlopt_stderr_with_file
183
209
  # does not work with Tempfile directly
184
210
  path = Tempfile.new('curb_test_curlopt_stderr').path
@@ -239,6 +265,92 @@ class TestCurbCurlEasy < Test::Unit::TestCase
239
265
  tmp.close! if defined?(tmp) && tmp
240
266
  end
241
267
 
268
+ # libcurl used to write through the IO's own FILE*, which Ruby frees on
269
+ # close. curb now owns a stream on a duplicate descriptor instead.
270
+ def test_curlopt_stderr_survives_block_form_file_close
271
+ tmp = Tempfile.new('curb_test_curlopt_stderr_closed')
272
+ easy = Curl::Easy.new(TestServlet.url)
273
+ easy.verbose = true
274
+ File.open(tmp.path, 'w') { |file| easy.setopt(Curl::CURLOPT_STDERR, file) }
275
+
276
+ 2.times { easy.perform }
277
+
278
+ output = File.read(tmp.path)
279
+ assert_equal 2, output.scan(/HTTP\/1\.1 200 OK/).length
280
+ ensure
281
+ tmp.close! if tmp
282
+ end
283
+
284
+ def test_curlopt_stderr_survives_close_after_multi_add
285
+ tmp = Tempfile.new('curb_test_curlopt_stderr_multi')
286
+ file = File.open(tmp.path, 'w')
287
+ easy = Curl::Easy.new(TestServlet.url)
288
+ easy.verbose = true
289
+ easy.setopt(Curl::CURLOPT_STDERR, file)
290
+ multi = Curl::Multi.new
291
+ multi.add(easy)
292
+ file.close
293
+ GC.start
294
+
295
+ multi.perform
296
+
297
+ assert_equal 200, easy.response_code
298
+ assert_match(/HTTP\/1\.1 200 OK/, File.read(tmp.path))
299
+ ensure
300
+ multi.close if multi
301
+ tmp.close! if tmp
302
+ end
303
+
304
+ def test_curlopt_stderr_clone_owns_its_stream
305
+ tmp = Tempfile.new('curb_test_curlopt_stderr_clone')
306
+ easy = Curl::Easy.new(TestServlet.url)
307
+ easy.verbose = true
308
+ File.open(tmp.path, 'w') { |file| easy.setopt(Curl::CURLOPT_STDERR, file) }
309
+ copy = easy.clone
310
+
311
+ # Releasing the original's stream must not affect the clone's.
312
+ easy.close
313
+ GC.start
314
+ copy.perform
315
+
316
+ assert_equal 200, copy.response_code
317
+ assert_match(/HTTP\/1\.1 200 OK/, File.read(tmp.path))
318
+ ensure
319
+ tmp.close! if tmp
320
+ end
321
+
322
+ def test_curlopt_stderr_streams_are_released
323
+ fd_dir = '/proc/self/fd'
324
+ omit('needs /proc/self/fd to count open descriptors') unless File.directory?(fd_dir)
325
+ tmp = Tempfile.new('curb_test_curlopt_stderr_release')
326
+ file = File.open(tmp.path, 'w')
327
+ easy = Curl::Easy.new(TestServlet.url)
328
+ before = Dir.children(fd_dir).length
329
+
330
+ # Replacement, reset, and close each release curb's descriptor.
331
+ 20.times { easy.setopt(Curl::CURLOPT_STDERR, file) }
332
+ easy.reset
333
+ easy.setopt(Curl::CURLOPT_STDERR, file)
334
+ easy.close
335
+ easy.setopt(Curl::CURLOPT_STDERR, file)
336
+ easy.clone.close
337
+ easy.close
338
+
339
+ assert_equal before, Dir.children(fd_dir).length
340
+ ensure
341
+ file.close if file && !file.closed?
342
+ tmp.close! if tmp
343
+ end
344
+
345
+ def test_curlopt_stderr_rejects_unwritable_io
346
+ tmp = Tempfile.new('curb_test_curlopt_stderr_readonly')
347
+ File.open(tmp.path, 'r') do |file|
348
+ assert_raise(IOError) { Curl::Easy.new.setopt(Curl::CURLOPT_STDERR, file) }
349
+ end
350
+ ensure
351
+ tmp.close! if tmp
352
+ end
353
+
242
354
  def test_head_request_no_body_and_no_timeout
243
355
  # Ensure a HEAD request completes and does not attempt to read a body
244
356
  # even when the server advertises a Content-Length.
@@ -2119,4 +2231,80 @@ class TestCurbCurlEasy < Test::Unit::TestCase
2119
2231
  server_setup
2120
2232
  end
2121
2233
 
2234
+ # Replacing request state while libcurl is still sending the old state
2235
+ # freed buffers libcurl was reading (use-after-free). Every entry point that
2236
+ # hands libcurl new request state must refuse while the handle is active.
2237
+ ACTIVE_HANDLE_MUTATORS = {
2238
+ 'post_body=' => ->(easy) { easy.post_body = 'replacement' },
2239
+ 'put_data=' => ->(easy) { easy.put_data = 'replacement' },
2240
+ 'autoreferer=' => ->(easy) { easy.autoreferer = true },
2241
+ 'http_post' => ->(easy) { easy.http_post('replacement') },
2242
+ 'http_put' => ->(easy) { easy.http_put('replacement') },
2243
+ 'http_patch' => ->(easy) { easy.http_patch('replacement') },
2244
+ 'http_delete' => ->(easy) { easy.http_delete },
2245
+ 'http(:OPTIONS)' => ->(easy) { easy.http(:OPTIONS) },
2246
+ '__curb_native_setup!' => ->(easy) { easy.__send__(:__curb_native_setup!) },
2247
+ }.freeze
2248
+
2249
+ def test_request_state_cannot_be_replaced_during_own_transfer
2250
+ ACTIVE_HANDLE_MUTATORS.each do |name, mutate|
2251
+ easy = Curl::Easy.new(TestServlet.url)
2252
+ easy.post_body = 'original'
2253
+ errors = []
2254
+ easy.on_body do |data|
2255
+ begin
2256
+ mutate.call(easy)
2257
+ rescue RuntimeError => e
2258
+ errors << e
2259
+ end
2260
+ data.bytesize
2261
+ end
2262
+
2263
+ easy.perform
2264
+
2265
+ assert_equal 1, errors.length, name
2266
+ assert_match(/Cannot modify an active curl handle/, errors.first.message, name)
2267
+ assert_equal 200, easy.response_code, name
2268
+ assert_equal 'original', easy.post_body, name
2269
+
2270
+ # Once idle, the same mutation is allowed again.
2271
+ easy.on_body
2272
+ assert_nothing_raised(name) { mutate.call(easy) }
2273
+ end
2274
+ end
2275
+
2276
+ def test_request_state_cannot_be_replaced_on_active_sibling_transfer
2277
+ ACTIVE_HANDLE_MUTATORS.each do |name, mutate|
2278
+ # The sibling's response is delayed so it is still in flight when the
2279
+ # first transfer's body callback runs.
2280
+ server = CurbTinyHTTPServer.new
2281
+ multi = Curl::Multi.new
2282
+ first = Curl::Easy.new(TestServlet.url)
2283
+ sibling = Curl::Easy.new(server.url('/delay/0.3'))
2284
+ sibling.post_body = 'original'
2285
+ errors = []
2286
+ first.on_body do |data|
2287
+ begin
2288
+ mutate.call(sibling)
2289
+ rescue RuntimeError => e
2290
+ errors << e
2291
+ end
2292
+ data.bytesize
2293
+ end
2294
+
2295
+ multi.add(first)
2296
+ multi.add(sibling)
2297
+ multi.perform
2298
+
2299
+ assert_operator errors.length, :>=, 1, name
2300
+ errors.each { |e| assert_match(/Cannot modify an active curl handle/, e.message, name) }
2301
+ assert_equal 200, sibling.response_code, name
2302
+ assert_equal 'delayed', sibling.body_str, name
2303
+ assert_equal 'original', sibling.post_body, name
2304
+ ensure
2305
+ multi.close if multi
2306
+ server.close if server
2307
+ end
2308
+ end
2309
+
2122
2310
  end