ethon 0.19.1 → 0.19.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 495f7cdb119483c6c3876354aa1ceeb8ae485ac25708280104213b2df28949e8
4
- data.tar.gz: 6343bc21b68edfbce32389f739880c40a3506640249341c6bbd6eeca250e958f
3
+ metadata.gz: 5174f0935f97734b2720d7a3445117049a2651cf2796bdfe0b85cee8742037c0
4
+ data.tar.gz: 14ec1a62317eff2251a2658dffb7cf8350071dee462fd1fb86c15888a78ffe31
5
5
  SHA512:
6
- metadata.gz: e55248f897175f82a169ce09bb6a5b344f867027a3c3386935394adf058e0693501502ceb1cecb5748b1e1d7ca4c39eb0ce83321ee45ceb4ac73f5e0c032ba98
7
- data.tar.gz: afbcbd73dc6a6e38f765d8ce89258711401b58cdda70e2b1fb7399d49b8bb0825fb400a47e489c4e23f86a222711859318550278056829fa7a87b2679e20505c
6
+ metadata.gz: 44c508f1dff76285672f55f80a69390ee9f5e472bba9894a0b74328af214a1c9eae9288114fcfd1dd79aa20a70911c0fa864982a9726711fb7e8757d8e4cec08
7
+ data.tar.gz: 85dcaebb702402a4fc45c6db9de5412496aa4cf2b0705f1d09ca702a6ad2f77a065b4abbb1654da1705a22f5fc5a6315e734b3a0cadb374a702cac4265845f85
data/CHANGELOG.md CHANGED
@@ -2,7 +2,51 @@
2
2
 
3
3
  ## Master
4
4
 
5
- [Full Changelog](https://github.com/typhoeus/ethon/compare/v0.19.1...master)
5
+ [Full Changelog](https://github.com/typhoeus/ethon/compare/v0.19.2...master)
6
+
7
+ ## 0.19.2
8
+
9
+ [Full Changelog](https://github.com/typhoeus/ethon/compare/v0.19.1...v0.19.2)
10
+
11
+ ### Fixes and hardening
12
+
13
+ * Clear `CURLOPT_POSTFIELDSIZE` to 0 instead of -1 when `Easy#postfields=` or
14
+ `Easy#copypostfields=` clears the body. With a size of -1 libcurl treats the
15
+ body as indeterminate, sends the POST chunked, and reads it from the default
16
+ read callback, which is stdin, so the request waited until stdin was closed.
17
+ ([#463](https://github.com/typhoeus/ethon/issues/463))
18
+
19
+ ### Enhancements
20
+
21
+ * Mirror `certinfo`, so the certificate chain read with `certinfo: true`
22
+ survives the handle reset and reaches Typhoeus responses
23
+ ([typhoeus/typhoeus#427](https://github.com/typhoeus/typhoeus/issues/427))
24
+
25
+ * Expose `Easy#primary_port`, `Easy#local_ip`, `Easy#local_port`,
26
+ `Easy#ssl_verifyresult`, `Easy#num_connects`, `Easy#os_errno`,
27
+ `Easy#http_connectcode`, `Easy#proxyauth_avail`, `Easy#condition_unmet`,
28
+ `Easy#header_size` and `Easy#content_type`. They are also included in the
29
+ mirror, so they survive the handle reset and show up in response options.
30
+ Everything in the mirror is read for every response, so the deprecated
31
+ `filetime`, `lastsocket` and `content_length_*` values and the RTSP and FTP
32
+ entry informations are left out in favour of the `_t` and `activesocket`
33
+ variants that are already exposed.
34
+ ([#460](https://github.com/typhoeus/ethon/pull/460))
35
+
36
+ * Add `Ethon::Multipart::Part` for setting the content type and, optionally,
37
+ the filename of a single `multipart/form-data` part. Plain body values keep
38
+ their existing behavior.
39
+ ([typhoeus/typhoeus#494](https://github.com/typhoeus/typhoeus/issues/494))
40
+
41
+ * Add `Ethon::Curl.allow_handles_after_fork!`, an opt-in for servers that fork
42
+ workers from a process that has already used libcurl and cannot `exec`.
43
+ Called in the child before its first handle, it lets the child create new
44
+ handles on the libcurl state it inherited instead of raising
45
+ `Ethon::Errors::ForkSafety`. Handles created before the fork are still
46
+ rejected, as is `Ethon::Curl.cleanup` in the child, and the default is
47
+ unchanged. The opt-in is not supported on macOS, where it raises
48
+ `Ethon::Errors::ForkSafety` in a child whose parent initialized libcurl.
49
+ ([#462](https://github.com/typhoeus/ethon/issues/462))
6
50
 
7
51
  ## 0.19.1
8
52
 
data/README.md CHANGED
@@ -75,7 +75,29 @@ easy.perform
75
75
 
76
76
  File values in a `:body` hash are sent as `multipart/form-data`. Ethon loads
77
77
  the optional `mime-types` gem on the first upload to detect each part's content
78
- type; without it, parts use `application/octet-stream`.
78
+ type; with it absent, files fall back to `application/octet-stream`.
79
+
80
+ Wrap a value in `Ethon::Multipart::Part` to set the content type for that part,
81
+ and optionally the filename. A String with a filename is sent as an in-memory
82
+ upload.
83
+
84
+ ```ruby
85
+ easy.http_request(
86
+ "www.example.com",
87
+ :post,
88
+ body: {
89
+ metadata: Ethon::Multipart::Part.new(
90
+ '{"a":1}',
91
+ content_type: "application/json"
92
+ ),
93
+ file: Ethon::Multipart::Part.new(
94
+ File.open("photo.jpg"),
95
+ filename: "avatar.png",
96
+ content_type: "image/png"
97
+ )
98
+ }
99
+ )
100
+ ```
79
101
 
80
102
  ## Parallel requests
81
103
 
@@ -228,13 +250,38 @@ With libcurl older than 7.84.0, create an Ethon handle before starting other
228
250
  application threads so libcurl's global initialization happens while the
229
251
  process is still single-threaded.
230
252
 
231
- Ethon handles and initialized libcurl state must not be inherited across a
232
- `fork`. If any `Easy` or `Multi` was created before forking, libcurl is already
233
- initialized and the child cannot create new handles either; use `fork` followed
234
- by `exec` in that case. If libcurl was not initialized before forking, create
235
- all `Easy` and `Multi` instances after the fork. Ethon raises
236
- `Ethon::Errors::ForkSafety` when a child process tries to use inherited libcurl
237
- state.
253
+ Ethon handles and, by default, initialized libcurl state must not be inherited
254
+ across a `fork`. If any `Easy` or `Multi` was created before forking, libcurl
255
+ is already initialized and the child cannot create new handles either; use
256
+ `fork` followed by `exec` in that case, or opt in as described below. If
257
+ libcurl was not initialized before forking, create all `Easy` and `Multi`
258
+ instances after the fork. Ethon raises `Ethon::Errors::ForkSafety` when a child
259
+ process tries to use an inherited handle, or to use inherited libcurl state
260
+ without opting in.
261
+
262
+ A server that forks workers from a process that has already used libcurl, such
263
+ as Pitchfork with reforking or Puma with `fork_worker`, cannot `exec` without
264
+ losing the memory its workers share with that process. Instead, each worker can
265
+ opt in to creating new handles on the libcurl state it inherited. Call
266
+ `Ethon::Curl.allow_handles_after_fork!` in the worker before it creates its
267
+ first handle and before any other thread uses Ethon, for example from
268
+ Pitchfork's `after_worker_fork` hook:
269
+
270
+ ```ruby
271
+ after_worker_fork do |_server, _worker|
272
+ Ethon::Curl.allow_handles_after_fork!
273
+ end
274
+ ```
275
+
276
+ Puma's equivalent hook is `before_worker_boot` (`on_worker_boot` before
277
+ Puma 7). Repeat calls in the same worker do nothing. The worker does not
278
+ initialize libcurl again, and it cannot clean up the libcurl state it
279
+ inherited: `Ethon::Curl.cleanup` raises `Ethon::Errors::ForkSafety` there, as
280
+ handles created before the fork still do. On macOS, system frameworks are not
281
+ safe to use after `fork` without `exec`, and libcurl can use them, for example
282
+ for TLS. The opt-in is not supported there and raises
283
+ `Ethon::Errors::ForkSafety` in a worker whose parent initialized libcurl, so
284
+ use `fork` followed by `exec` on macOS.
238
285
 
239
286
  ## License
240
287
 
data/lib/ethon/curl.rb CHANGED
@@ -26,6 +26,11 @@ module Ethon
26
26
  Libc.windows?
27
27
  end
28
28
 
29
+ # :nodoc:
30
+ def self.macos?
31
+ Libc.macos?
32
+ end
33
+
29
34
  require 'ethon/curls/constants'
30
35
  require 'ethon/curls/introspection_constants'
31
36
  require 'ethon/curls/settings'
@@ -36,6 +41,7 @@ module Ethon
36
41
  @blocking = true
37
42
 
38
43
  @@initialized = false
44
+ @@initialized_pid = nil
39
45
  @@loaded_pid = Process.pid
40
46
  @@curl_mutex = Mutex.new
41
47
 
@@ -70,6 +76,7 @@ module Ethon
70
76
  if not @@initialized
71
77
  raise Errors::GlobalInit.new if Curl.global_init(GLOBAL_ALL) != 0
72
78
  @@initialized = true
79
+ @@initialized_pid = Process.pid
73
80
  Ethon.logger.debug("ETHON: Libcurl initialized") if Ethon.logger
74
81
  end
75
82
  }
@@ -85,10 +92,19 @@ module Ethon
85
92
  # any other thread that uses these other libraries.
86
93
  # See the description in libcurl of global environment requirements
87
94
  # for details of how to use this function.
95
+ #
96
+ # @raise [ Ethon::Errors::ForkSafety ] In a child that opted in with
97
+ # allow_handles_after_fork! and inherited libcurl initialized by its
98
+ # parent.
88
99
  def cleanup
89
100
  check_pid!
90
101
  @@curl_mutex.synchronize {
91
102
  if @@initialized
103
+ if @@initialized_pid != Process.pid
104
+ raise Errors::ForkSafety.new(
105
+ "Ethon cannot clean up libcurl initialized before a fork; clean it up in the process that initialized it"
106
+ )
107
+ end
92
108
  Curl.global_cleanup()
93
109
  @@initialized = false
94
110
  Ethon.logger.debug("ETHON: Libcurl cleanup") if Ethon.logger
@@ -105,6 +121,34 @@ module Ethon
105
121
  @@curl_mutex = Mutex.new
106
122
  end
107
123
 
124
+ # Let a forked child create new handles on the libcurl state it
125
+ # inherited. Call it in the child before its first handle; repeat calls
126
+ # do nothing. Handles created before the fork still raise ForkSafety,
127
+ # and so does cleanup, because the child did not initialize libcurl.
128
+ #
129
+ # @example Opt in from a Pitchfork worker's after_worker_fork hook.
130
+ # Ethon::Curl.allow_handles_after_fork!
131
+ #
132
+ # @raise [ Ethon::Errors::ForkSafety ] On macOS, in a child whose parent
133
+ # initialized libcurl.
134
+ #
135
+ # @api public
136
+ def allow_handles_after_fork!
137
+ return if @@loaded_pid == Process.pid
138
+
139
+ if @@initialized && macos?
140
+ raise Errors::ForkSafety.new(
141
+ "Ethon cannot use libcurl inherited across a fork on macOS; use fork+exec"
142
+ )
143
+ end
144
+
145
+ # Leave @@initialized set: the child inherited the parent's libcurl
146
+ # global state, so it must not run curl_global_init again.
147
+ @@loaded_pid = Process.pid
148
+ @@curl_mutex = Mutex.new
149
+ nil
150
+ end
151
+
108
152
  end
109
153
  end
110
154
  end
@@ -48,8 +48,10 @@ module Ethon
48
48
  @last ||= FFI::MemoryPointer.new(:pointer)
49
49
  end
50
50
 
51
- # Return if form is multipart. The form is multipart
52
- # when it contains a file or multipart option is set on the form during creation.
51
+ # Return if form is multipart. The form is multipart when the multipart
52
+ # option is set, when it contains a file, or when a value is wrapped in a
53
+ # Multipart::Part that carries a content type or a filename. A wrapped
54
+ # plain value is encoded like an unwrapped one.
53
55
  #
54
56
  # @example Return if form is multipart.
55
57
  # form.multipart?
@@ -57,7 +59,7 @@ module Ethon
57
59
  # @return [ Boolean ] True if form is multipart, else false.
58
60
  def multipart?
59
61
  return true if @multipart
60
- contains_file?(@params)
62
+ contains_multipart_part?(@params)
61
63
  end
62
64
 
63
65
  # Add form elements to libcurl.
@@ -88,13 +90,7 @@ module Ethon
88
90
  part = structure.addpart
89
91
  name, content = pair
90
92
  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
93
+ add_mime_part(part, content)
98
94
  end
99
95
  structure
100
96
  end
@@ -111,48 +107,147 @@ module Ethon
111
107
  raise ArgumentError, "multipart bodies require a Hash of fields"
112
108
  end
113
109
 
114
- def contains_file?(value)
110
+ def contains_multipart_part?(value)
115
111
  case value
112
+ when Multipart::Part
113
+ value.upload? || !value.content_type.nil?
116
114
  when Hash
117
- value.values.any? { |item| contains_file?(item) }
115
+ value.values.any? { |item| contains_multipart_part?(item) }
118
116
  when Array
119
- value.any? { |item| contains_file?(item) }
117
+ value.any? { |item| contains_multipart_part?(item) }
120
118
  else
121
- value.is_a?(File) || (defined?(Tempfile) && value.is_a?(Tempfile))
119
+ Multipart::Part.file?(value)
122
120
  end
123
121
  end
124
122
 
123
+ def add_mime_part(part, content)
124
+ case content
125
+ when Multipart::Part
126
+ add_mime_part_from_part(part, content)
127
+ when Queryable::FileInfo
128
+ part.file = content[2]
129
+ part.filename = content[0]
130
+ part.type = content[1]
131
+ else
132
+ part.data = content.to_s
133
+ end
134
+ end
135
+
136
+ def add_mime_part_from_part(part, content)
137
+ unless content.upload?
138
+ part.data = content.value.to_s
139
+ part.type = content.content_type unless content.content_type.nil?
140
+ return
141
+ end
142
+
143
+ filename = part_filename(content)
144
+ content_type = part_content_type(content, filename)
145
+
146
+ if content.file?
147
+ part.file = part_path(content)
148
+ else
149
+ part.data = content.value.to_s
150
+ end
151
+ part.filename = filename
152
+ part.type = content_type
153
+ end
154
+
125
155
  def form_add(name, content)
126
156
  name = validate_header_line(:name, name.to_s)
127
- code = case content
157
+ options = [
158
+ :form_option, :copyname, :pointer, name,
159
+ :form_option, :namelength, :long, name.bytesize
160
+ ]
161
+
162
+ case content
163
+ when Multipart::Part
164
+ add_form_options_for_part(options, content)
128
165
  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)
133
- Curl.formadd(first, last,
134
- :form_option, :copyname, :pointer, name,
135
- :form_option, :namelength, :long, name.bytesize,
136
- :form_option, :file, :string, content[2],
137
- :form_option, :filename, :string, filename,
138
- :form_option, :contenttype, :string, content_type,
139
- :form_option, :end
140
- )
166
+ add_form_file_options(options, content[2], content[0], content[1])
141
167
  else
142
- Curl.formadd(first, last,
143
- :form_option, :copyname, :pointer, name,
144
- :form_option, :namelength, :long, name.bytesize,
145
- :form_option, :copycontents, :pointer, content.to_s,
146
- :form_option, :contentslength, :long, content ? content.to_s.bytesize : 0,
147
- :form_option, :end
148
- )
168
+ data = content.to_s
169
+ options.concat([
170
+ :form_option, :copycontents, :pointer, data,
171
+ :form_option, :contentslength, :long, data.bytesize
172
+ ])
149
173
  end
150
174
 
175
+ options << :form_option << :end
176
+ code = Curl.formadd(first, last, *options)
177
+
151
178
  raise Errors::FormAdd.new(code) unless code == 0
152
179
 
153
180
  setup_garbage_collection
154
181
  end
155
182
 
183
+ def add_form_options_for_part(options, content)
184
+ unless content.upload?
185
+ data = content.value.to_s
186
+ options.concat([
187
+ :form_option, :copycontents, :pointer, data,
188
+ :form_option, :contentslength, :long, data.bytesize
189
+ ])
190
+ return if content.content_type.nil?
191
+
192
+ content_type = validate_header_line(:content_type, content.content_type.to_s)
193
+ options.concat([:form_option, :contenttype, :string, content_type])
194
+ return
195
+ end
196
+
197
+ filename = part_filename(content)
198
+ content_type = part_content_type(content, filename)
199
+
200
+ if content.file?
201
+ add_form_file_options(options, part_path(content), filename, content_type)
202
+ else
203
+ add_form_buffer_options(options, content.value.to_s, filename, content_type)
204
+ end
205
+ end
206
+
207
+ # libcurl keeps the path and opens the file when the form is sent, so
208
+ # make it absolute in case the process changes directory in between.
209
+ def part_path(part)
210
+ File.expand_path(part.value.path)
211
+ end
212
+
213
+ def part_filename(part)
214
+ part.filename || File.basename(part.value.path)
215
+ end
216
+
217
+ def part_content_type(part, filename)
218
+ part.content_type || mime_type(filename)
219
+ end
220
+
221
+ def add_form_file_options(options, path, filename, content_type)
222
+ # The form API writes these into Content-Disposition headers. Older
223
+ # libcurl versions do not escape them.
224
+ filename = validate_header_line(:filename, filename.to_s)
225
+ content_type = validate_header_line(:content_type, content_type.to_s)
226
+ options.concat([
227
+ :form_option, :file, :string, path,
228
+ :form_option, :filename, :string, filename,
229
+ :form_option, :contenttype, :string, content_type
230
+ ])
231
+ end
232
+
233
+ def add_form_buffer_options(options, data, filename, content_type)
234
+ filename = validate_header_line(:filename, filename.to_s)
235
+ content_type = validate_header_line(:content_type, content_type.to_s)
236
+
237
+ # CURLFORM_BUFFERPTR does not copy the data, so keep a stable copy
238
+ # alive until libcurl has read the form during perform. The caller's
239
+ # String can be changed after setup, and the GC can move it.
240
+ buffer = FFI::MemoryPointer.from_string(data)
241
+ (@form_buffers ||= []) << buffer
242
+
243
+ options.concat([
244
+ :form_option, :buffer, :string, filename,
245
+ :form_option, :bufferptr, :pointer, buffer,
246
+ :form_option, :bufferlength, :long, data.bytesize,
247
+ :form_option, :contenttype, :string, content_type
248
+ ])
249
+ end
250
+
156
251
  def setup_garbage_collection
157
252
  # first is a pointer to a pointer. Since it's a MemoryPointer it will
158
253
  # auto clean itself up, but we need to clean up the object it points
@@ -9,6 +9,19 @@ module Ethon
9
9
 
10
10
  # Holds available informations and their type, which is needed to
11
11
  # request the informations from libcurl.
12
+ #
13
+ # Everything listed here is read for every response that builds a
14
+ # Mirror, so it stays limited to values that are cheap, not deprecated,
15
+ # and not tied to a single protocol. Deliberately left out:
16
+ #
17
+ # * CURLINFO_PRIVATE returns the pointer set with CURLOPT_PRIVATE, not a
18
+ # C string, so a string getter would read freed memory.
19
+ # * CURLINFO_FILETIME, CURLINFO_CONTENT_LENGTH_DOWNLOAD,
20
+ # CURLINFO_CONTENT_LENGTH_UPLOAD and CURLINFO_LASTSOCKET are superseded
21
+ # by filetime_t, content_length_*_t and activesocket, which are listed
22
+ # above.
23
+ # * The RTSP informations and CURLINFO_FTP_ENTRY_PATH are only set for
24
+ # those protocols.
12
25
  AVAILABLE_INFORMATIONS = {
13
26
  # The _T variants use curl_off_t and do not lose precision for large
14
27
  # values or long-running transfers.
@@ -39,13 +52,24 @@ module Ethon
39
52
  :httpauth_used => :long,
40
53
  :proxyauth_used => :long,
41
54
  :proxy_ssl_verifyresult => :long,
55
+ # Result of the SSL peer certificate verification. Zero means the
56
+ # certificate was verified successfully. (Added in 7.19.0)
57
+ :ssl_verifyresult => :long,
42
58
  :proxy_error => :long,
43
59
  :used_proxy => :long,
60
+ :local_port => :long,
61
+ :num_connects => :long,
62
+ :os_errno => :long,
63
+ :http_connectcode => :long,
64
+ :proxyauth_avail => :long,
65
+ :condition_unmet => :long,
66
+ :header_size => :long,
44
67
  :scheme => :string,
45
68
  :effective_method => :string,
46
69
  :referer => :string,
47
70
  :cainfo => :string,
48
71
  :capath => :string,
72
+ :content_type => :string,
49
73
  :activesocket => :socket,
50
74
  :tls_ssl_ptr => :ptr,
51
75
 
@@ -100,6 +124,12 @@ module Ethon
100
124
  # may be IPv6 if that's enabled.
101
125
  :primary_ip => :string,
102
126
 
127
+ # Return the local IP address of the most recent connection.
128
+ :local_ip => :string,
129
+
130
+ # Return the destination port of the most recent connection.
131
+ :primary_port => :long,
132
+
103
133
  # Return the last received HTTP, FTP or SMTP response code.
104
134
  # The value will be zero if no server response code has
105
135
  # been received. Note that a proxy's CONNECT response should
@@ -8,9 +8,15 @@ module Ethon
8
8
  # Raw pointers cannot be marshaled and point into native state that can
9
9
  # go away when the handle is reset. Applications serialize a mirror (for
10
10
  # example as a cached Typhoeus response), so leave them on the Easy.
11
+ #
12
+ # certinfo is appended by hand because libcurl returns a list of lists
13
+ # for it rather than a scalar, so it is not part of
14
+ # AVAILABLE_INFORMATIONS. It is read on every mirror like the rest,
15
+ # which costs one getinfo call that returns NULL when the Easy was not
16
+ # asked for the chain.
11
17
  INFORMATIONS_TO_MIRROR = Informations::AVAILABLE_INFORMATIONS.
12
18
  reject { |_, type| type == :ptr }.keys +
13
- [:return_code, :response_headers, :response_body, :debug_info, :error_message]
19
+ [:return_code, :response_headers, :response_body, :debug_info, :error_message, :certinfo]
14
20
 
15
21
  INFORMATIONS_TO_LOG = [:effective_url, :response_code, :return_code, :total_time]
16
22
 
@@ -219,9 +219,12 @@ module Ethon
219
219
  @postfields = nil
220
220
  @postfields_pointer = nil
221
221
  @postfield_body_size = nil
222
- # -1 is libcurl's default: the body ends at the first NUL.
223
- Curl.set_option(:postfieldsize, -1, handle)
224
- @postfieldsize_value = -1
222
+ # A zero size keeps libcurl on the null reader, which sends
223
+ # Content-Length: 0. With the size left at -1 libcurl treats the
224
+ # body as indeterminate, sends it chunked, and reads it from the
225
+ # default read callback, which is stdin.
226
+ Curl.set_option(:postfieldsize, 0, handle)
227
+ @postfieldsize_value = nil
225
228
  else
226
229
  string = value.to_s
227
230
  string = string.dup.freeze unless string.frozen?
@@ -249,10 +252,11 @@ module Ethon
249
252
  @postfields = nil
250
253
  @postfields_pointer = nil
251
254
  @postfield_body_size = nil
252
- # Clear the size too: libcurl fails the request when a non-zero size
253
- # is paired with the NULL body left by COPYPOSTFIELDS.
254
- Curl.set_option(:postfieldsize, -1, handle)
255
- @postfieldsize_value = -1
255
+ # Clear the size too, otherwise libcurl keeps describing the copy
256
+ # that is being removed. Zero rather than -1 for the same reason as
257
+ # in #postfields=.
258
+ Curl.set_option(:postfieldsize, 0, handle)
259
+ @postfieldsize_value = nil
256
260
  return value
257
261
  end
258
262
 
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
+ require 'ethon/multipart'
3
+
2
4
  module Ethon
3
5
  class Easy
4
6
 
@@ -186,6 +188,9 @@ module Ethon
186
188
 
187
189
  def pairs_for(v, key, pairs)
188
190
  case v
191
+ when Multipart::Part
192
+ # The form builders need the metadata, so keep the part intact.
193
+ pairs << [key, v]
189
194
  when Hash, Array
190
195
  recursively_generate_pairs(v, key, pairs)
191
196
  when File, Tempfile
@@ -2,8 +2,8 @@
2
2
  module Ethon
3
3
  module Errors
4
4
  class ForkSafety < EthonError
5
- def initialize
6
- super("Ethon cannot use libcurl initialized before a fork; use fork+exec or initialize libcurl after forking")
5
+ def initialize(message = "Ethon cannot use libcurl initialized before a fork; use fork+exec or initialize libcurl after forking")
6
+ super(message)
7
7
  end
8
8
  end
9
9
  end
data/lib/ethon/libc.rb CHANGED
@@ -15,6 +15,11 @@ module Ethon
15
15
  Gem.win_platform?
16
16
  end
17
17
 
18
+ # :nodoc:
19
+ def self.macos?
20
+ FFI::Platform.mac?
21
+ end
22
+
18
23
  def self.open_max
19
24
  value = Etc.sysconf(Etc::SC_OPEN_MAX)
20
25
  return value if value && value > 0
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ethon
4
+ # Multipart form data support.
5
+ module Multipart
6
+ # A single part of a multipart form body.
7
+ #
8
+ # Wrap a value to control the content type, and optionally the filename,
9
+ # that Ethon sends for that field. Values that are not wrapped keep the
10
+ # existing behaviour: files are detected with mime-types and plain values
11
+ # have no content type.
12
+ #
13
+ # @example A JSON field.
14
+ # Ethon::Multipart::Part.new('{"a":1}', content_type: "application/json")
15
+ #
16
+ # @example A file with an explicit filename and content type.
17
+ # Ethon::Multipart::Part.new(
18
+ # File.open("photo.jpg"),
19
+ # filename: "avatar.png",
20
+ # content_type: "image/png"
21
+ # )
22
+ #
23
+ # @example An in-memory upload.
24
+ # Ethon::Multipart::Part.new(
25
+ # "file contents",
26
+ # filename: "notes.txt",
27
+ # content_type: "text/plain"
28
+ # )
29
+ class Part
30
+ # @return [ String, File, Tempfile ] The value sent for this field.
31
+ attr_reader :value
32
+
33
+ # @return [ String, nil ] The filename sent for an upload.
34
+ attr_reader :filename
35
+
36
+ # @return [ String, nil ] The content type sent for this part.
37
+ attr_reader :content_type
38
+
39
+ # Create a new multipart part.
40
+ #
41
+ # A value that responds to #read must be a File or Tempfile. Ethon
42
+ # sends files by path; other IO objects have to be read by the caller.
43
+ #
44
+ # @param [ String, File, Tempfile ] value The value of the part.
45
+ # @param [ String, nil ] filename The filename to send. Setting it turns
46
+ # a String value into a file part.
47
+ # @param [ String, nil ] content_type The content type for this part.
48
+ #
49
+ # @raise [ ArgumentError ] If value is an IO that Ethon cannot upload.
50
+ def initialize(value, filename: nil, content_type: nil)
51
+ if value.respond_to?(:read) && !self.class.file?(value)
52
+ raise ArgumentError,
53
+ "multipart parts take a String or a File, not #{value.class}; read the IO first"
54
+ end
55
+
56
+ @value = value
57
+ @filename = filename
58
+ @content_type = content_type
59
+ end
60
+
61
+ # Return whether the value is a file Ethon can read by path.
62
+ #
63
+ # @return [ Boolean ] True for File and Tempfile values.
64
+ def file?
65
+ self.class.file?(value)
66
+ end
67
+
68
+ # Return whether this part is an upload rather than a regular field.
69
+ # A filename turns in-memory data into an upload, matching curl's
70
+ # "-F name=value;filename=..." behaviour.
71
+ #
72
+ # @return [ Boolean ] True when the part has a file or a filename.
73
+ def upload?
74
+ file? || !filename.nil?
75
+ end
76
+
77
+ # @api private
78
+ def self.file?(value)
79
+ value.is_a?(File) || (defined?(Tempfile) && value.is_a?(Tempfile))
80
+ end
81
+
82
+ # Return the value as a string, so a part can still be encoded like a
83
+ # plain form value when a caller asks for a non-multipart body.
84
+ #
85
+ # @return [ String ] The string representation of the value.
86
+ def to_s
87
+ value.to_s
88
+ end
89
+
90
+ def ==(other)
91
+ other.is_a?(Part) &&
92
+ value_key == other.value_key &&
93
+ filename == other.filename &&
94
+ content_type == other.content_type
95
+ end
96
+ alias eql? ==
97
+
98
+ def hash
99
+ [Part, value_key, filename, content_type].hash
100
+ end
101
+
102
+ # The value used by #== and #hash. Files compare by path because File
103
+ # itself compares by identity.
104
+ #
105
+ # @api private
106
+ def value_key
107
+ file? ? value.path : value
108
+ end
109
+
110
+ def inspect
111
+ "#<#{self.class} value=#{value.inspect} filename=#{filename.inspect} content_type=#{content_type.inspect}>"
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/ethon/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  module Ethon
3
3
 
4
4
  # Ethon version.
5
- VERSION = '0.19.1'
5
+ VERSION = '0.19.2'
6
6
  end
data/lib/ethon.rb CHANGED
@@ -7,6 +7,7 @@ require 'tempfile'
7
7
  require 'ethon/libc'
8
8
  require 'ethon/curl'
9
9
  require 'ethon/curls/mime'
10
+ require 'ethon/multipart'
10
11
  require 'ethon/easy'
11
12
  require 'ethon/easy/url'
12
13
  require 'ethon/easy/mime'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ethon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.1
4
+ version: 0.19.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Hasselberg
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-28 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -128,16 +128,17 @@ files:
128
128
  - lib/ethon/multi/operations.rb
129
129
  - lib/ethon/multi/options.rb
130
130
  - lib/ethon/multi/stack.rb
131
+ - lib/ethon/multipart.rb
131
132
  - lib/ethon/version.rb
132
133
  homepage: https://github.com/typhoeus/ethon
133
134
  licenses:
134
135
  - MIT
135
136
  metadata:
136
137
  bug_tracker_uri: https://github.com/typhoeus/ethon/issues
137
- changelog_uri: https://github.com/typhoeus/ethon/blob/v0.19.1/CHANGELOG.md
138
- documentation_uri: https://www.rubydoc.info/gems/ethon/0.19.1
138
+ changelog_uri: https://github.com/typhoeus/ethon/blob/v0.19.2/CHANGELOG.md
139
+ documentation_uri: https://www.rubydoc.info/gems/ethon/0.19.2
139
140
  rubygems_mfa_required: 'true'
140
- source_code_uri: https://github.com/typhoeus/ethon/tree/v0.19.1
141
+ source_code_uri: https://github.com/typhoeus/ethon/tree/v0.19.2
141
142
  post_install_message:
142
143
  rdoc_options: []
143
144
  require_paths: