curb 1.3.5 → 1.3.7

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/tests/helper.rb CHANGED
@@ -238,6 +238,85 @@ module BugTestServerSetupTeardown
238
238
  end
239
239
  end
240
240
 
241
+ # Test::Unit creates a new test instance for every test method. Keep the
242
+ # common WEBrick fixture process-scoped so those instances can share it while
243
+ # still allowing an explicit stop_test_server call to exercise lifecycle code.
244
+ module CurbTestServerRegistry
245
+ Entry = Struct.new(:key, :port, :server, :thread, :lock_file, :pid)
246
+
247
+ @mutex = Mutex.new
248
+ @entries = {}
249
+
250
+ class << self
251
+ def acquire(port, servlet)
252
+ key = [port, servlet]
253
+
254
+ @mutex.synchronize do
255
+ entry = @entries[key]
256
+ return entry if active?(entry)
257
+
258
+ @entries.delete(key)
259
+ entry = yield
260
+ @entries[key] = entry if entry
261
+ entry
262
+ end
263
+ end
264
+
265
+ def stop(entry, timeout)
266
+ return false unless remove(entry)
267
+
268
+ shutdown_entry(entry, timeout)
269
+ true
270
+ end
271
+
272
+ def register_shutdown_hook(entry)
273
+ # Test::Unit can run a directly executed test file from its own at_exit
274
+ # callback. Register after startup so this hook runs after that callback.
275
+ at_exit { stop(entry, 5) }
276
+ end
277
+
278
+ private
279
+
280
+ def active?(entry)
281
+ entry && entry.server && entry.thread && entry.thread.alive? && entry.server.status == :Running
282
+ rescue StandardError
283
+ false
284
+ end
285
+
286
+ def remove(entry)
287
+ @mutex.synchronize do
288
+ return false unless @entries[entry.key].equal?(entry)
289
+
290
+ @entries.delete(entry.key)
291
+ true
292
+ end
293
+ end
294
+
295
+ def shutdown_entry(entry, timeout)
296
+ return unless Process.pid == entry.pid
297
+
298
+ begin
299
+ entry.server.shutdown if entry.server
300
+ rescue StandardError
301
+ nil
302
+ end
303
+
304
+ thread = entry.thread
305
+ if thread
306
+ thread.join(timeout)
307
+ if thread.alive?
308
+ thread.kill
309
+ thread.join(timeout)
310
+ end
311
+ end
312
+
313
+ File.unlink(entry.lock_file) if entry.lock_file && File.exist?(entry.lock_file)
314
+ rescue Errno::ENOENT
315
+ nil
316
+ end
317
+ end
318
+ end
319
+
241
320
  module TestServerMethods
242
321
  def server_responding?(port)
243
322
  socket = TCPSocket.new('127.0.0.1', port)
@@ -267,6 +346,22 @@ module TestServerMethods
267
346
  end
268
347
  end
269
348
 
349
+ # Waiting on WEBrick's state avoids opening a probe connection for every
350
+ # startup. On Windows those short-lived connections make server teardown
351
+ # disproportionately expensive.
352
+ def wait_for_server_started(server, port, thread: nil)
353
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + server_startup_timeout
354
+
355
+ loop do
356
+ return true if server.status == :Running
357
+ return false if thread && !thread.alive?
358
+
359
+ raise "Failed to startup test server on port #{port}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
360
+
361
+ sleep 0.01
362
+ end
363
+ end
364
+
270
365
  def server_shutdown_timeout
271
366
  [server_startup_timeout, 0.25].max
272
367
  end
@@ -337,6 +432,13 @@ module TestServerMethods
337
432
  end
338
433
 
339
434
  def stop_test_server
435
+ shared_entry = instance_variable_defined?(:@__shared_test_server_entry) ? @__shared_test_server_entry : nil
436
+ if shared_entry
437
+ CurbTestServerRegistry.stop(shared_entry, server_shutdown_timeout)
438
+ release_shared_test_server
439
+ return
440
+ end
441
+
340
442
  server = instance_variable_defined?(:@server) ? @server : nil
341
443
  pid = instance_variable_defined?(:@__pid) ? @__pid : nil
342
444
  return unless server || pid
@@ -382,11 +484,28 @@ module TestServerMethods
382
484
  def teardown
383
485
  super
384
486
  ensure
385
- stop_test_server
487
+ if instance_variable_defined?(:@__shared_test_server_entry) && @__shared_test_server_entry
488
+ release_shared_test_server
489
+ else
490
+ stop_test_server
491
+ end
386
492
  end
387
493
 
388
494
  def server_setup(port=9129,servlet=TestServlet)
389
495
  @__port = port
496
+
497
+ unless TEST_SINGLE_THREADED
498
+ entry = CurbTestServerRegistry.acquire(port, servlet) do
499
+ start_shared_test_server(port, servlet)
500
+ end
501
+
502
+ if entry
503
+ attach_shared_test_server(entry)
504
+ end
505
+
506
+ return
507
+ end
508
+
390
509
  @server = nil unless instance_variable_defined?(:@server)
391
510
  @__pid = nil unless instance_variable_defined?(:@__pid)
392
511
  @test_thread = nil unless instance_variable_defined?(:@test_thread)
@@ -474,6 +593,64 @@ module TestServerMethods
474
593
  end
475
594
  rescue Errno::EADDRINUSE
476
595
  end
596
+
597
+ def attach_shared_test_server(entry)
598
+ @server = entry.server
599
+ @test_thread = entry.thread
600
+ @__pid = nil
601
+ @__shared_test_server_entry = entry
602
+ end
603
+
604
+ def release_shared_test_server
605
+ @server = nil
606
+ @test_thread = nil
607
+ @__pid = nil
608
+ @__shared_test_server_entry = nil
609
+ end
610
+
611
+ def start_shared_test_server(port, servlet)
612
+ clear_stale_server_lock(port)
613
+
614
+ if File.exist?(locked_file)
615
+ begin
616
+ wait_for_server_ready(port)
617
+ return nil
618
+ rescue RuntimeError
619
+ clear_stale_server_lock(port)
620
+ end
621
+ end
622
+
623
+ write_server_lock
624
+ server = WEBrick::HTTPServer.new(:Port => port, :DocumentRoot => File.expand_path(File.dirname(__FILE__)), :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
625
+
626
+ server.mount(servlet.path, servlet)
627
+ server.mount("/ext", WEBrick::HTTPServlet::FileHandler, File.join(File.dirname(__FILE__),'..','ext'))
628
+
629
+ thread = Thread.new(server) { |srv| srv.start }
630
+ unless wait_for_server_started(server, port, thread: thread)
631
+ server.shutdown
632
+ thread.join(server_shutdown_timeout)
633
+ raise "Failed to startup test server on port #{port}"
634
+ end
635
+
636
+ entry = CurbTestServerRegistry::Entry.new([port, servlet], port, server, thread, locked_file, Process.pid)
637
+ CurbTestServerRegistry.register_shutdown_hook(entry)
638
+ entry
639
+ rescue Errno::EADDRINUSE
640
+ File.unlink(locked_file) if File.exist?(locked_file)
641
+ return nil if server_responding?(port)
642
+
643
+ raise
644
+ rescue StandardError
645
+ begin
646
+ server.shutdown if defined?(server) && server
647
+ thread.join(server_shutdown_timeout) if defined?(thread) && thread
648
+ rescue StandardError
649
+ nil
650
+ end
651
+ File.unlink(locked_file) if File.exist?(locked_file)
652
+ raise
653
+ end
477
654
  end
478
655
 
479
656
 
@@ -0,0 +1,105 @@
1
+ # Reproduces the FIBER_TODO.md [Critical] finding: a Fiber scheduler that
2
+ # implements io_wait/kernel_sleep/block/unblock but NOT the optional io_select
3
+ # hook. rb_fiber_scheduler_io_select returns Qundef (without waiting) for such
4
+ # schedulers; if the multi-fd wait path of Curl::Multi#_socket_perform treats
5
+ # that as a completed wait, the drive loop spins at 100% CPU with no interrupt
6
+ # checkpoint and this script never exits — it cannot even service SIGTERM.
7
+ #
8
+ # Run by tc_fiber_scheduler.rb in a subprocess guarded by a SIGKILL fuse.
9
+ # Usage: ruby io_select_less_scheduler_probe.rb <port>
10
+ # <port> must serve HTTP GET /test (see BugTestServerSetupTeardown).
11
+
12
+ require 'fiber'
13
+ require 'curb'
14
+
15
+ port = Integer(ARGV[0])
16
+ url = "http://127.0.0.1:#{port}/test"
17
+
18
+ class NoIoSelectScheduler
19
+ def fiber(&block)
20
+ Fiber.new(blocking: false, &block)
21
+ end
22
+
23
+ def io_wait(io, events, timeout = nil)
24
+ readers = (events & IO::READABLE) != 0 ? [io] : nil
25
+ writers = (events & IO::WRITABLE) != 0 ? [io] : nil
26
+ deadline = monotonic_now + timeout if timeout
27
+
28
+ loop do
29
+ readable, writable = IO.select(readers, writers, nil, 0)
30
+ ready = 0
31
+ ready |= IO::READABLE if readable && !readable.empty?
32
+ ready |= IO::WRITABLE if writable && !writable.empty?
33
+ return ready unless ready.zero?
34
+ return false if deadline && monotonic_now >= deadline
35
+
36
+ Fiber.yield
37
+ end
38
+ end
39
+
40
+ def kernel_sleep(duration = nil)
41
+ deadline = monotonic_now + (duration || 0)
42
+ Fiber.yield while monotonic_now < deadline
43
+ duration
44
+ end
45
+
46
+ def block(_blocker, timeout = nil)
47
+ kernel_sleep(timeout)
48
+ false
49
+ end
50
+
51
+ def unblock(*); end
52
+
53
+ def close; end
54
+
55
+ def fiber_interrupt(*); end
56
+
57
+ private
58
+
59
+ def monotonic_now
60
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ end
62
+ end
63
+
64
+ results = []
65
+ ticks = 0
66
+ done = false
67
+
68
+ Fiber.set_scheduler(NoIoSelectScheduler.new)
69
+ fiber = Fiber.schedule do
70
+ multi = Curl::Multi.new
71
+ begin
72
+ # Two easies so the drive loop tracks more than one socket and takes the
73
+ # multi-fd (io_select) wait branch.
74
+ 2.times do
75
+ easy = Curl::Easy.new(url)
76
+ easy.on_complete { results << easy.response_code }
77
+ multi.add(easy)
78
+ end
79
+ multi.send(:_socket_perform)
80
+ ensure
81
+ multi.close
82
+ done = true
83
+ end
84
+ end
85
+ ticker = Fiber.schedule do
86
+ until done
87
+ sleep 0.005
88
+ ticks += 1 unless done
89
+ end
90
+ end
91
+
92
+ while fiber.alive? || ticker.alive?
93
+ fiber.resume if fiber.alive?
94
+ ticker.resume if ticker.alive?
95
+ sleep 0.001
96
+ end
97
+ Fiber.set_scheduler(nil)
98
+
99
+ if results == [200, 200]
100
+ puts "OK ticks=#{ticks}"
101
+ exit 0
102
+ else
103
+ puts "FAILED results=#{results.inspect}"
104
+ exit 1
105
+ end
@@ -1,4 +1,5 @@
1
1
  require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+ require 'tmpdir'
2
3
 
3
4
  class TestCurbCurlDownload < Test::Unit::TestCase
4
5
  include TestServerMethods
@@ -18,6 +19,91 @@ class TestCurbCurlDownload < Test::Unit::TestCase
18
19
  File.unlink(dl_path) if File.exist?(dl_path)
19
20
  end
20
21
 
22
+ def test_download_default_path_does_not_overwrite_existing_file
23
+ dl_url = "http://127.0.0.1:9129/ext/curb_easy.c"
24
+
25
+ Dir.mktmpdir('curb-download-') do |dir|
26
+ Dir.chdir(dir) do
27
+ File.write('curb_easy.c', 'sentinel')
28
+
29
+ assert_raise(Curl::DownloadTargetExistsError) do
30
+ Curl::Easy.download(dl_url)
31
+ end
32
+
33
+ assert_equal 'sentinel', File.read('curb_easy.c')
34
+ end
35
+ end
36
+ end
37
+
38
+ def test_download_default_path_strips_query_from_url_basename
39
+ dl_url = "http://127.0.0.1:9129/ext/curb_easy.c?cache=1"
40
+ source_path = File.expand_path(File.join(File.dirname(__FILE__), '..','ext','curb_easy.c'))
41
+
42
+ Dir.mktmpdir('curb-download-') do |dir|
43
+ Dir.chdir(dir) do
44
+ Curl::Easy.download(dl_url)
45
+
46
+ assert File.exist?('curb_easy.c')
47
+ assert !File.exist?('curb_easy.c?cache=1')
48
+ assert_equal File.read(source_path), File.read('curb_easy.c')
49
+ end
50
+ end
51
+ end
52
+
53
+ def test_download_safe_directory_rejects_unsafe_names
54
+ dl_url = "http://127.0.0.1:9129/ext/curb_easy.c"
55
+
56
+ Dir.mktmpdir('curb-download-') do |dir|
57
+ ['../escape.c', '.env', 'nested/file', '/tmp/escape.c', "bad\0name"].each do |name|
58
+ assert_raise(ArgumentError, "expected #{name.inspect} to be rejected") do
59
+ Curl::Easy.download(dl_url, name, :download_dir => dir)
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ def test_download_safe_directory_rejects_dotfile_from_url
66
+ dl_url = "http://127.0.0.1:9129/ext/.env"
67
+
68
+ Dir.mktmpdir('curb-download-') do |dir|
69
+ assert_raise(ArgumentError) do
70
+ Curl::Easy.download(dl_url, :download_dir => dir)
71
+ end
72
+
73
+ assert !File.exist?(File.join(dir, '.env'))
74
+ end
75
+ end
76
+
77
+ def test_download_overwrite_true_replaces_existing_file
78
+ dl_url = "http://127.0.0.1:9129/ext/curb_easy.c"
79
+
80
+ Dir.mktmpdir('curb-download-') do |dir|
81
+ dl_path = File.join(dir, 'curb_easy.c')
82
+ File.write(dl_path, 'sentinel')
83
+
84
+ Curl::Easy.download(dl_url, dl_path, :overwrite => true)
85
+
86
+ assert_equal File.read(File.join(File.dirname(__FILE__), '..','ext','curb_easy.c')), File.read(dl_path)
87
+ end
88
+ end
89
+
90
+ def test_download_callback_abort_does_not_replace_existing_file
91
+ dl_url = "http://127.0.0.1:9129/ext/curb_easy.c"
92
+
93
+ Dir.mktmpdir('curb-download-') do |dir|
94
+ dl_path = File.join(dir, 'curb_easy.c')
95
+ File.write(dl_path, 'sentinel')
96
+
97
+ assert_raise(Curl::Err::WriteError, Curl::Err::RecvError) do
98
+ Curl::Easy.download(dl_url, dl_path, :overwrite => true) do |curl|
99
+ curl.on_body { |_data| 0 }
100
+ end
101
+ end
102
+
103
+ assert_equal 'sentinel', File.read(dl_path)
104
+ end
105
+ end
106
+
21
107
  def test_download_url_to_file_via_file_io
22
108
  dl_url = "http://127.0.0.1:9129/ext/curb_easy.c"
23
109
  dl_path = File.join(Dir::tmpdir, "dl_url_test.file")
@@ -18,6 +18,13 @@ class TestCurbCurlEasy < Test::Unit::TestCase
18
18
  assert_equal 200, easy.code
19
19
  end
20
20
 
21
+ def test_perform_with_implicit_multi
22
+ easy = Curl::Easy.new($TEST_URL)
23
+
24
+ assert_nothing_raised { easy.perform }
25
+ assert_match(/^# DO NOT REMOVE THIS COMMENT/, easy.body_str)
26
+ end
27
+
21
28
  def test_curlopt_resolve
22
29
  require 'resolv'
23
30
  uri = URI.parse(TestServlet.url)
@@ -39,6 +46,7 @@ class TestCurbCurlEasy < Test::Unit::TestCase
39
46
 
40
47
  http = Curl::Easy.new(url)
41
48
  http.dns_cache_timeout = 0
49
+ http.proxy_url = ""
42
50
  http.resolve = [mapping]
43
51
  http.get
44
52
  assert_match(/GET/, http.body)
@@ -1629,6 +1637,36 @@ class TestCurbCurlEasy < Test::Unit::TestCase
1629
1637
  easy.http_get
1630
1638
  end
1631
1639
 
1640
+ def test_easy_reset_clears_network_policy_allowlists
1641
+ easy = Curl::Easy.new(TestServlet.url)
1642
+ easy.allowed_hosts = ['127.0.0.1']
1643
+ easy.network_policy = :public
1644
+ easy.allowed_cidrs = ['1.1.1.0/24']
1645
+
1646
+ settings = easy.reset
1647
+
1648
+ assert settings.key?(:allowed_hosts)
1649
+ assert settings.key?(:allowed_cidrs)
1650
+ assert_nil easy.allowed_hosts
1651
+ assert_nil easy.allowed_cidrs
1652
+ assert_equal :none, easy.network_policy
1653
+ rescue NotImplementedError
1654
+ omit('network_policy=:public requires CURLOPT_OPENSOCKETFUNCTION support')
1655
+ end
1656
+
1657
+ def test_frozen_easy_close_and_reset_skip_safety_override_cleanup
1658
+ closed_easy = Curl::Easy.new
1659
+ closed_easy.freeze
1660
+
1661
+ assert_nothing_raised { closed_easy.close }
1662
+
1663
+ reset_easy = Curl::Easy.new
1664
+ reset_easy.safe_http!
1665
+ reset_easy.freeze
1666
+
1667
+ assert_nothing_raised { reset_easy.reset }
1668
+ end
1669
+
1632
1670
  def test_last_result_initialization
1633
1671
  # Test for issue #463 - ensure last_result is properly initialized to 0
1634
1672
  easy = Curl::Easy.new
@@ -1780,6 +1818,35 @@ class TestCurbCurlEasy < Test::Unit::TestCase
1780
1818
  end
1781
1819
  end
1782
1820
 
1821
+ def test_close_during_http_post_argument_coercion_is_blocked
1822
+ curl = Curl::Easy.new(TestServlet.url)
1823
+ field = Object.new
1824
+ field.define_singleton_method(:to_s) do
1825
+ curl.close
1826
+ "a=b"
1827
+ end
1828
+
1829
+ error = assert_raise(RuntimeError) { curl.http_post(field) }
1830
+ assert_match(/Cannot close an active curl handle/, error.message)
1831
+
1832
+ assert_nothing_raised { curl.close }
1833
+ end
1834
+
1835
+ def test_close_during_url_coercion_is_blocked
1836
+ curl = Curl::Easy.new
1837
+ url = Object.new
1838
+ url.define_singleton_method(:to_str) do
1839
+ curl.close
1840
+ TestServlet.url
1841
+ end
1842
+
1843
+ curl.url = url
1844
+ error = assert_raise(RuntimeError) { curl.perform }
1845
+ assert_match(/Cannot close an active curl handle/, error.message)
1846
+
1847
+ assert_nothing_raised { curl.close }
1848
+ end
1849
+
1783
1850
  def test_close_in_on_progress_is_blocked
1784
1851
  curl = Curl::Easy.new(TestServlet.url)
1785
1852
  did_raise = false
@@ -1865,6 +1932,16 @@ class TestCurbCurlEasy < Test::Unit::TestCase
1865
1932
  end
1866
1933
  end
1867
1934
 
1935
+ def test_poison
1936
+ res_a = Curl.get("#{TestServlet.url}?a_body")
1937
+ first_a_body = Digest::MD5.hexdigest(res_a.body)
1938
+ res_b = Curl.get("#{TestServlet.url}?b_body")
1939
+
1940
+ b_body = Digest::MD5.hexdigest(res_b.body)
1941
+ a_body = Digest::MD5.hexdigest(res_a.body)
1942
+ assert_not_equal a_body, b_body, "we expected #{a_body} to be #{first_a_body}"
1943
+ end
1944
+
1868
1945
  include TestServerMethods
1869
1946
 
1870
1947
  def setup
@@ -22,6 +22,7 @@ class TestCurbCurlEasyResolve < Test::Unit::TestCase
22
22
  mapping = "#{host}:#{TestServlet.port}:127.0.0.1"
23
23
 
24
24
  @easy.url = "http://#{host}:#{TestServlet.port}#{TestServlet.path}"
25
+ @easy.proxy_url = ""
25
26
  @easy.dns_cache_timeout = 0
26
27
  @easy.set(:resolve, [mapping])
27
28
 
@@ -39,6 +40,7 @@ class TestCurbCurlEasyResolve < Test::Unit::TestCase
39
40
  entry.define_singleton_method(:to_s) { mapping }
40
41
 
41
42
  @easy.url = "http://#{host}:#{TestServlet.port}#{TestServlet.path}"
43
+ @easy.proxy_url = ""
42
44
  @easy.dns_cache_timeout = 0
43
45
  @easy.resolve = [entry]
44
46