curb 0.8.5 → 1.3.6

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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +579 -0
  3. data/Rakefile +85 -27
  4. data/doc.rb +48 -8
  5. data/ext/banned.h +32 -0
  6. data/ext/curb.c +563 -233
  7. data/ext/curb.h +19 -10
  8. data/ext/curb_easy.c +3229 -368
  9. data/ext/curb_easy.h +42 -0
  10. data/ext/curb_errors.c +117 -18
  11. data/ext/curb_errors.h +9 -5
  12. data/ext/curb_macros.h +33 -21
  13. data/ext/curb_multi.c +1904 -272
  14. data/ext/curb_multi.h +11 -3
  15. data/ext/curb_postfield.c +149 -77
  16. data/ext/curb_postfield.h +1 -0
  17. data/ext/curb_upload.c +38 -11
  18. data/ext/curb_upload.h +2 -0
  19. data/ext/extconf.rb +355 -35
  20. data/lib/curb.rb +1 -0
  21. data/lib/curl/download.rb +160 -0
  22. data/lib/curl/easy.rb +422 -88
  23. data/lib/curl/multi.rb +295 -56
  24. data/lib/curl.rb +676 -11
  25. data/tests/bug_crash_on_debug.rb +14 -28
  26. data/tests/bug_crash_on_progress.rb +32 -16
  27. data/tests/bug_curb_easy_blocks_ruby_threads.rb +10 -15
  28. data/tests/bug_curb_easy_post_with_string_no_content_length_header.rb +6 -30
  29. data/tests/bug_follow_redirect_288.rb +83 -0
  30. data/tests/bug_instance_post_differs_from_class_post.rb +3 -5
  31. data/tests/bug_issue102.rb +17 -0
  32. data/tests/bug_issue_noproxy.rb +56 -0
  33. data/tests/bug_issue_post_redirect.rb +93 -0
  34. data/tests/bug_issue_spnego.rb +41 -0
  35. data/tests/bug_multi_segfault.rb +1 -0
  36. data/tests/bug_poison.rb +29 -0
  37. data/tests/bug_raise_on_callback.rb +30 -0
  38. data/tests/helper.rb +400 -44
  39. data/tests/leak_trace.rb +237 -0
  40. data/tests/mem_check.rb +3 -0
  41. data/tests/tc_curl.rb +31 -1
  42. data/tests/tc_curl_download.rb +98 -7
  43. data/tests/tc_curl_easy.rb +985 -66
  44. data/tests/tc_curl_easy_cookielist.rb +277 -0
  45. data/tests/tc_curl_easy_request_target.rb +41 -0
  46. data/tests/tc_curl_easy_resolve.rb +48 -0
  47. data/tests/tc_curl_maxfilesize.rb +212 -0
  48. data/tests/tc_curl_multi.rb +1111 -51
  49. data/tests/tc_curl_native_coverage.rb +145 -0
  50. data/tests/tc_curl_network_policy.rb +1475 -0
  51. data/tests/tc_curl_postfield.rb +207 -30
  52. data/tests/tc_curl_protocols.rb +388 -0
  53. data/tests/tc_fiber_scheduler.rb +584 -0
  54. data/tests/tc_ftp_options.rb +39 -0
  55. data/tests/tc_gc_compact.rb +223 -0
  56. data/tests/tc_test_server_methods.rb +110 -0
  57. data/tests/timeout.rb +30 -6
  58. metadata +66 -31
  59. data/README +0 -194
data/lib/curl.rb CHANGED
@@ -1,14 +1,608 @@
1
+ # frozen_string_literal: true
1
2
  require 'curb_core'
3
+ require 'curl/download'
2
4
  require 'curl/easy'
3
5
  require 'curl/multi'
6
+ require 'ipaddr'
4
7
  require 'uri'
5
8
 
6
9
  # expose shortcut methods
7
10
  module Curl
8
-
11
+ class SafetyConfig
12
+ DEFAULT_PROTOCOLS = [:http, :https].freeze
13
+
14
+ attr_reader :max_body_bytes, :network_policy
15
+ attr_accessor :allow_proxies, :allow_resolve, :allow_connect_to, :allow_doh, :allow_unix_socket
16
+
17
+ def initialize
18
+ @protocols = DEFAULT_PROTOCOLS
19
+ @redirect_protocols = nil
20
+ @max_body_bytes = nil
21
+ @network_policy = nil
22
+ @allowed_hosts = nil
23
+ @allowed_proxy_hosts = nil
24
+ @allowed_cidrs = nil
25
+ @allow_proxies = false
26
+ @allow_resolve = false
27
+ @allow_connect_to = false
28
+ @allow_doh = false
29
+ @allow_unix_socket = false
30
+ end
31
+
32
+ def protocols
33
+ @protocols.dup
34
+ end
35
+
36
+ def protocols=(protocols)
37
+ @protocols = normalize_protocols(protocols)
38
+ end
39
+
40
+ def redirect_protocols
41
+ @redirect_protocols&.dup
42
+ end
43
+
44
+ def redirect_protocols=(protocols)
45
+ @redirect_protocols = protocols.nil? ? nil : normalize_protocols(protocols)
46
+ end
47
+
48
+ def allowed_hosts
49
+ @allowed_hosts&.map(&:dup)
50
+ end
51
+
52
+ def allowed_proxy_hosts
53
+ @allowed_proxy_hosts&.map(&:dup)
54
+ end
55
+
56
+ def allowed_cidrs
57
+ @allowed_cidrs&.map(&:dup)
58
+ end
59
+
60
+ def max_body_bytes=(bytes)
61
+ if bytes.nil?
62
+ @max_body_bytes = nil
63
+ return
64
+ end
65
+
66
+ bytes = Integer(bytes)
67
+ raise ArgumentError, "max_body_bytes must be greater than or equal to zero" if bytes < 0
68
+
69
+ @max_body_bytes = bytes.zero? ? nil : bytes
70
+ end
71
+
72
+ def network_policy=(policy)
73
+ @network_policy = normalize_network_policy(policy)
74
+ end
75
+
76
+ def allowed_hosts=(hosts)
77
+ @allowed_hosts = normalize_allowed_hosts(hosts)
78
+ end
79
+
80
+ def allowed_proxy_hosts=(hosts)
81
+ @allowed_proxy_hosts = normalize_allowed_hosts(hosts)
82
+ end
83
+
84
+ def allowed_cidrs=(cidrs)
85
+ @allowed_cidrs = normalize_allowed_cidrs(cidrs)
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_protocols(protocols)
91
+ protocol_names = Array(protocols).map { |protocol| protocol.to_s.downcase.to_sym }
92
+ raise ArgumentError, "at least one protocol is required" if protocol_names.empty?
93
+
94
+ protocol_names
95
+ end
96
+
97
+ def normalize_network_policy(policy)
98
+ return nil if policy.nil?
99
+
100
+ policy_name = policy.to_s.downcase.to_sym
101
+ return policy_name if [:none, :public].include?(policy_name)
102
+
103
+ raise ArgumentError, "network_policy must be one of :none, :public"
104
+ end
105
+
106
+ def normalize_allowed_hosts(hosts)
107
+ list = normalize_optional_list(hosts)
108
+ return nil unless list
109
+
110
+ list.map { |host| normalize_allowed_host(host) }.uniq
111
+ end
112
+
113
+ def normalize_allowed_cidrs(cidrs)
114
+ list = normalize_optional_list(cidrs)
115
+ return nil unless list
116
+
117
+ list.map do |cidr|
118
+ cidr = cidr.to_s.strip
119
+ raise ArgumentError, "allowed_cidrs cannot include blank entries" if cidr.empty?
120
+
121
+ IPAddr.new(cidr)
122
+ cidr
123
+ end.uniq
124
+ rescue IPAddr::InvalidAddressError => e
125
+ raise ArgumentError, "invalid CIDR range: #{e.message}"
126
+ end
127
+
128
+ def normalize_optional_list(values)
129
+ return nil if values.nil?
130
+
131
+ list = Array(values)
132
+ return nil if list.empty?
133
+
134
+ list
135
+ end
136
+
137
+ def normalize_allowed_host(host)
138
+ host = host.to_s.strip.downcase
139
+ raise ArgumentError, "allowed_hosts cannot include blank entries" if host.empty?
140
+
141
+ parsed_host = begin
142
+ URI.parse(host).host if host.include?("://")
143
+ rescue URI::InvalidURIError
144
+ nil
145
+ end
146
+
147
+ host = parsed_host.downcase if parsed_host
148
+ host = normalize_host_authority(host) unless parsed_host
149
+ host = host[1...-1] if host.start_with?("[") && host.end_with?("]")
150
+ host = host.chomp(".")
151
+
152
+ raise ArgumentError, "allowed_hosts cannot include blank entries" if host.empty?
153
+
154
+ host
155
+ end
156
+
157
+ def normalize_host_authority(host)
158
+ authority = host.split(/[\/?#]/, 2).first
159
+ authority = authority[(authority.rindex("@") + 1)..-1] if authority.include?("@")
160
+
161
+ if authority.start_with?("[")
162
+ closing = authority.index("]")
163
+ raise ArgumentError, "invalid allowed host: #{host}" unless closing
164
+
165
+ return authority[1...closing]
166
+ end
167
+
168
+ if authority.count(":") <= 1
169
+ authority = authority.split(":", 2).first
170
+ end
171
+
172
+ authority
173
+ end
174
+ end
175
+
176
+ def self.safe!
177
+ config = SafetyConfig.new
178
+ yield config if block_given?
179
+ @safety_config = config
180
+ bump_safety_generation!
181
+ end
182
+
183
+ def self.apply_safety!(easy)
184
+ config = @safety_config
185
+ override = safety_override_for(easy)
186
+ return easy unless config || override
187
+
188
+ protocols = config&.protocols
189
+ redirect_protocols = config && (config.redirect_protocols || protocols)
190
+
191
+ if override
192
+ override_protocols = override[:protocols]
193
+ protocols = safety_protocol_intersection(protocols, override_protocols) if override_protocols
194
+
195
+ override_redirect_protocols = override[:redirect_protocols] || override_protocols
196
+ redirect_protocols = safety_protocol_intersection(redirect_protocols, override_redirect_protocols) if override_redirect_protocols
197
+ end
198
+
199
+ if protocols
200
+ easy.allowed_protocols = protocols
201
+ easy.allowed_redirect_protocols = redirect_protocols || protocols
202
+ end
203
+
204
+ apply_allowed_hosts!(easy, config.allowed_hosts) if config&.allowed_hosts
205
+ apply_allowed_cidrs!(easy, config) if config&.allowed_cidrs
206
+
207
+ if config&.network_policy
208
+ easy.network_policy = config.network_policy
209
+ apply_public_network_policy_controls!(easy, config) if config.network_policy == :public
210
+ end
211
+
212
+ apply_max_body_bytes!(easy, config.max_body_bytes) if config&.max_body_bytes
213
+ apply_max_body_bytes!(easy, override[:max_body_bytes]) if override && override.key?(:max_body_bytes)
214
+ easy
215
+ end
216
+
217
+ def self.clear_safe!
218
+ @safety_config = nil
219
+ bump_safety_generation!
220
+ end
221
+
222
+ def self.safety_active_for?(easy)
223
+ !!(@safety_config || safety_override_for(easy))
224
+ end
225
+
226
+ def self.safety_signature_for(easy)
227
+ return nil unless safety_active_for?(easy)
228
+
229
+ override_generation = if easy.respond_to?(:__curb_safety_override_generation, true)
230
+ easy.__send__(:__curb_safety_override_generation)
231
+ end
232
+
233
+ [safety_generation, override_generation.to_i]
234
+ end
235
+
236
+ def self.safety_override_for(easy)
237
+ if easy.respond_to?(:__curb_safety_override, true)
238
+ easy.__send__(:__curb_safety_override)
239
+ end
240
+ end
241
+
242
+ def self.safety_generation
243
+ @safety_generation ||= 0
244
+ end
245
+
246
+ def self.bump_safety_generation!
247
+ @safety_generation = safety_generation + 1
248
+ end
249
+
250
+ def self.safety_protocol_intersection(base_protocols, override_protocols)
251
+ return override_protocols unless base_protocols
252
+
253
+ protocols = override_protocols & base_protocols
254
+ raise ArgumentError, "safety policies allow no protocols" if protocols.empty?
255
+
256
+ protocols
257
+ end
258
+
259
+ def self.apply_max_body_bytes!(easy, max_body_bytes)
260
+ return unless max_body_bytes
261
+
262
+ current_max_body_bytes = easy.max_body_bytes
263
+ easy.max_body_bytes = max_body_bytes if current_max_body_bytes.nil? || current_max_body_bytes > max_body_bytes
264
+ end
265
+
266
+ def self.apply_public_network_policy_controls!(easy, config)
267
+ reject_resolve_override!(easy) unless config.allow_resolve
268
+ reject_connect_to_override!(easy) unless config.allow_connect_to
269
+ reject_doh_override!(easy) unless config.allow_doh
270
+ reject_dns_servers_override!(easy)
271
+ allow_proxy = config.allow_proxies || !!config.allowed_proxy_hosts
272
+ easy.__send__(:__curb_allow_proxy=, allow_proxy) if easy.respond_to?(:__curb_allow_proxy=, true)
273
+ easy.__send__(:__curb_allow_unix_socket=, config.allow_unix_socket) if easy.respond_to?(:__curb_allow_unix_socket=, true)
274
+ reject_unix_socket_override!(easy) unless config.allow_unix_socket
275
+ if config.allowed_proxy_hosts
276
+ apply_allowed_proxy!(easy, config.allowed_proxy_hosts)
277
+ elsif !config.allow_proxies
278
+ disable_proxy!(easy)
279
+ end
280
+ end
281
+
282
+ def self.apply_allowed_hosts!(easy, allowed_hosts)
283
+ if easy.respond_to?(:follow_location?) && easy.follow_location? &&
284
+ !Curl.const_defined?(:CURLOPT_PREREQFUNCTION)
285
+ raise NotImplementedError, "redirect-aware host allowlists require CURLOPT_PREREQFUNCTION support"
286
+ end
287
+
288
+ host = begin
289
+ URI.parse(easy.url.to_s).host
290
+ rescue URI::InvalidURIError
291
+ nil
292
+ end
293
+
294
+ normalized_host = host.to_s.downcase.chomp(".")
295
+ normalized_host = normalized_host[1...-1] if normalized_host.start_with?("[") && normalized_host.end_with?("]")
296
+
297
+ unless allowed_hosts.include?(normalized_host)
298
+ raise Curl::Err::UnsafeDestinationError,
299
+ "URL host #{host.inspect} is not allowed by safe mode host allowlist"
300
+ end
301
+
302
+ easy.allowed_hosts = allowed_hosts if easy.respond_to?(:allowed_hosts=)
303
+ end
304
+
305
+ def self.apply_allowed_cidrs!(easy, config)
306
+ unless config.network_policy == :public
307
+ raise ArgumentError, "allowed_cidrs require network_policy = :public"
308
+ end
309
+
310
+ easy.allowed_cidrs = config.allowed_cidrs if easy.respond_to?(:allowed_cidrs=)
311
+ end
312
+
313
+ def self.apply_allowed_proxy!(easy, allowed_proxy_hosts)
314
+ proxy_url = easy.proxy_url if easy.respond_to?(:proxy_url)
315
+ if proxy_url.nil? || proxy_url.to_s.empty?
316
+ disable_proxy!(easy)
317
+ return
318
+ end
319
+
320
+ proxy_host = normalize_proxy_host(proxy_url)
321
+ unless allowed_proxy_hosts.include?(proxy_host)
322
+ raise Curl::Err::UnsafeDestinationError,
323
+ "proxy host #{proxy_host.inspect} is not allowed by safe mode proxy allowlist"
324
+ end
325
+
326
+ easy.set(Curl::CURLOPT_NOPROXY, "") if Curl.const_defined?(:CURLOPT_NOPROXY)
327
+ end
328
+
329
+ def self.normalize_proxy_host(proxy_url)
330
+ value = proxy_url.to_s.strip
331
+ raise Curl::Err::UnsafeDestinationError, "proxy URL is empty" if value.empty?
332
+
333
+ uri = begin
334
+ URI.parse(value.include?("://") ? value : "http://#{value}")
335
+ rescue URI::InvalidURIError
336
+ nil
337
+ end
338
+
339
+ host = uri&.host
340
+ raise Curl::Err::UnsafeDestinationError, "proxy URL host is invalid" if host.nil? || host.empty?
341
+
342
+ host = host.downcase.chomp(".")
343
+ host = host[1...-1] if host.start_with?("[") && host.end_with?("]")
344
+ host
345
+ end
346
+
347
+ def self.reject_resolve_override!(easy)
348
+ return unless easy.respond_to?(:resolve)
349
+
350
+ resolve = easy.resolve
351
+ return if resolve.nil? || (resolve.respond_to?(:empty?) && resolve.empty?)
352
+
353
+ raise Curl::Err::UnsafeDestinationError, "resolve overrides are disabled by public network policy"
354
+ end
355
+
356
+ def self.reject_connect_to_override!(easy)
357
+ return unless easy.respond_to?(:connect_to)
358
+
359
+ connect_to = easy.connect_to
360
+ return if connect_to.nil? || (connect_to.respond_to?(:empty?) && connect_to.empty?)
361
+
362
+ raise Curl::Err::UnsafeDestinationError, "connect_to overrides are disabled by public network policy"
363
+ end
364
+
365
+ def self.reject_doh_override!(easy)
366
+ return unless easy.respond_to?(:doh_url)
367
+
368
+ doh_url = easy.doh_url
369
+ return if doh_url.nil? || (doh_url.respond_to?(:empty?) && doh_url.empty?)
370
+
371
+ raise Curl::Err::UnsafeDestinationError, "DoH URL overrides are disabled by public network policy"
372
+ end
373
+
374
+ def self.reject_dns_servers_override!(easy)
375
+ return unless easy.respond_to?(:dns_servers)
376
+
377
+ dns_servers = easy.dns_servers
378
+ return if dns_servers.nil? || (dns_servers.respond_to?(:empty?) && dns_servers.empty?)
379
+
380
+ raise Curl::Err::UnsafeDestinationError, "DNS server overrides are disabled by public network policy"
381
+ end
382
+
383
+ def self.reject_unix_socket_override!(easy)
384
+ return unless easy.respond_to?(:unix_socket_path)
385
+
386
+ unix_socket_path = easy.unix_socket_path
387
+ return if unix_socket_path.nil? || (unix_socket_path.respond_to?(:empty?) && unix_socket_path.empty?)
388
+
389
+ raise Curl::Err::UnsafeDestinationError, "Unix socket paths are disabled by public network policy"
390
+ end
391
+
392
+ def self.disable_proxy!(easy)
393
+ easy.proxy_url = "" if easy.respond_to?(:proxy_url=)
394
+ easy.proxy_tunnel = false if easy.respond_to?(:proxy_tunnel=)
395
+ easy.set(Curl::CURLOPT_NOPROXY, "*") if Curl.const_defined?(:CURLOPT_NOPROXY)
396
+ end
397
+
398
+ private_class_method :apply_safety!, :clear_safe!, :safety_active_for?,
399
+ :safety_signature_for, :safety_override_for,
400
+ :safety_generation, :bump_safety_generation!,
401
+ :safety_protocol_intersection,
402
+ :apply_max_body_bytes!, :apply_public_network_policy_controls!,
403
+ :apply_allowed_hosts!, :apply_allowed_cidrs!,
404
+ :apply_allowed_proxy!, :normalize_proxy_host,
405
+ :reject_resolve_override!, :reject_connect_to_override!,
406
+ :reject_doh_override!, :reject_dns_servers_override!,
407
+ :reject_unix_socket_override!,
408
+ :disable_proxy!
409
+
410
+ def self.scheduler_active?
411
+ Fiber.respond_to?(:scheduler) && !Fiber.scheduler.nil?
412
+ end
413
+
414
+ def self.deferred_exception_source_id(state)
415
+ return unless state[:multi].instance_variable_defined?(:@__curb_deferred_exception_source_id)
416
+
417
+ state[:multi].instance_variable_get(:@__curb_deferred_exception_source_id)
418
+ end
419
+
420
+ def self.scheduler_waiter_blocking_supported?
421
+ scheduler = Fiber.scheduler
422
+ scheduler && scheduler.respond_to?(:block) && scheduler.respond_to?(:unblock)
423
+ end
424
+
425
+ def self.wake_scheduler_waiter(waiter)
426
+ fiber = waiter[:fiber]
427
+ scheduler = waiter[:scheduler]
428
+ return unless fiber&.alive? && scheduler&.respond_to?(:unblock)
429
+
430
+ scheduler.unblock(waiter, fiber)
431
+ end
432
+
433
+ def self.complete_scheduler_waiter(waiter)
434
+ return if waiter[:done]
435
+
436
+ waiter[:done] = true
437
+ wake_scheduler_waiter(waiter)
438
+ end
439
+
440
+ def self.fail_scheduler_waiter(waiter, error)
441
+ return if waiter[:error]
442
+
443
+ waiter[:error] = error
444
+ wake_scheduler_waiter(waiter)
445
+ end
446
+
447
+ def self.release_scheduler_error(state, error)
448
+ source_waiter = state[:waiters][deferred_exception_source_id(state)]
449
+
450
+ if source_waiter
451
+ fail_scheduler_waiter(source_waiter, error)
452
+ else
453
+ state[:error] = error
454
+ state[:waiters].each_value { |waiter| wake_scheduler_waiter(waiter) }
455
+ end
456
+ end
457
+
458
+ def self.block_scheduler_waiter(waiter)
459
+ unless scheduler_waiter_blocking_supported?
460
+ sleep 0
461
+ return
462
+ end
463
+
464
+ waiter[:fiber] = Fiber.current
465
+ waiter[:scheduler] ||= Fiber.scheduler
466
+ return if waiter[:done] || waiter[:error]
467
+
468
+ waiter[:scheduler].block(waiter, nil)
469
+ ensure
470
+ waiter[:fiber] = nil if waiter[:fiber].equal?(Fiber.current)
471
+ end
472
+
473
+ def self.scheduler_yield
474
+ scheduler = Fiber.scheduler
475
+
476
+ if scheduler&.respond_to?(:kernel_sleep)
477
+ scheduler.kernel_sleep(0)
478
+ else
479
+ sleep 0
480
+ end
481
+ end
482
+
483
+ def self.release_scheduler_waiters(state)
484
+ source_id = deferred_exception_source_id(state)
485
+
486
+ state[:waiters].each do |easy_id, waiter|
487
+ next if source_id == easy_id
488
+
489
+ complete_scheduler_waiter(waiter) if waiter[:completed]
490
+ end
491
+ end
492
+
493
+ def self.perform_with_scheduler(easy)
494
+ state = scheduler_state
495
+ waiter = {completed: false, done: false, error: nil, fiber: nil, scheduler: Fiber.scheduler}
496
+ state[:waiters][easy.object_id] = waiter
497
+ previous_complete = easy.on_complete do |completed_easy|
498
+ previous_complete.call(completed_easy) if previous_complete
499
+ waiter[:completed] = true
500
+ end
501
+
502
+ state[:pending] << easy
503
+ ensure_scheduler_driver(state)
504
+
505
+ until waiter[:done]
506
+ raise waiter[:error] if waiter[:error]
507
+ raise state[:error] if state[:error]
508
+ block_scheduler_waiter(waiter)
509
+ end
510
+
511
+ while state[:driver_running] && state[:pending].empty? &&
512
+ state[:waiters].length == 1 && state[:waiters].key?(easy.object_id)
513
+ scheduler_yield
514
+ end
515
+
516
+ true
517
+ ensure
518
+ state[:waiters].delete(easy.object_id) if defined?(state) && state[:waiters]
519
+ if defined?(previous_complete)
520
+ if previous_complete
521
+ easy.on_complete(&previous_complete)
522
+ else
523
+ easy.on_complete
524
+ end
525
+ end
526
+ end
527
+
528
+ def self.scheduler_state
529
+ Thread.current.thread_variable_get(:curb_scheduler_state) || begin
530
+ state = {
531
+ multi: Curl::Multi.new,
532
+ pending: [],
533
+ driver_running: false,
534
+ error: nil,
535
+ waiters: {},
536
+ }
537
+ Thread.current.thread_variable_set(:curb_scheduler_state, state)
538
+ state
539
+ end
540
+ end
541
+
542
+ def self.ensure_scheduler_driver(state)
543
+ return if state[:driver_running]
544
+
545
+ state[:driver_running] = true
546
+ state[:error] = nil
547
+
548
+ runner = proc do
549
+ begin
550
+ # Give sibling fibers a chance to enqueue work so the shared multi can
551
+ # batch scheduler-driven Easy#perform calls together.
552
+ pending_count = -1
553
+ until pending_count == state[:pending].size
554
+ pending_count = state[:pending].size
555
+ scheduler_yield
556
+ end
557
+
558
+ loop do
559
+ drain_scheduler_pending(state)
560
+ break if state[:multi].idle?
561
+
562
+ begin
563
+ state[:multi].perform do
564
+ drain_scheduler_pending(state)
565
+ release_scheduler_waiters(state)
566
+ scheduler_yield
567
+ end
568
+ ensure
569
+ # Release any siblings that completed just before a deferred
570
+ # callback exception is re-raised.
571
+ release_scheduler_waiters(state)
572
+ end
573
+ end
574
+ rescue => e
575
+ release_scheduler_waiters(state)
576
+ release_scheduler_error(state, e)
577
+ ensure
578
+ state[:driver_running] = false
579
+ ensure_scheduler_driver(state) if state[:error].nil? && !state[:pending].empty?
580
+ end
581
+ end
582
+
583
+ if Fiber.respond_to?(:schedule)
584
+ Fiber.schedule(&runner)
585
+ else
586
+ Fiber.new(blocking: false, &runner).resume
587
+ end
588
+ end
589
+
590
+ def self.drain_scheduler_pending(state)
591
+ pending = state[:pending]
592
+ until pending.empty?
593
+ easy = pending.first
594
+
595
+ break if state[:multi].instance_variable_defined?(:@__curb_deferred_exception)
596
+
597
+ state[:multi].add(easy)
598
+ break unless state[:multi].requests.key?(easy.object_id)
599
+
600
+ pending.shift
601
+ end
602
+ end
603
+
9
604
  def self.http(verb, url, post_body=nil, put_data=nil, &block)
10
- handle = Thread.current[:curb_curl] ||= Curl::Easy.new
11
- handle.reset
605
+ handle = Curl::Easy.new
12
606
  handle.url = url
13
607
  handle.post_body = post_body if post_body
14
608
  handle.put_data = put_data if put_data
@@ -17,47 +611,118 @@ module Curl
17
611
  handle
18
612
  end
19
613
 
614
+ def self.safe_http(verb, url, post_body=nil, put_data=nil, options={}, &block)
615
+ options = safe_http_options(options)
616
+
617
+ http(verb, url, post_body, put_data) do |handle|
618
+ yield handle if block
619
+ handle.safe_http!
620
+ handle.max_body_bytes = options[:max_body_bytes] if options.key?(:max_body_bytes)
621
+ end
622
+ end
623
+
20
624
  def self.get(url, params={}, &block)
21
625
  http :GET, urlalize(url, params), nil, nil, &block
22
626
  end
23
627
 
628
+ def self.safe_get(url, params={}, options={}, &block)
629
+ params, options = split_safe_http_params_options(params, options)
630
+ safe_http :GET, urlalize(url, params), nil, nil, options, &block
631
+ end
632
+
24
633
  def self.post(url, params={}, &block)
25
634
  http :POST, url, postalize(params), nil, &block
26
635
  end
27
636
 
637
+ def self.safe_post(url, params={}, options={}, &block)
638
+ safe_http :POST, url, postalize(params), nil, options, &block
639
+ end
640
+
28
641
  def self.put(url, params={}, &block)
29
642
  http :PUT, url, nil, postalize(params), &block
30
643
  end
31
644
 
645
+ def self.safe_put(url, params={}, options={}, &block)
646
+ safe_http :PUT, url, nil, postalize(params), options, &block
647
+ end
648
+
32
649
  def self.delete(url, params={}, &block)
33
650
  http :DELETE, url, postalize(params), nil, &block
34
651
  end
35
652
 
653
+ def self.safe_delete(url, params={}, options={}, &block)
654
+ safe_http :DELETE, url, postalize(params), nil, options, &block
655
+ end
656
+
36
657
  def self.patch(url, params={}, &block)
37
658
  http :PATCH, url, postalize(params), nil, &block
38
659
  end
39
660
 
661
+ def self.safe_patch(url, params={}, options={}, &block)
662
+ safe_http :PATCH, url, postalize(params), nil, options, &block
663
+ end
664
+
40
665
  def self.head(url, params={}, &block)
41
666
  http :HEAD, urlalize(url, params), nil, nil, &block
42
667
  end
43
668
 
669
+ def self.safe_head(url, params={}, options={}, &block)
670
+ params, options = split_safe_http_params_options(params, options)
671
+ safe_http :HEAD, urlalize(url, params), nil, nil, options, &block
672
+ end
673
+
44
674
  def self.options(url, params={}, &block)
45
675
  http :OPTIONS, urlalize(url, params), nil, nil, &block
46
676
  end
47
677
 
678
+ def self.safe_options(url, params={}, options={}, &block)
679
+ params, options = split_safe_http_params_options(params, options)
680
+ safe_http :OPTIONS, urlalize(url, params), nil, nil, options, &block
681
+ end
682
+
48
683
  def self.urlalize(url, params={})
49
- query_str = params.map {|k,v| "#{URI.escape(k.to_s)}=#{URI.escape(v.to_s)}" }.join('&')
50
- if url.match(/\?/)
51
- "#{url}&#{query_str}"
52
- elsif query_str.size > 0
53
- "#{url}?#{query_str}"
54
- else
55
- url
56
- end
684
+ uri = URI(url)
685
+ # early return if we didn't specify any extra params
686
+ return uri.to_s if (params || {}).empty?
687
+
688
+ params_query = URI.encode_www_form(params || {})
689
+ uri.query = [uri.query.to_s, params_query].reject(&:empty?).join('&')
690
+ uri.to_s
57
691
  end
58
692
 
59
693
  def self.postalize(params={})
60
694
  params.respond_to?(:map) ? URI.encode_www_form(params) : (params.respond_to?(:to_s) ? params.to_s : params)
61
695
  end
62
696
 
697
+ def self.safe_http_options(options)
698
+ options ||= {}
699
+ raise ArgumentError, "safe HTTP options must be a Hash" unless options.is_a?(Hash)
700
+
701
+ options = options.dup
702
+ unsupported = options.keys - safe_http_option_keys
703
+ raise ArgumentError, "unsupported safe HTTP option(s): #{unsupported.join(', ')}" unless unsupported.empty?
704
+
705
+ options
706
+ end
707
+
708
+ def self.split_safe_http_params_options(params, options)
709
+ if options == {} && safe_http_option_hash?(params)
710
+ [{}, params]
711
+ else
712
+ [params, options]
713
+ end
714
+ end
715
+
716
+ def self.safe_http_option_hash?(value)
717
+ value.is_a?(Hash) && !value.empty? && (value.keys - safe_http_option_keys).empty?
718
+ end
719
+
720
+ def self.safe_http_option_keys
721
+ [:max_body_bytes]
722
+ end
723
+
724
+ def self.reset
725
+ Thread.current[:curb_curl] = Curl::Easy.new
726
+ end
727
+
63
728
  end