curb 1.3.7 → 1.4.1

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
@@ -228,12 +228,17 @@ module BugTestServerSetupTeardown
228
228
  @thread = Thread.new(@server) do|srv|
229
229
  srv.start
230
230
  end
231
+
232
+ # WEBrick#shutdown is a no-op until start has created its shutdown pipe
233
+ # and set :Running, so wait for that before the test (and its teardown)
234
+ # runs. Otherwise a teardown racing server startup could miss the brief
235
+ # :Shutdown status and spin forever once the server settled back on :Stop.
236
+ sleep 0.001 until @server.status == :Running || !@thread.alive?
237
+ @thread.value unless @thread.alive?
231
238
  end
232
239
 
233
240
  def teardown
234
- while @server.status != :Shutdown
235
- @server.shutdown
236
- end
241
+ @server.shutdown
237
242
  @thread.join
238
243
  end
239
244
  end
@@ -730,3 +735,117 @@ end
730
735
  unless URI.methods.include?(:encode_www_form)
731
736
  URI.extend(Backports::Ruby18::URIFormEncoding)
732
737
  end
738
+
739
+ # Valgrind slows every request enough that tight wall-clock bounds stop
740
+ # meaning anything; tests use this to relax or skip only those bounds.
741
+ def curb_under_valgrind?
742
+ ENV['LD_PRELOAD'].to_s.include?('vgpreload')
743
+ end
744
+
745
+ # Queue#pop(timeout:) needs Ruby 3.2. Older Rubies take the keyword hash as
746
+ # the positional non_block flag and raise ThreadError immediately.
747
+ def curb_pop_within(queue, seconds)
748
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
749
+ loop do
750
+ begin
751
+ return queue.pop(true)
752
+ rescue ThreadError
753
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
754
+ sleep 0.01
755
+ end
756
+ end
757
+ end
758
+
759
+ # Minimal HTTP/1.1 server so a test controls exactly when and how a response
760
+ # is written. Every request gets its own connection (Connection: close).
761
+ #
762
+ # /fast "fast" immediately
763
+ # /delay/<seconds> "delayed" after sleeping
764
+ # /hang "released" once release_hung is called
765
+ # /stream/<chunks>/<n> <chunks> writes of <n> bytes (see stream_body)
766
+ # /truncate promises 100000 bytes, sends 10, then closes
767
+ # /reset sends part of a body, then aborts with a TCP RST
768
+ class CurbTinyHTTPServer
769
+ attr_reader :port, :requests
770
+
771
+ # Chunk i is filled with one letter so a test can verify reassembly.
772
+ def self.stream_body(chunks, bytes)
773
+ (0...chunks).map { |i| (97 + (i % 26)).chr * bytes }.join
774
+ end
775
+
776
+ def initialize
777
+ @listener = TCPServer.new('127.0.0.1', 0)
778
+ @port = @listener.addr[1]
779
+ @requests = Queue.new
780
+ @release = Queue.new
781
+ @clients = []
782
+ @thread = Thread.new { accept_loop }
783
+ end
784
+
785
+ def url(path)
786
+ "http://127.0.0.1:#{@port}#{path}"
787
+ end
788
+
789
+ # Let pending /hang requests finish.
790
+ def release_hung(count = 64)
791
+ count.times { @release << true }
792
+ end
793
+
794
+ def close
795
+ release_hung
796
+ @listener.close rescue nil
797
+ @thread.kill
798
+ @thread.join(2)
799
+ @clients.each { |t| t.kill; t.join(1) }
800
+ end
801
+
802
+ private
803
+
804
+ def accept_loop
805
+ loop do
806
+ sock = @listener.accept
807
+ @clients << Thread.new(sock) { |s| handle(s) }
808
+ end
809
+ rescue IOError, Errno::EBADF
810
+ nil
811
+ end
812
+
813
+ def handle(sock)
814
+ request_line = sock.gets.to_s
815
+ while (line = sock.gets) && line != "\r\n"; end
816
+ path = request_line.split(' ')[1].to_s
817
+ @requests << path
818
+
819
+ case path
820
+ when %r{\A/delay/([\d.]+)}
821
+ sleep Float($1)
822
+ respond(sock, "delayed")
823
+ when '/hang'
824
+ curb_pop_within(@release, 10)
825
+ respond(sock, "released")
826
+ when %r{\A/stream/(\d+)/(\d+)}
827
+ chunks = Integer($1)
828
+ bytes = Integer($2)
829
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: #{chunks * bytes}\r\nConnection: close\r\n\r\n")
830
+ chunks.times do |i|
831
+ sock.write((97 + (i % 26)).chr * bytes)
832
+ Thread.pass
833
+ end
834
+ when '/truncate'
835
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n0123456789")
836
+ when '/reset'
837
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n0123456789")
838
+ sock.setsockopt(Socket::Option.linger(true, 0))
839
+ else
840
+ respond(sock, "fast")
841
+ end
842
+ rescue IOError, SystemCallError
843
+ nil
844
+ ensure
845
+ sock.close rescue nil
846
+ end
847
+
848
+ def respond(sock, body)
849
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
850
+ end
851
+ end
@@ -1,9 +1,131 @@
1
1
  require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
- class FooNoToS
2
+ class FooNoToS
3
3
  undef to_s
4
4
  end
5
5
 
6
+ class FooBadToS
7
+ def to_s
8
+ 42
9
+ end
10
+ end
11
+
6
12
  class TestCurbCurlEasy < Test::Unit::TestCase
13
+ CONNECTION_INFO_METHODS = %i[
14
+ last_effective_url response_code code primary_ip http_connect_code file_time
15
+ total_time name_lookup_time connect_time app_connect_time pre_transfer_time
16
+ start_transfer_time redirect_time redirect_count redirect_url downloaded_bytes
17
+ uploaded_bytes download_speed upload_speed header_size request_size
18
+ ssl_verify_result downloaded_content_length uploaded_content_length content_type
19
+ os_errno num_connects cookielist ftp_entry_path
20
+ ].freeze
21
+
22
+ def test_uninitialized_handles_reject_native_operations
23
+ subclass = Class.new(Curl::Easy) { def initialize; end }
24
+ [Curl::Easy.allocate, subclass.new].each do |easy|
25
+ methods = CONNECTION_INFO_METHODS + %i[reset close clone dup perform]
26
+ methods.each do |method|
27
+ next unless easy.respond_to?(method)
28
+ error = assert_raise(Curl::Err::FailedInitError, method.to_s) { easy.public_send(method) }
29
+ assert_match(/not initialized/, error.message)
30
+ end
31
+ [[:escape, "x"], [:unescape, "x"], [:setopt, Curl::CURLOPT_VERBOSE, true],
32
+ [:http_post, "x"], [:http_put, "x"], [:http_patch, "x"]].each do |args|
33
+ assert_raise(Curl::Err::FailedInitError, args.first.to_s) { easy.public_send(*args) }
34
+ end
35
+ multi = Curl::Multi.new
36
+ begin
37
+ assert_raise(Curl::Err::FailedInitError) { multi.add(easy) }
38
+ assert_raise(Curl::Err::FailedInitError) { multi.send(:_remove, easy) }
39
+ ensure
40
+ multi.close
41
+ end
42
+ end
43
+ end
44
+
45
+ def test_allocated_handle_can_be_initialized
46
+ easy = Curl::Easy.allocate
47
+ easy.send(:initialize, $TEST_URL)
48
+ easy.perform
49
+ # libcurl normalizes file:///C:/ to file://C:/ on Windows.
50
+ expected_url = WINDOWS ? $TEST_URL.sub(%r{\Afile:///}, "file://") : $TEST_URL
51
+ assert_equal expected_url, easy.last_effective_url
52
+ assert_match(/^# DO NOT REMOVE THIS COMMENT/, easy.body_str)
53
+ end
54
+
55
+ def test_connection_info_before_perform
56
+ easy = Curl::Easy.new
57
+ CONNECTION_INFO_METHODS.each do |method|
58
+ next unless easy.respond_to?(method)
59
+ assert_nothing_raised(method.to_s) { easy.public_send(method) }
60
+ end
61
+ assert_nil easy.last_effective_url
62
+ assert_nil easy.content_type
63
+ assert_equal 0, easy.response_code
64
+ end
65
+
66
+ def test_reinitialize_detaches_previous_handle
67
+ easy = Curl::Easy.new($TEST_URL)
68
+ easy.headers = { "X-Old" => "old" }
69
+ multi = Curl::Multi.new
70
+ multi.add(easy)
71
+ easy.send(:initialize, $TEST_URL)
72
+ assert_nil easy.multi
73
+ assert_equal({}, easy.headers)
74
+ multi.perform
75
+ easy.perform
76
+ assert_match(/^# DO NOT REMOVE THIS COMMENT/, easy.body_str)
77
+ multi.add(easy)
78
+ multi.perform
79
+ assert_match(/^# DO NOT REMOVE THIS COMMENT/, easy.body_str)
80
+ ensure
81
+ multi.close if multi
82
+ end
83
+
84
+ [:initialize, :close, :reset].each do |operation|
85
+ define_method("test_#{operation}_removes_multi_bookkeeping_before_readd") do
86
+ easy = Curl::Easy.new($TEST_URL)
87
+ multi = Curl::Multi.new
88
+ multi.add(easy)
89
+ easy.__send__(operation)
90
+
91
+ assert_nil easy.multi
92
+ assert_empty multi.requests
93
+ assert_empty multi.__send__(:__curb_native_safety_signatures)
94
+
95
+ easy.url = $TEST_URL
96
+ multi.add(easy)
97
+ assert_equal [easy], multi.requests.values
98
+ multi.perform
99
+ assert_match(/^# DO NOT REMOVE THIS COMMENT/, easy.body_str)
100
+ ensure
101
+ multi.close if multi
102
+ easy.close if easy
103
+ end
104
+ end
105
+
106
+ def test_reinitialize_during_callback_is_blocked
107
+ easy = Curl::Easy.new($TEST_URL)
108
+ easy.on_body do |data|
109
+ error = assert_raise(RuntimeError) { easy.send(:initialize, $TEST_URL) }
110
+ assert_match(/Cannot initialize an active curl handle/, error.message)
111
+ data.bytesize
112
+ end
113
+ easy.perform
114
+ easy.send(:initialize, $TEST_URL)
115
+ easy.perform
116
+ assert_match(/^# DO NOT REMOVE THIS COMMENT/, easy.body_str)
117
+ end
118
+
119
+ def test_reinitialize_during_argument_coercion_is_blocked
120
+ easy = Curl::Easy.new($TEST_URL)
121
+ field = Object.new
122
+ field.define_singleton_method(:to_s) { easy.send(:initialize, $TEST_URL); "a=b" }
123
+ error = assert_raise(RuntimeError) { easy.http_post(field) }
124
+ assert_match(/Cannot initialize an active curl handle/, error.message)
125
+ easy.perform
126
+ assert_match(/^# DO NOT REMOVE THIS COMMENT/, easy.body_str)
127
+ end
128
+
7
129
  def test_global_reset
8
130
  Curl.get($TEST_URL)
9
131
  # in a Timeout block you should reset the thread current handle
@@ -560,7 +682,24 @@ class TestCurbCurlEasy < Test::Unit::TestCase
560
682
  assert_equal "one\000two three", c.unescape("one%00two%20three")
561
683
  end
562
684
  end
563
-
685
+
686
+ def test_escape_with_non_string_to_s_result_does_not_crash
687
+ c = Curl::Easy.new
688
+ result = nil
689
+ # #to_s returning a non-String (42, a Fixnum) makes rb_obj_as_string
690
+ # fall back to the default Object#to_s representation rather than "42" -
691
+ # the point of this test is that it doesn't crash, not the exact value.
692
+ assert_nothing_raised { result = c.escape(FooBadToS.new) }
693
+ assert_kind_of String, result
694
+ end
695
+
696
+ def test_unescape_with_non_string_to_s_result_raises_type_error
697
+ c = Curl::Easy.new
698
+ # unescape expects an actual String (or something #to_str-coercible);
699
+ # it must raise cleanly rather than crash on a bad #to_s.
700
+ assert_raise(TypeError) { c.unescape(FooBadToS.new) }
701
+ end
702
+
564
703
  def test_headers
565
704
  c = Curl::Easy.new($TEST_URL)
566
705
 
@@ -745,7 +884,23 @@ class TestCurbCurlEasy < Test::Unit::TestCase
745
884
  c.http_auth_types = nil
746
885
  assert_nil c.http_auth_types
747
886
  end
748
-
887
+
888
+ def test_http_auth_types_with_non_string_to_s_result_does_not_crash
889
+ c = Curl::Easy.new
890
+ assert_nothing_raised { c.http_auth_types = FooBadToS.new }
891
+ assert_nothing_raised { c.http_auth_types = [FooBadToS.new, :basic] }
892
+ assert_equal Curl::CURLAUTH_BASIC, c.http_auth_types
893
+ end
894
+
895
+ def test_http_auth_types_array_of_symbols
896
+ c = Curl::Easy.new
897
+ c.http_auth_types = [:basic, :digest]
898
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_DIGEST, c.http_auth_types
899
+
900
+ c.send(:http_auth_types=, :basic, :ntlm)
901
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_NTLM, c.http_auth_types
902
+ end
903
+
749
904
  def test_proxy_auth_types_01
750
905
  c = Curl::Easy.new($TEST_URL)
751
906
 
@@ -757,7 +912,23 @@ class TestCurbCurlEasy < Test::Unit::TestCase
757
912
  c.proxy_auth_types = nil
758
913
  assert_nil c.proxy_auth_types
759
914
  end
760
-
915
+
916
+ def test_proxy_auth_types_symbols
917
+ c = Curl::Easy.new
918
+
919
+ c.proxy_auth_types = :basic
920
+ assert_equal Curl::CURLAUTH_BASIC, c.proxy_auth_types
921
+
922
+ c.proxy_auth_types = [:basic, :ntlm]
923
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_NTLM, c.proxy_auth_types
924
+
925
+ c.send(:proxy_auth_types=, :basic, :digest)
926
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_DIGEST, c.proxy_auth_types
927
+
928
+ assert_nothing_raised { c.proxy_auth_types = [FooBadToS.new, :basic] }
929
+ assert_equal Curl::CURLAUTH_BASIC, c.proxy_auth_types
930
+ end
931
+
761
932
  def test_max_redirects_01
762
933
  c = Curl::Easy.new($TEST_URL)
763
934
 
@@ -46,14 +46,17 @@ class TestCurbCurlEasyCookielist < Test::Unit::TestCase
46
46
  expires = (Date.today + 2).to_datetime
47
47
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/set_cookies"
48
48
  easy.setopt(Curl::CURLOPT_COOKIELIST, "Set-Cookie: c1=v1; domain=localhost; expires=#{expires.httpdate};")
49
- easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', domain: 'localhost', expires: expires.httpdate, path: '/' }])
49
+ # c2 has no Domain attribute so it is host-only on every libcurl version.
50
+ # With domain=localhost, curl >= 8.22 built with libpsl makes it host-only
51
+ # (localhost is a public suffix) while older curl tail-matches (#483).
52
+ easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', expires: expires.httpdate, path: '/' }])
50
53
  easy.perform
51
54
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/get_cookies"
52
55
  easy.post_body = nil
53
56
  easy.perform
54
57
 
55
58
  assert !easy.enable_cookies?
56
- assert_equal [".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc1\tv1", ".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
59
+ assert_equal [".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc1\tv1", "localhost\tFALSE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
57
60
  assert_equal 'c2=v2; c1=v1', easy.body_str
58
61
  end
59
62
 
@@ -183,13 +186,15 @@ class TestCurbCurlEasyCookielist < Test::Unit::TestCase
183
186
  cookiefile = File.join(Dir.tmpdir, 'curl_test_cookiefile')
184
187
  assert !File.exist?(cookiefile)
185
188
  begin
189
+ # Use host-only file cookies: libpsl builds normalize localhost domain
190
+ # cookies on file load, but COOKIELIST imports retain tail matching.
186
191
  cookielist = [
187
192
  # Won't be updated, added instead
188
- ".localhost\tTRUE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent\t84",
189
- ".localhost\tTRUE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent_file\t84",
193
+ "localhost\tFALSE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent\t84",
194
+ "localhost\tFALSE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent_file\t84",
190
195
  # Won't be updated, added instead
191
- ".localhost\tTRUE\t/\tFALSE\t0\tsession\t840",
192
- ".localhost\tTRUE\t/\tFALSE\t0\tsession_file\t840",
196
+ "localhost\tFALSE\t/\tFALSE\t0\tsession\t840",
197
+ "localhost\tFALSE\t/\tFALSE\t0\tsession_file\t840",
193
198
  '',
194
199
  ]
195
200
  File.write(cookiefile, cookielist.join("\n"))
@@ -201,10 +206,10 @@ class TestCurbCurlEasyCookielist < Test::Unit::TestCase
201
206
  expected_cookielist = [
202
207
  ".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tpermanent\t42",
203
208
  ".localhost\tTRUE\t/\tFALSE\t0\tsession\t420",
204
- ".localhost\tTRUE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent\t84",
205
- ".localhost\tTRUE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent_file\t84",
206
- ".localhost\tTRUE\t/\tFALSE\t0\tsession\t840",
207
- ".localhost\tTRUE\t/\tFALSE\t0\tsession_file\t840",
209
+ "localhost\tFALSE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent\t84",
210
+ "localhost\tFALSE\t/\tFALSE\t#{expires_file.to_time.to_i}\tpermanent_file\t84",
211
+ "localhost\tFALSE\t/\tFALSE\t0\tsession\t840",
212
+ "localhost\tFALSE\t/\tFALSE\t0\tsession_file\t840",
208
213
  ]
209
214
  assert_equal expected_cookielist, easy.cookielist
210
215
  easy.perform
@@ -245,14 +250,15 @@ class TestCurbCurlEasyCookielist < Test::Unit::TestCase
245
250
  expires = (Date.today + 2).to_datetime
246
251
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/set_cookies"
247
252
  easy.setopt(Curl::CURLOPT_COOKIELIST, command)
248
- easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', domain: 'localhost', expires: expires.httpdate, path: '/' }])
253
+ # host-only c2, see test_setopt_cookielist_enables_cookie_engine (#483)
254
+ easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', expires: expires.httpdate, path: '/' }])
249
255
  easy.perform
250
256
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/get_cookies"
251
257
  easy.post_body = nil
252
258
  easy.perform
253
259
 
254
260
  assert !easy.enable_cookies?
255
- assert_equal [".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
261
+ assert_equal ["localhost\tFALSE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
256
262
  assert_equal 'c2=v2', easy.body_str
257
263
  end
258
264
  end
@@ -10,6 +10,94 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
10
10
  super
11
11
  end
12
12
 
13
+ def test_safe_body_limit_defaults_and_raw_requests
14
+ assert_equal 10 * 1024 * 1024, Curl::SafetyConfig.new.max_body_bytes
15
+ assert_nil @easy.max_body_bytes
16
+ stub_public_network_policy_for_local_fixture(@easy)
17
+ @easy.safe_http!
18
+ assert_equal Curl::SafetyConfig::DEFAULT_MAX_BODY_BYTES, @easy.max_body_bytes
19
+ @easy.max_body_bytes = nil
20
+ Curl.__send__(:apply_safety!, @easy)
21
+ assert_equal Curl::SafetyConfig::DEFAULT_MAX_BODY_BYTES, @easy.max_body_bytes
22
+ end
23
+
24
+ [:global, :helper, :handle].each do |mode|
25
+ define_method("test_#{mode}_default_body_limit_rejects_oversized_response") do
26
+ payload = 'x' * (Curl::SafetyConfig::DEFAULT_MAX_BODY_BYTES + 1)
27
+ with_raw_http_response("HTTP/1.1 200 OK\r\nContent-Length: #{payload.bytesize}\r\n\r\n#{payload}") do |url|
28
+ assert_body_limit_error do
29
+ if mode == :helper
30
+ Curl.safe_get(url) { |easy| stub_public_network_policy_for_local_fixture(easy) }
31
+ else
32
+ Curl.safe! if mode == :global
33
+ @easy.url = url
34
+ stub_public_network_policy_for_local_fixture(@easy)
35
+ @easy.safe_http! if mode == :handle
36
+ @easy.perform
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ def test_default_body_limit_bounds_callback_delivery_without_content_length
44
+ limit = Curl::SafetyConfig::DEFAULT_MAX_BODY_BYTES
45
+ payload = 'x' * (limit + 1)
46
+ received = 0
47
+ with_raw_http_response("HTTP/1.0 200 OK\r\n\r\n#{payload}") do |url|
48
+ @easy.url = url
49
+ stub_public_network_policy_for_local_fixture(@easy)
50
+ @easy.safe_http!
51
+ @easy.on_body { |data| received += data.bytesize; data.bytesize }
52
+ assert_body_limit_error { @easy.perform }
53
+ end
54
+ assert_operator received, :<=, limit
55
+ assert_operator received, :>, 0
56
+ end
57
+
58
+ def test_global_body_limit_can_be_explicitly_disabled
59
+ Curl.safe! { |config| config.max_body_bytes = nil }
60
+ stub_public_network_policy_for_local_fixture(@easy)
61
+ @easy.safe_http!
62
+ assert_nil @easy.max_body_bytes
63
+ end
64
+
65
+ def test_safe_helper_accepts_explicit_larger_limit_and_unlimited
66
+ [20 * 1024 * 1024, nil, 0].each do |limit|
67
+ with_raw_http_response("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") do |url|
68
+ easy = Curl.safe_get(url, :max_body_bytes => limit) do |handle|
69
+ stub_public_network_policy_for_local_fixture(handle)
70
+ end
71
+ assert_equal(limit == 0 ? nil : limit, easy.max_body_bytes)
72
+ assert_equal 'ok', easy.body_str
73
+ end
74
+ end
75
+ end
76
+
77
+ def test_safe_helper_inherits_global_body_limit
78
+ Curl.safe! { |config| config.max_body_bytes = 20 * 1024 * 1024 }
79
+ stub_public_network_policy_for_local_fixture(@easy)
80
+ @easy.safe_http!
81
+ assert_equal 20 * 1024 * 1024, @easy.max_body_bytes
82
+ end
83
+
84
+ def test_safe_helper_cannot_weaken_global_body_limit
85
+ Curl.safe! { |config| config.max_body_bytes = 10 }
86
+ [100, nil, 0].each do |limit|
87
+ with_raw_http_response("HTTP/1.1 200 OK\r\nContent-Length: 64\r\n\r\n#{'x' * 64}") do |url|
88
+ assert_body_limit_error do
89
+ Curl.safe_get(url, :max_body_bytes => limit) do |handle|
90
+ stub_public_network_policy_for_local_fixture(handle)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ def test_safe_http_rejects_negative_body_limit
98
+ assert_raise(ArgumentError) { @easy.safe_http!(:max_body_bytes => -1) }
99
+ end
100
+
13
101
  def test_maxfilesize_setopt
14
102
  @easy.set(Curl::CURLOPT_MAXFILESIZE, 5000000)
15
103
  @easy.set(Curl::CURLOPT_MAXFILESIZE_LARGE, 5000000) if Curl.const_defined?(:CURLOPT_MAXFILESIZE_LARGE)
@@ -41,7 +129,7 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
41
129
  def test_safe_get_accepts_max_body_bytes_as_second_argument
42
130
  with_raw_http_response("HTTP/1.1 200 OK\r\nContent-Length: 64\r\n\r\n#{'x' * 64}") do |url|
43
131
  assert_body_limit_error do
44
- Curl.safe_get(url, :max_body_bytes => 10)
132
+ Curl.safe_get(url, :max_body_bytes => 10) { |easy| stub_public_network_policy_for_local_fixture(easy) }
45
133
  end
46
134
  end
47
135
  end
@@ -50,6 +138,7 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
50
138
  with_raw_http_response("HTTP/1.1 200 OK\r\nContent-Length: 64\r\n\r\n#{'x' * 64}") do |url|
51
139
  assert_body_limit_error do
52
140
  Curl.safe_get(url, {}, :max_body_bytes => 10) do |easy|
141
+ stub_public_network_policy_for_local_fixture(easy)
53
142
  easy.max_body_bytes = nil
54
143
  end
55
144
  end
@@ -63,6 +152,7 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
63
152
  end
64
153
 
65
154
  @easy.url = url
155
+ stub_public_network_policy_for_local_fixture(@easy)
66
156
 
67
157
  assert_body_limit_error do
68
158
  @easy.perform
@@ -78,6 +168,7 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
78
168
 
79
169
  assert_body_limit_error do
80
170
  Curl.get(url) do |easy|
171
+ stub_public_network_policy_for_local_fixture(easy)
81
172
  easy.max_body_bytes = nil
82
173
  end
83
174
  end
@@ -91,7 +182,7 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
91
182
  end
92
183
 
93
184
  assert_body_limit_error do
94
- Curl.safe_get(url, :max_body_bytes => 10)
185
+ Curl.safe_get(url, :max_body_bytes => 10) { |easy| stub_public_network_policy_for_local_fixture(easy) }
95
186
  end
96
187
  end
97
188
  end
@@ -106,7 +197,7 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
106
197
 
107
198
  def test_safe_head_does_not_apply_body_cap_to_content_length
108
199
  with_raw_http_response("HTTP/1.1 200 OK\r\nContent-Length: 65536\r\n\r\n") do |url|
109
- easy = Curl.safe_head(url, :max_body_bytes => 1)
200
+ easy = Curl.safe_head(url, :max_body_bytes => 1) { |handle| stub_public_network_policy_for_local_fixture(handle) }
110
201
 
111
202
  assert_equal 200, easy.response_code
112
203
  assert_equal '', easy.body_str
@@ -182,6 +273,14 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
182
273
 
183
274
  private
184
275
 
276
+ # Keep body-limit integration tests local; real destination enforcement is
277
+ # covered separately in tc_curl_network_policy.rb.
278
+ def stub_public_network_policy_for_local_fixture(easy)
279
+ def easy.network_policy=(policy)
280
+ raise "unexpected policy: #{policy}" unless policy == :public
281
+ end
282
+ end
283
+
185
284
  def assert_body_limit_error
186
285
  assert_raise(Curl::Err::FileSizeExceededError) do
187
286
  yield
@@ -197,8 +296,13 @@ class TestCurbCurlMaxFileSize < Test::Unit::TestCase
197
296
  socket.readpartial(4096)
198
297
  rescue EOFError
199
298
  end
200
- socket.write(response)
201
- socket.close
299
+ begin
300
+ socket.write(response)
301
+ rescue Errno::EPIPE, Errno::ECONNRESET
302
+ # Expected when the client aborts at the body limit.
303
+ ensure
304
+ socket.close
305
+ end
202
306
  end
203
307
 
204
308
  yield "http://127.0.0.1:#{port}/"
@@ -586,6 +586,35 @@ class TestCurbCurlMulti < Test::Unit::TestCase
586
586
  end
587
587
  end
588
588
 
589
+ # Only easies attached by the raising callback itself are withdrawn. Work
590
+ # queued by an earlier, successful callback in the same perform was already
591
+ # attached when the failing callback started, so it keeps running and the
592
+ # deferred exception is raised once it drains.
593
+ def test_multi_perform_keeps_work_added_by_earlier_callback_when_later_callback_raises
594
+ server = CurbTinyHTTPServer.new
595
+ multi = Curl::Multi.new
596
+ first = Curl::Easy.new(server.url('/fast'))
597
+ failing = Curl::Easy.new(server.url('/delay/0.15'))
598
+ kept = Curl::Easy.new(server.url('/delay/0.4'))
599
+
600
+ first.on_complete { multi.add(kept) }
601
+ failing.on_complete { raise "complete blew up" }
602
+
603
+ error = assert_raise(Curl::Err::AbortedByCallbackError) do
604
+ multi.add(first)
605
+ multi.add(failing)
606
+ multi.perform
607
+ end
608
+
609
+ assert_equal "complete blew up", error.message
610
+ assert_equal 0, kept.last_result
611
+ assert_equal "delayed", kept.body_str,
612
+ "work added by an earlier successful callback should finish, not be withdrawn with the failing callback's additions"
613
+ ensure
614
+ multi.close if multi
615
+ server.close if server
616
+ end
617
+
589
618
  def test_multi_perform_does_not_start_work_added_within_on_complete_after_on_body_exception
590
619
  with_queue_refill_test_server do |port, hits|
591
620
  multi = Curl::Multi.new