ethon 0.17.0 → 0.19.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +698 -2
  3. data/README.md +183 -40
  4. data/ethon.gemspec +9 -1
  5. data/lib/ethon/curl.rb +22 -0
  6. data/lib/ethon/curls/classes.rb +284 -12
  7. data/lib/ethon/curls/codes.rb +28 -9
  8. data/lib/ethon/curls/constants.rb +61 -4
  9. data/lib/ethon/curls/form_options.rb +2 -1
  10. data/lib/ethon/curls/functions.rb +60 -11
  11. data/lib/ethon/curls/infos.rb +104 -5
  12. data/lib/ethon/curls/introspection.rb +139 -0
  13. data/lib/ethon/curls/introspection_constants.rb +42 -0
  14. data/lib/ethon/curls/mime.rb +187 -0
  15. data/lib/ethon/curls/optional.rb +14 -0
  16. data/lib/ethon/curls/options.rb +298 -71
  17. data/lib/ethon/curls/settings.rb +12 -3
  18. data/lib/ethon/curls/share.rb +165 -0
  19. data/lib/ethon/curls/share_constants.rb +47 -0
  20. data/lib/ethon/curls/url.rb +171 -0
  21. data/lib/ethon/curls/url_constants.rb +78 -0
  22. data/lib/ethon/easy/callbacks.rb +178 -46
  23. data/lib/ethon/easy/features.rb +4 -1
  24. data/lib/ethon/easy/files.rb +113 -0
  25. data/lib/ethon/easy/form.rb +65 -6
  26. data/lib/ethon/easy/header.rb +57 -15
  27. data/lib/ethon/easy/headers_api.rb +79 -0
  28. data/lib/ethon/easy/http/actionable.rb +25 -3
  29. data/lib/ethon/easy/http/get.rb +5 -1
  30. data/lib/ethon/easy/http/post.rb +1 -0
  31. data/lib/ethon/easy/http/postable.rb +9 -2
  32. data/lib/ethon/easy/http/putable.rb +19 -4
  33. data/lib/ethon/easy/http.rb +16 -4
  34. data/lib/ethon/easy/informations.rb +51 -2
  35. data/lib/ethon/easy/mime.rb +29 -0
  36. data/lib/ethon/easy/mirror.rb +17 -4
  37. data/lib/ethon/easy/operations.rb +259 -11
  38. data/lib/ethon/easy/options.rb +327 -8
  39. data/lib/ethon/easy/queryable.rb +59 -14
  40. data/lib/ethon/easy/response_callbacks.rb +33 -12
  41. data/lib/ethon/easy/share.rb +28 -0
  42. data/lib/ethon/easy/url.rb +32 -0
  43. data/lib/ethon/easy/util.rb +19 -0
  44. data/lib/ethon/easy.rb +185 -10
  45. data/lib/ethon/errors/fork_safety.rb +10 -0
  46. data/lib/ethon/errors/form_add.rb +12 -0
  47. data/lib/ethon/errors/global_init.rb +1 -1
  48. data/lib/ethon/errors/header.rb +12 -0
  49. data/lib/ethon/errors/invalid_option.rb +3 -1
  50. data/lib/ethon/errors/invalid_value.rb +3 -1
  51. data/lib/ethon/errors/mime.rb +17 -0
  52. data/lib/ethon/errors/multi_add.rb +1 -1
  53. data/lib/ethon/errors/multi_assign.rb +12 -0
  54. data/lib/ethon/errors/multi_fdset.rb +1 -1
  55. data/lib/ethon/errors/multi_perform.rb +12 -0
  56. data/lib/ethon/errors/multi_poll.rb +12 -0
  57. data/lib/ethon/errors/multi_remove.rb +1 -1
  58. data/lib/ethon/errors/multi_timeout.rb +2 -2
  59. data/lib/ethon/errors/multi_wait.rb +12 -0
  60. data/lib/ethon/errors/multi_wakeup.rb +12 -0
  61. data/lib/ethon/errors/select.rb +1 -1
  62. data/lib/ethon/errors/set_option.rb +27 -0
  63. data/lib/ethon/errors/share.rb +17 -0
  64. data/lib/ethon/errors/unsupported_option.rb +23 -0
  65. data/lib/ethon/errors/url.rb +17 -0
  66. data/lib/ethon/errors.rb +13 -0
  67. data/lib/ethon/libc.rb +30 -1
  68. data/lib/ethon/loggable.rb +1 -2
  69. data/lib/ethon/multi/operations.rb +269 -25
  70. data/lib/ethon/multi/options.rb +86 -12
  71. data/lib/ethon/multi/stack.rb +175 -10
  72. data/lib/ethon/multi.rb +44 -12
  73. data/lib/ethon/version.rb +1 -1
  74. data/lib/ethon.rb +8 -9
  75. metadata +52 -4
@@ -20,13 +20,20 @@ module Ethon
20
20
  # @example Set callbacks.
21
21
  # easy.set_callbacks
22
22
  def set_callbacks
23
- Curl.set_option(:writefunction, body_write_callback, handle)
24
- Curl.set_option(:headerfunction, header_write_callback, handle)
25
- Curl.set_option(:debugfunction, debug_callback, handle)
26
- @response_body = String.new
23
+ install_callbacks(handle)
24
+ reset_transfer_state
25
+ end
26
+
27
+ # Reset the state that belongs to a single transfer. Reusing an Easy must
28
+ # replace the previous response instead of appending to it.
29
+ def reset_transfer_state
30
+ rewind_request_body
31
+ @response_body = @response_body_io ? nil : String.new
27
32
  @response_headers = String.new
28
33
  @headers_called = false
29
34
  @debug_info = Ethon::Easy::DebugInfo.new
35
+ @request_body_read = 0
36
+ @return_code = nil
30
37
  end
31
38
 
32
39
  # Returns the body write callback.
@@ -37,10 +44,19 @@ module Ethon
37
44
  # @return [ Proc ] The callback.
38
45
  def body_write_callback
39
46
  @body_write_callback ||= proc do |stream, size, num, object|
40
- headers
41
- result = body(chunk = stream.read_string(size * num))
42
- @response_body << chunk if result == :unyielded
43
- result != :abort ? size * num : -1
47
+ begin
48
+ headers_user_callback_result = headers
49
+ result = body(chunk = stream.read_string(size * num))
50
+ if @response_body_io && result != :abort && headers_user_callback_result != :abort
51
+ write_response_body(@response_body_io, chunk)
52
+ elsif result == :unyielded && !@response_body_io && headers_user_callback_result != :abort
53
+ @response_body << chunk
54
+ end
55
+ (result != :abort && headers_user_callback_result != :abort) ? size * num : -1
56
+ rescue Exception => e
57
+ @callback_error ||= e
58
+ 0
59
+ end
44
60
  end
45
61
  end
46
62
 
@@ -52,14 +68,21 @@ module Ethon
52
68
  # @return [ Proc ] The callback.
53
69
  def header_write_callback
54
70
  @header_write_callback ||= proc {|stream, size, num, object|
55
- result = headers
56
- @response_headers << stream.read_string(size * num)
57
- result != :abort ? size * num : -1
71
+ begin
72
+ @response_headers << stream.read_string(size * num)
73
+ size * num
74
+ rescue Exception => e
75
+ @callback_error ||= e
76
+ 0
77
+ end
58
78
  }
59
79
  end
60
80
 
61
- # Returns the debug callback. This callback is currently used
62
- # write the raw http request headers.
81
+ # This callback is called for every debug message libcurl emits while
82
+ # verbose mode is enabled.
83
+ #
84
+ # Messages are written through +Ethon.logger+ at the debug level. The
85
+ # user observer registered with #debugfunction is called as well.
63
86
  #
64
87
  # @example Return the callback.
65
88
  # easy.debug_callback
@@ -67,19 +90,37 @@ module Ethon
67
90
  # @return [ Proc ] The callback.
68
91
  def debug_callback
69
92
  @debug_callback ||= proc {|handle, type, data, size, udata|
70
- message = data.read_string(size)
71
- @debug_info.add type, message
72
- print message unless [:data_in, :data_out].include?(type)
93
+ begin
94
+ message = data.read_string(size)
95
+ @debug_info.add type, message
96
+
97
+ if [:ssl_data_in, :ssl_data_out].include?(type)
98
+ log_debug("[#{size} bytes data]")
99
+ elsif [:data_in, :data_out].include?(type)
100
+ # Body data stays in #debug_info; logging it would duplicate the
101
+ # response body in the application log.
102
+ else
103
+ log_debug(message)
104
+ end
105
+ @user_debug_callback.call(handle, type, data, size, udata) if @user_debug_callback
106
+ rescue Exception => e
107
+ @callback_error ||= e
108
+ end
73
109
  0
74
110
  }
75
111
  end
76
112
 
77
113
  def set_progress_callback
78
- if Curl.version_info[:version] >= "7.32.0"
79
- Curl.set_option(:xferinfofunction, progress_callback, handle)
114
+ # CURLOPT_XFERINFOFUNCTION was added in libcurl 7.32.0.
115
+ option = if Curl.version_at_least?("7.32.0")
116
+ :xferinfofunction
80
117
  else
81
- Curl.set_option(:progressfunction, progress_callback, handle)
118
+ :progressfunction
82
119
  end
120
+ Curl.set_option(option, progress_callback, handle)
121
+ # Remember which callback is effective so a duplicate can restore it.
122
+ @progress_source = :internal
123
+ @internal_progress_option = option
83
124
  end
84
125
 
85
126
  # Returns the progress callback.
@@ -89,10 +130,34 @@ module Ethon
89
130
  #
90
131
  # @return [ Proc ] The callback.
91
132
  def progress_callback
92
- @progress_callback ||= proc { |_, dltotal, dlnow, ultotal, ulnow|
93
- progress(dltotal, dlnow, ultotal, ulnow)
94
- 0
95
- }
133
+ @progress_callback ||= proc do |_, dltotal, dlnow, ultotal, ulnow|
134
+ begin
135
+ progress(dltotal, dlnow, ultotal, ulnow)
136
+ 0
137
+ rescue Exception => e
138
+ @callback_error ||= e
139
+ 0
140
+ end
141
+ end
142
+ end
143
+
144
+ # A completion callback may start the next transfer on this Easy. Keep
145
+ # the finished transfer's callback error so the caller still sees it;
146
+ # the next transfer starts without it once the error is raised.
147
+ def clear_callback_error
148
+ @callback_error = nil unless @completing
149
+ end
150
+
151
+ # The transfer teardown in #perform, #complete, and Multi#check already
152
+ # finishes the response stream. Finishing it here again could close a
153
+ # stream a completion callback installed for the next transfer, or
154
+ # replace the callback error with a teardown error.
155
+ def raise_callback_error
156
+ return unless @callback_error
157
+
158
+ error = @callback_error
159
+ @callback_error = nil
160
+ raise error
96
161
  end
97
162
 
98
163
  # Set the read callback. This callback is used by libcurl to
@@ -104,35 +169,52 @@ module Ethon
104
169
  # @param [ String ] body The body.
105
170
  def set_read_callback(body)
106
171
  @request_body_read = 0
107
- readfunction do |stream, size, num, object|
108
- size = size * num
109
- body_size = if body.respond_to?(:bytesize)
110
- body.bytesize
111
- elsif body.respond_to?(:size)
112
- body.size
113
- elsif body.is_a?(File)
114
- File.size(body.path)
115
- end
172
+ @request_body = body
173
+ # Remember where a seekable body starts so reusing the Easy replays the
174
+ # same range instead of finding the IO at EOF.
175
+ @request_body_start = begin
176
+ body.pos if body.respond_to?(:seek) && body.respond_to?(:pos)
177
+ rescue IOError, SystemCallError
178
+ nil
179
+ end
180
+ @read_callback = proc do |stream, size, num, object|
181
+ begin
182
+ size = size * num
183
+ remaining = if body.respond_to?(:byteslice)
184
+ body.bytesize - @request_body_read
185
+ elsif body.respond_to?(:read)
186
+ # IO-like bodies advance their own position.
187
+ body.size - body.pos if body.respond_to?(:size) && body.respond_to?(:pos)
188
+ elsif body.respond_to?(:size)
189
+ body.size - @request_body_read
190
+ end
116
191
 
117
- left = body_size - @request_body_read
118
- size = left if size > left
192
+ size = remaining if remaining && size > remaining
119
193
 
120
- if size > 0
121
- chunk = if body.respond_to?(:byteslice)
122
- body.byteslice(@request_body_read, size)
123
- elsif body.respond_to?(:read)
124
- body.read(size)
125
- else
126
- body[@request_body_read, size]
194
+ chunk = if size > 0
195
+ if body.respond_to?(:byteslice)
196
+ body.byteslice(@request_body_read, size)
197
+ elsif body.respond_to?(:read)
198
+ body.read(size)
199
+ else
200
+ body[@request_body_read, size]
201
+ end
127
202
  end
128
203
 
129
- stream.write_string(
130
- chunk, size
131
- )
132
- @request_body_read += size
204
+ # libcurl expects the number of bytes actually copied into the
205
+ # buffer. IO-like bodies may return fewer bytes than requested.
206
+ chunk_size = chunk ? chunk.bytesize : 0
207
+ if chunk_size > 0
208
+ stream.write_string(chunk, chunk_size)
209
+ @request_body_read += chunk_size
210
+ end
211
+ chunk_size
212
+ rescue Exception => e
213
+ @callback_error ||= e
214
+ 0
133
215
  end
134
- size
135
216
  end
217
+ readfunction(&@read_callback)
136
218
  end
137
219
 
138
220
  # Returns the body read callback.
@@ -144,6 +226,56 @@ module Ethon
144
226
  def read_callback
145
227
  @read_callback
146
228
  end
229
+
230
+ private
231
+
232
+ # IO#write may accept fewer bytes than it was given, especially for
233
+ # nonblocking or wrapper IOs. libcurl must only see the chunk as handled
234
+ # once every byte reached the destination. IOs that return nil (or a
235
+ # non-integer) keep the historical "wrote everything" behavior.
236
+ def write_response_body(io, chunk)
237
+ offset = 0
238
+ total = chunk.bytesize
239
+ while offset < total
240
+ pending = offset.zero? ? chunk : chunk.byteslice(offset, total - offset)
241
+ written = io.write(pending)
242
+ return unless written.is_a?(Integer)
243
+
244
+ raise IOError, "response body write made no progress" if written <= 0
245
+ offset += written
246
+ end
247
+ end
248
+
249
+ # Install the callbacks that fill the Ruby-side response state. The
250
+ # handle is passed explicitly so callers do not have to go through
251
+ # #handle, which would create a handle as a side effect.
252
+ def install_callbacks(target)
253
+ Curl.set_option(:writefunction, body_write_callback, target)
254
+ Curl.set_option(:headerfunction, header_write_callback, target)
255
+ Curl.set_option(:debugfunction, debug_callback, target)
256
+ end
257
+
258
+ # Log a libcurl debug message through Ethon.logger. The default level is
259
+ # INFO, so applications opt in by lowering the logger level.
260
+ def log_debug(message)
261
+ return if message.empty?
262
+
263
+ logger = Ethon.logger
264
+ return unless logger.debug?
265
+
266
+ logger.debug { message.sub(/\n+\z/, "") }
267
+ end
268
+
269
+ # Seek a request body back to where it was when the callback was
270
+ # installed. Pipes and other non-seekable bodies are left alone.
271
+ def rewind_request_body
272
+ return unless @request_body_start
273
+ return unless @request_body.respond_to?(:seek)
274
+
275
+ @request_body.seek(@request_body_start)
276
+ rescue IOError, SystemCallError
277
+ nil
278
+ end
147
279
  end
148
280
  end
149
281
  end
@@ -24,7 +24,10 @@ module Ethon
24
24
  !!(Curl.version_info[:features] & Curl::VERSION_ASYNCHDNS)
25
25
  end
26
26
 
27
- alias :supports_timeout_ms? :supports_asynch_dns?
27
+ # CURLOPT_TIMEOUT_MS was added in libcurl 7.16.2.
28
+ def supports_timeout_ms?
29
+ Curl.version_at_least?("7.16.2")
30
+ end
28
31
 
29
32
  end
30
33
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+ module Ethon
3
+ class Easy
4
+ # This module contains logic for streaming response bodies to Ruby IOs.
5
+ module Files
6
+ # Stream the response body to a path or writable Ruby object. Ethon closes
7
+ # files it opens from paths; caller-provided objects remain caller-owned.
8
+ # Pass nil to restore in-memory response buffering.
9
+ def file=(destination)
10
+ # Resolve the handle first. Opening a path truncates it, so a cleaned
11
+ # up or fork-inherited handle must not touch the destination before the
12
+ # error is raised.
13
+ handle
14
+
15
+ # Install the new destination before releasing the old one. A failed
16
+ # open must leave the previous destination and its buffered response
17
+ # usable instead of leaving the Easy pointed at a path that cannot be
18
+ # opened.
19
+ # Pathname-like objects may also implement #write. Prefer the path when
20
+ # the object is not an IO (File and Tempfile expose #fileno).
21
+ if destination.respond_to?(:to_path) && !destination.respond_to?(:fileno)
22
+ replace_response_body_path(destination.to_path)
23
+ elsif destination.respond_to?(:write)
24
+ replace_response_body_io(destination)
25
+ elsif destination
26
+ replace_response_body_path(destination.to_s)
27
+ else
28
+ clear_response_body_io
29
+ end
30
+
31
+ @response_body = @response_body_io ? nil : String.new
32
+ destination
33
+ end
34
+
35
+ alias writedata= file=
36
+
37
+ def prepare_response_body_io
38
+ return unless @response_body_path && !@response_body_io
39
+
40
+ @response_body_io = File.open(@response_body_path, "wb")
41
+ @owns_response_body_io = true
42
+ end
43
+
44
+ def close_response_body_io
45
+ @response_body_io.close if @owns_response_body_io && @response_body_io && !@response_body_io.closed?
46
+ @response_body_io = nil
47
+ @owns_response_body_io = false
48
+ end
49
+
50
+ def clear_response_body_io
51
+ close_response_body_io
52
+ @response_body_path = nil
53
+ end
54
+
55
+ # Flush buffered writes without closing a caller-owned IO. Response
56
+ # callbacks and readers should see the complete body before the transfer
57
+ # is treated as finished.
58
+ def flush_response_body_io
59
+ io = @response_body_io
60
+ return unless io
61
+ return if io.respond_to?(:closed?) && io.closed?
62
+
63
+ io.flush if io.respond_to?(:flush)
64
+ end
65
+
66
+ # Finish the destination used by one transfer. A completion callback may
67
+ # already have installed the destination for the next transfer; only the
68
+ # stream this transfer wrote to belongs to it.
69
+ #
70
+ # @param [ IO, nil ] io The destination that transfer used.
71
+ def finish_response_body_io(io = @response_body_io)
72
+ return unless @response_body_io && @response_body_io.equal?(io)
73
+
74
+ if @owns_response_body_io
75
+ close_response_body_io
76
+ else
77
+ flush_response_body_io
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ # Open the new path first. If that fails, or if the old destination
84
+ # cannot be released, the previous state stays in place.
85
+ def replace_response_body_path(path)
86
+ io = File.open(path, "wb")
87
+ begin
88
+ clear_response_body_io
89
+ rescue Exception
90
+ close_quietly(io)
91
+ raise
92
+ end
93
+
94
+ @response_body_path = path
95
+ @response_body_io = io
96
+ @owns_response_body_io = true
97
+ end
98
+
99
+ def replace_response_body_io(io)
100
+ clear_response_body_io
101
+ @response_body_io = io
102
+ @owns_response_body_io = false
103
+ end
104
+
105
+ def close_quietly(io)
106
+ io.close unless io.closed?
107
+ rescue StandardError
108
+ # The caller is already handling the failure that kept us from
109
+ # installing the new destination.
110
+ end
111
+ end
112
+ end
113
+ end
@@ -57,7 +57,7 @@ module Ethon
57
57
  # @return [ Boolean ] True if form is multipart, else false.
58
58
  def multipart?
59
59
  return true if @multipart
60
- query_pairs.any?{|pair| pair.respond_to?(:last) && pair.last.is_a?(Array)}
60
+ contains_file?(@params)
61
61
  end
62
62
 
63
63
  # Add form elements to libcurl.
@@ -65,20 +65,77 @@ module Ethon
65
65
  # @example Add form to libcurl.
66
66
  # form.materialize
67
67
  def materialize
68
+ unless Curl.respond_to?(:formadd)
69
+ raise NotImplementedError,
70
+ "curl_formadd is not supported by this libcurl; use the MIME API instead"
71
+ end
72
+
73
+ ensure_named_fields!
68
74
  query_pairs.each { |pair| form_add(pair.first.to_s, pair.last) }
69
75
  end
70
76
 
77
+ # Build a MIME structure for this form.
78
+ #
79
+ # @example Build a MIME structure.
80
+ # form.mime
81
+ #
82
+ # @return [ Ethon::Curl::Mime ] The MIME structure.
83
+ def mime
84
+ @mime ||= begin
85
+ ensure_named_fields!
86
+ structure = Ethon::Curl::Mime.new(@easy)
87
+ query_pairs.each do |pair|
88
+ part = structure.addpart
89
+ name, content = pair
90
+ part.name = name.to_s
91
+ if content.is_a?(Queryable::FileInfo)
92
+ part.file = content[2]
93
+ part.filename = content[0]
94
+ part.type = content[1]
95
+ else
96
+ part.data = content.to_s
97
+ end
98
+ end
99
+ structure
100
+ end
101
+ end
102
+
71
103
  private
72
104
 
73
- def form_add(name, content)
74
- case content
105
+ # A multipart part needs a field name. A raw String body is one pair and
106
+ # has no name, which used to produce an unnamed part through the MIME API
107
+ # and a NoMethodError through the form API.
108
+ def ensure_named_fields!
109
+ return unless query_pairs.any? { |pair| pair.is_a?(String) }
110
+
111
+ raise ArgumentError, "multipart bodies require a Hash of fields"
112
+ end
113
+
114
+ def contains_file?(value)
115
+ case value
116
+ when Hash
117
+ value.values.any? { |item| contains_file?(item) }
75
118
  when Array
119
+ value.any? { |item| contains_file?(item) }
120
+ else
121
+ value.is_a?(File) || (defined?(Tempfile) && value.is_a?(Tempfile))
122
+ end
123
+ end
124
+
125
+ def form_add(name, content)
126
+ name = validate_header_line(:name, name.to_s)
127
+ code = case content
128
+ when Queryable::FileInfo
129
+ # The form API writes these into Content-Disposition headers. Older
130
+ # libcurl versions do not escape them.
131
+ filename = validate_header_line(:filename, content[0].to_s)
132
+ content_type = validate_header_line(:content_type, content[1].to_s)
76
133
  Curl.formadd(first, last,
77
134
  :form_option, :copyname, :pointer, name,
78
135
  :form_option, :namelength, :long, name.bytesize,
79
136
  :form_option, :file, :string, content[2],
80
- :form_option, :filename, :string, content[0],
81
- :form_option, :contenttype, :string, content[1],
137
+ :form_option, :filename, :string, filename,
138
+ :form_option, :contenttype, :string, content_type,
82
139
  :form_option, :end
83
140
  )
84
141
  else
@@ -91,6 +148,8 @@ module Ethon
91
148
  )
92
149
  end
93
150
 
151
+ raise Errors::FormAdd.new(code) unless code == 0
152
+
94
153
  setup_garbage_collection
95
154
  end
96
155
 
@@ -100,7 +159,7 @@ module Ethon
100
159
  # to. So this results in (pseudo-c):
101
160
  # form_data_cleanup_handler = *first
102
161
  # curl_form_free(form_data_cleanup_handler)
103
- @form_data_cleanup_handler ||= FFI::AutoPointer.new(@first.get_pointer(0), Curl.method(:formfree))
162
+ @form_data_cleanup_handler ||= Curl::FormData.new(@first.get_pointer(0))
104
163
  end
105
164
  end
106
165
  end
@@ -5,14 +5,19 @@ module Ethon
5
5
  #
6
6
  # @api private
7
7
  module Header
8
- # Return headers, return empty hash if none.
8
+ # Return the request headers, return empty hash if none.
9
9
  #
10
- # @example Return the headers.
11
- # easy.headers
10
+ # The returned hash is an independent snapshot of the headers that were
11
+ # sent to libcurl. Mutating the hash or a value passed to #headers=
12
+ # afterwards cannot change either the handle or this snapshot, and
13
+ # mutating the returned hash or its values does not change the request.
12
14
  #
13
- # @return [ Hash ] The headers.
14
- def headers
15
- @headers ||= {}
15
+ # @example Return the request headers.
16
+ # easy.request_headers
17
+ #
18
+ # @return [ Hash ] The request headers.
19
+ def request_headers
20
+ copy_headers(@headers || {})
16
21
  end
17
22
 
18
23
  # Set the headers.
@@ -20,17 +25,25 @@ module Ethon
20
25
  # @example Set the headers.
21
26
  # easy.headers = {'User-Agent' => 'ethon'}
22
27
  #
23
- # @param [ Hash ] headers The headers.
24
- def headers=(headers)
25
- headers ||= {}
26
- header_list = nil
27
- headers.each do |k, v|
28
- header_list = Curl.slist_append(header_list, compose_header(k,v))
28
+ # @param [ Hash ] value The headers.
29
+ def headers=(value)
30
+ # Snapshot before building the list so the stored value is exactly the
31
+ # set that libcurl accepted. Copy the values too: mutating a
32
+ # caller-owned header value in place would otherwise change the
33
+ # snapshot.
34
+ headers = copy_headers(value || {})
35
+ list = build_slist(headers)
36
+ begin
37
+ Curl.set_option(:httpheader, list, handle)
38
+ rescue Exception
39
+ Curl.slist_free_all(list) if list
40
+ raise
29
41
  end
30
- Curl.set_option(:httpheader, header_list, handle)
31
42
 
32
- @header_list = header_list && FFI::AutoPointer.new(header_list, Curl.method(:slist_free_all))
43
+ @headers = headers.freeze
44
+ @header_list = list && Curl::Slist.new(list)
33
45
  end
46
+ alias request_headers= headers=
34
47
 
35
48
  # Return header_list.
36
49
  #
@@ -54,7 +67,36 @@ module Ethon
54
67
  #
55
68
  # @return [ String ] The composed header.
56
69
  def compose_header(key, value)
57
- Util.escape_zero_byte("#{key}: #{value}")
70
+ header = if key.to_s.end_with?(';') && value.to_s.empty?
71
+ key.to_s
72
+ else
73
+ "#{key}: #{value}"
74
+ end
75
+ header = Util.escape_zero_byte(header)
76
+ # libcurl sends custom headers verbatim, so CR/LF would inject
77
+ # additional headers into the request.
78
+ Util.validate_header_line(:headers, header)
79
+ end
80
+
81
+ private
82
+
83
+ # Copy a header hash so callers and the Easy cannot change each other's
84
+ # header values in place. Values that cannot be copied are kept as they
85
+ # are; header values are normally strings.
86
+ def copy_headers(headers)
87
+ headers.each_with_object({}) do |(key, value), copy|
88
+ copy[key] = begin
89
+ value.dup
90
+ rescue TypeError
91
+ value
92
+ end
93
+ end
94
+ end
95
+
96
+ # Build a curl_slist for the headers. Compose first so an invalid header
97
+ # does not leave a partial native list behind.
98
+ def build_slist(headers)
99
+ Curl::Slist.build(headers.map { |key, value| compose_header(key, value) })
58
100
  end
59
101
  end
60
102
  end