patron 0.13.4 → 0.14.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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +6 -0
- data/.github/workflows/ci.yml +56 -3
- data/CHANGELOG.md +20 -0
- data/Gemfile +2 -1
- data/README.md +30 -1
- data/ext/patron/extconf.rb +4 -0
- data/ext/patron/session_ext.c +285 -75
- data/lib/patron/error.rb +4 -0
- data/lib/patron/request.rb +6 -4
- data/lib/patron/response.rb +5 -0
- data/lib/patron/session.rb +17 -0
- data/lib/patron/version.rb +1 -1
- data/patron.gemspec +2 -1
- data/script/generate_client_certs +65 -0
- data/spec/request_spec.rb +12 -0
- data/spec/session_spec.rb +178 -11
- data/spec/session_ssl_spec.rb +126 -8
- data/spec/spec_helper.rb +6 -0
- data/spec/support/certs/client.p12 +0 -0
- data/spec/support/certs/client_ca.pem +20 -0
- data/spec/support/certs/client_cert.pem +20 -0
- data/spec/support/certs/client_key.pem +30 -0
- data/spec/support/certs/rogue_client.p12 +0 -0
- data/spec/support/config.ru +7 -0
- data/spec/support/raw_request_capture.rb +93 -0
- data/spec/support/test_server.rb +6 -3
- data/spec/support/valgrind.supp +2 -0
- metadata +13 -4
data/lib/patron/error.rb
CHANGED
|
@@ -36,6 +36,10 @@ module Patron
|
|
|
36
36
|
# Gets raised if the progress callback, or an interrupt, aborts the Curl perform() call
|
|
37
37
|
class Aborted < Error; end
|
|
38
38
|
|
|
39
|
+
# Gets raised when the client certificate or its private key could not be used,
|
|
40
|
+
# for example when the key password is wrong or the certificate file is missing.
|
|
41
|
+
class ClientCertificateError < Error; end
|
|
42
|
+
|
|
39
43
|
# Gets raised when the server specifies an encoding that could not be found, or has an invalid name,
|
|
40
44
|
# or when the server "lies" about the encoding of the response body (such as can be the case
|
|
41
45
|
# when the server specifies an encoding in `Content-Type`) which the HTML generator then overrides
|
data/lib/patron/request.rb
CHANGED
|
@@ -25,13 +25,13 @@ module Patron
|
|
|
25
25
|
:ignore_content_length, :multipart, :action, :timeout, :connect_timeout, :dns_cache_timeout,
|
|
26
26
|
:max_redirects, :headers, :auth_type, :upload_data, :buffer_size, :cacert,
|
|
27
27
|
:ssl_version, :http_version, :automatic_content_encoding, :force_ipv4, :download_byte_limit,
|
|
28
|
-
:low_speed_time, :low_speed_limit, :progress_callback
|
|
28
|
+
:low_speed_time, :low_speed_limit, :progress_callback, :ssl_cert, :ssl_cert_type, :ssl_key, :ssl_key_password
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
WRITER_VARS = [
|
|
32
32
|
:url, :username, :password, :file_name, :proxy, :proxy_type, :insecure, :dns_cache_timeout,
|
|
33
33
|
:ignore_content_length, :multipart, :cacert, :ssl_version, :http_version, :automatic_content_encoding, :force_ipv4, :download_byte_limit,
|
|
34
|
-
:low_speed_time, :low_speed_limit, :progress_callback
|
|
34
|
+
:low_speed_time, :low_speed_limit, :progress_callback, :ssl_cert, :ssl_cert_type, :ssl_key, :ssl_key_password
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
attr_reader(*READER_VARS)
|
|
@@ -181,7 +181,8 @@ module Patron
|
|
|
181
181
|
def marshal_dump
|
|
182
182
|
[ @url, @username, @password, @file_name, @proxy, @proxy_type, @insecure,
|
|
183
183
|
@ignore_content_length, @multipart, @action, @timeout, @connect_timeout,
|
|
184
|
-
@max_redirects, @headers, @auth_type, @upload_data, @buffer_size, @cacert
|
|
184
|
+
@max_redirects, @headers, @auth_type, @upload_data, @buffer_size, @cacert,
|
|
185
|
+
@ssl_cert, @ssl_cert_type, @ssl_key, @ssl_key_password ]
|
|
185
186
|
end
|
|
186
187
|
|
|
187
188
|
# Reinstates instance variables from a marshaled representation
|
|
@@ -190,7 +191,8 @@ module Patron
|
|
|
190
191
|
def marshal_load(data)
|
|
191
192
|
@url, @username, @password, @file_name, @proxy, @proxy_type, @insecure,
|
|
192
193
|
@ignore_content_length, @multipart, @action, @timeout, @connect_timeout,
|
|
193
|
-
@max_redirects, @headers, @auth_type, @upload_data, @buffer_size, @cacert
|
|
194
|
+
@max_redirects, @headers, @auth_type, @upload_data, @buffer_size, @cacert,
|
|
195
|
+
@ssl_cert, @ssl_cert_type, @ssl_key, @ssl_key_password = data
|
|
194
196
|
end
|
|
195
197
|
|
|
196
198
|
end
|
data/lib/patron/response.rb
CHANGED
|
@@ -28,6 +28,11 @@ module Patron
|
|
|
28
28
|
# to be a valid charset name, just stored. To check the charset for validity, use #body_decodable?
|
|
29
29
|
attr_reader :charset
|
|
30
30
|
|
|
31
|
+
# @return [Hash{Symbol => Float}, nil] curl's cumulative transfer marks in seconds since the request
|
|
32
|
+
# started: `:namelookup`, `:connect`, `:appconnect`, `:pretransfer`, `:starttransfer`, `:redirect`
|
|
33
|
+
# and `:total`. `nil` when the response was not built from a curl transfer (e.g. a stub).
|
|
34
|
+
attr_reader :timings
|
|
35
|
+
|
|
31
36
|
# Overridden so that the output is shorter and there is no response body printed
|
|
32
37
|
def inspect
|
|
33
38
|
# Avoid spamming the console with the header and body data
|
data/lib/patron/session.rb
CHANGED
|
@@ -67,6 +67,19 @@ module Patron
|
|
|
67
67
|
# @return [String] path to the CA file used for certificate verification, or `nil` if CURL default is used
|
|
68
68
|
attr_accessor :cacert
|
|
69
69
|
|
|
70
|
+
# @return [String] path to the client SSL certificate file, or `nil` if no client certificate is used
|
|
71
|
+
attr_accessor :ssl_cert
|
|
72
|
+
|
|
73
|
+
# @return [String] the client SSL certificate file type: "PEM" (the libcurl default), "DER" or "P12" (PKCS#12)
|
|
74
|
+
attr_accessor :ssl_cert_type
|
|
75
|
+
|
|
76
|
+
# @return [String] path to the PEM private key for the client SSL certificate, if it is not bundled with
|
|
77
|
+
# the certificate itself (as it is with PKCS#12)
|
|
78
|
+
attr_accessor :ssl_key
|
|
79
|
+
|
|
80
|
+
# @return [String] the password for the client SSL certificate private key
|
|
81
|
+
attr_accessor :ssl_key_password
|
|
82
|
+
|
|
70
83
|
# @return [Boolean] whether Content-Range and Content-Length headers should be ignored
|
|
71
84
|
attr_accessor :ignore_content_length
|
|
72
85
|
|
|
@@ -376,6 +389,10 @@ module Patron
|
|
|
376
389
|
req.ssl_version = options.fetch :ssl_version, self.ssl_version
|
|
377
390
|
req.http_version = options.fetch :http_version, self.http_version
|
|
378
391
|
req.cacert = options.fetch :cacert, self.cacert
|
|
392
|
+
req.ssl_cert = options.fetch :ssl_cert, self.ssl_cert
|
|
393
|
+
req.ssl_cert_type = options.fetch :ssl_cert_type, self.ssl_cert_type
|
|
394
|
+
req.ssl_key = options.fetch :ssl_key, self.ssl_key
|
|
395
|
+
req.ssl_key_password = options.fetch :ssl_key_password, self.ssl_key_password
|
|
379
396
|
req.ignore_content_length = options.fetch :ignore_content_length, self.ignore_content_length
|
|
380
397
|
req.buffer_size = options.fetch :buffer_size, self.buffer_size
|
|
381
398
|
req.download_byte_limit = options.fetch :download_byte_limit, self.download_byte_limit
|
data/lib/patron/version.rb
CHANGED
data/patron.gemspec
CHANGED
|
@@ -22,7 +22,8 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
spec.required_rubygems_version = ">=
|
|
25
|
+
spec.required_rubygems_version = ">= 2.5.0"
|
|
26
|
+
spec.required_ruby_version = ">= 2.3"
|
|
26
27
|
|
|
27
28
|
spec.files = `git ls-files -z`.split("\x0")
|
|
28
29
|
spec.bindir = "exe"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Regenerates the client certificate fixtures used by the mutual TLS specs
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
DIR = File.expand_path('../spec/support/certs', __dir__)
|
|
6
|
+
PASSWORD = 'patron'
|
|
7
|
+
NOT_BEFORE = Time.utc(2025, 1, 1)
|
|
8
|
+
NOT_AFTER = Time.utc(2045, 1, 1)
|
|
9
|
+
|
|
10
|
+
def name(cn)
|
|
11
|
+
OpenSSL::X509::Name.parse("/C=US/O=Patron Test Certificate/CN=#{cn}")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def make_ca(cn)
|
|
15
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
16
|
+
cert = OpenSSL::X509::Certificate.new
|
|
17
|
+
cert.version = 2
|
|
18
|
+
cert.serial = 1
|
|
19
|
+
cert.subject = name(cn)
|
|
20
|
+
cert.issuer = cert.subject
|
|
21
|
+
cert.public_key = key.public_key
|
|
22
|
+
cert.not_before = NOT_BEFORE
|
|
23
|
+
cert.not_after = NOT_AFTER
|
|
24
|
+
ef = OpenSSL::X509::ExtensionFactory.new(cert, cert)
|
|
25
|
+
cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true))
|
|
26
|
+
cert.add_extension(ef.create_extension('keyUsage', 'keyCertSign,cRLSign', true))
|
|
27
|
+
cert.add_extension(ef.create_extension('subjectKeyIdentifier', 'hash'))
|
|
28
|
+
cert.sign(key, OpenSSL::Digest::SHA256.new)
|
|
29
|
+
[cert, key]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def make_client(ca, ca_key)
|
|
33
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
34
|
+
cert = OpenSSL::X509::Certificate.new
|
|
35
|
+
cert.version = 2
|
|
36
|
+
cert.serial = 2
|
|
37
|
+
cert.subject = name('patron-test-client')
|
|
38
|
+
cert.issuer = ca.subject
|
|
39
|
+
cert.public_key = key.public_key
|
|
40
|
+
cert.not_before = NOT_BEFORE
|
|
41
|
+
cert.not_after = NOT_AFTER
|
|
42
|
+
ef = OpenSSL::X509::ExtensionFactory.new(ca, cert)
|
|
43
|
+
cert.add_extension(ef.create_extension('basicConstraints', 'CA:FALSE', true))
|
|
44
|
+
cert.add_extension(ef.create_extension('keyUsage', 'digitalSignature,keyEncipherment', true))
|
|
45
|
+
cert.add_extension(ef.create_extension('extendedKeyUsage', 'clientAuth'))
|
|
46
|
+
cert.sign(ca_key, OpenSSL::Digest::SHA256.new)
|
|
47
|
+
[cert, key]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_p12(filename, cert, key)
|
|
51
|
+
p12 = OpenSSL::PKCS12.create(PASSWORD, 'patron-test-client', key, cert, nil, 'PBE-SHA1-3DES', 'PBE-SHA1-3DES')
|
|
52
|
+
File.binwrite(File.join(DIR, filename), p12.to_der)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
ca, ca_key = make_ca('Patron Test Client CA')
|
|
56
|
+
client, client_key = make_client(ca, ca_key)
|
|
57
|
+
File.write(File.join(DIR, 'client_ca.pem'), ca.to_pem)
|
|
58
|
+
File.write(File.join(DIR, 'client_cert.pem'), client.to_pem)
|
|
59
|
+
File.write(File.join(DIR, 'client_key.pem'), client_key.export(OpenSSL::Cipher.new('aes-256-cbc'), PASSWORD))
|
|
60
|
+
write_p12('client.p12', client, client_key)
|
|
61
|
+
|
|
62
|
+
# Same subject as the real client certificate, but signed by a CA the test server does not trust
|
|
63
|
+
rogue_ca, rogue_ca_key = make_ca('Patron Rogue CA')
|
|
64
|
+
rogue, rogue_key = make_client(rogue_ca, rogue_ca_key)
|
|
65
|
+
write_p12('rogue_client.p12', rogue, rogue_key)
|
data/spec/request_spec.rb
CHANGED
|
@@ -74,4 +74,16 @@ describe Patron::Request do
|
|
|
74
74
|
it "should be able to serialize and deserialize itself" do
|
|
75
75
|
expect(Marshal.load(Marshal.dump(@request))).to eql(@request)
|
|
76
76
|
end
|
|
77
|
+
|
|
78
|
+
it "should preserve the client certificate settings when serialized" do
|
|
79
|
+
@request.ssl_cert = "client.pem"
|
|
80
|
+
@request.ssl_cert_type = "PEM"
|
|
81
|
+
@request.ssl_key = "client_key.pem"
|
|
82
|
+
@request.ssl_key_password = "secret"
|
|
83
|
+
restored = Marshal.load(Marshal.dump(@request))
|
|
84
|
+
expect(restored.ssl_cert).to eq("client.pem")
|
|
85
|
+
expect(restored.ssl_cert_type).to eq("PEM")
|
|
86
|
+
expect(restored.ssl_key).to eq("client_key.pem")
|
|
87
|
+
expect(restored.ssl_key_password).to eq("secret")
|
|
88
|
+
end
|
|
77
89
|
end
|
data/spec/session_spec.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'yaml'
|
|
|
4
4
|
require 'base64'
|
|
5
5
|
require 'fileutils'
|
|
6
6
|
require 'securerandom'
|
|
7
|
+
require 'timeout'
|
|
7
8
|
|
|
8
9
|
describe Patron::Session do
|
|
9
10
|
|
|
@@ -67,6 +68,16 @@ describe Patron::Session do
|
|
|
67
68
|
unescaped = @session.unescape(escaped)
|
|
68
69
|
expect(unescaped).to be == string
|
|
69
70
|
end
|
|
71
|
+
|
|
72
|
+
it "unescapes a %00 to a NUL byte instead of cutting the string there" do
|
|
73
|
+
expect(@session.unescape("a%00b").b).to be == "a\0b".b
|
|
74
|
+
expect(@session.escape("a\0b")).to be == "a%00b"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "rejects a file path with an embedded NUL byte" do
|
|
78
|
+
expect { @session.enable_debug("/tmp/x\0y") }.to raise_error(ArgumentError)
|
|
79
|
+
expect { @session.handle_cookies("/tmp/x\0y") }.to raise_error(ArgumentError)
|
|
80
|
+
end
|
|
70
81
|
end
|
|
71
82
|
|
|
72
83
|
it "should raise an error when passed an invalid action" do
|
|
@@ -84,6 +95,19 @@ describe Patron::Session do
|
|
|
84
95
|
expect(body.request_method).to be == "GET"
|
|
85
96
|
end
|
|
86
97
|
|
|
98
|
+
it "should expose curl's cumulative transfer timings" do
|
|
99
|
+
timings = @session.get("/test").timings
|
|
100
|
+
|
|
101
|
+
expect(timings.keys).to contain_exactly(
|
|
102
|
+
:namelookup, :connect, :appconnect, :pretransfer, :starttransfer, :redirect, :total
|
|
103
|
+
)
|
|
104
|
+
expect(timings.values).to all(be_a(Float).and(be >= 0))
|
|
105
|
+
expect(timings[:namelookup]).to be <= timings[:connect]
|
|
106
|
+
expect(timings[:connect]).to be <= timings[:starttransfer]
|
|
107
|
+
expect(timings[:starttransfer]).to be <= timings[:total]
|
|
108
|
+
expect(timings[:total]).to be > 0
|
|
109
|
+
end
|
|
110
|
+
|
|
87
111
|
it "should use full base url" do
|
|
88
112
|
@session.base_url = "http://localhost:9001/api/v1"
|
|
89
113
|
response = @session.get("/test")
|
|
@@ -170,6 +194,14 @@ describe Patron::Session do
|
|
|
170
194
|
# on 1.9 it will be Patron::PartialFileError
|
|
171
195
|
end
|
|
172
196
|
|
|
197
|
+
it "accepts a download body limit above 2 GiB and timeouts given as Floats" do
|
|
198
|
+
@session.download_byte_limit = 5 * 1024 * 1024 * 1024
|
|
199
|
+
@session.dns_cache_timeout = 2.5
|
|
200
|
+
@session.low_speed_time = 2.5
|
|
201
|
+
@session.low_speed_limit = 1.5
|
|
202
|
+
expect(@session.get("/test").status).to be == 200
|
|
203
|
+
end
|
|
204
|
+
|
|
173
205
|
it "should not send the user-agent if it has been deleted from headers" do
|
|
174
206
|
@session.headers.delete 'User-Agent'
|
|
175
207
|
response = @session.get("/test")
|
|
@@ -219,7 +251,7 @@ describe Patron::Session do
|
|
|
219
251
|
expect {@session.get("/timeout?millis=400")}.to raise_error(Patron::TimeoutError)
|
|
220
252
|
end
|
|
221
253
|
|
|
222
|
-
it "should raise an exception on timeout when reading from a slow resource and low speed limit is set" do
|
|
254
|
+
it "should raise an exception on timeout when reading from a slow resource and low speed limit is set", :timing do
|
|
223
255
|
@session.timeout = 10 # Greater than the low_speed_time
|
|
224
256
|
@session.low_speed_time = 2
|
|
225
257
|
@session.low_speed_limit = 10
|
|
@@ -233,7 +265,7 @@ describe Patron::Session do
|
|
|
233
265
|
expect(delta_s).to be_within(0.1).of(2).or be_within(0.1).of(3)
|
|
234
266
|
end
|
|
235
267
|
|
|
236
|
-
it "is able to terminate the thread that is running a slow request using Thread#kill (uses the custom unblock)" do
|
|
268
|
+
it "is able to terminate the thread that is running a slow request using Thread#kill (uses the custom unblock)", :timing do
|
|
237
269
|
t = Thread.new do
|
|
238
270
|
session = Patron::Session.new
|
|
239
271
|
session.timeout = 10 # Greater than the sleep duration
|
|
@@ -253,7 +285,7 @@ describe Patron::Session do
|
|
|
253
285
|
expect(delta_s).to be_within(0.1).of(2).or be_within(0.1).of(3)
|
|
254
286
|
end
|
|
255
287
|
|
|
256
|
-
it "is able to terminate the process that is running a slow request with SIGINT" do
|
|
288
|
+
it "is able to terminate the process that is running a slow request with SIGINT", :timing do
|
|
257
289
|
pid = Process.fork do
|
|
258
290
|
trap('SIGINT') do
|
|
259
291
|
exit # exit the process
|
|
@@ -292,6 +324,64 @@ describe Patron::Session do
|
|
|
292
324
|
expect(callback_args).not_to be_empty
|
|
293
325
|
end
|
|
294
326
|
|
|
327
|
+
it "propagates an exception raised in the progress callback and keeps the session usable" do
|
|
328
|
+
session = Patron::Session.new
|
|
329
|
+
session.timeout = 10
|
|
330
|
+
session.base_url = "http://localhost:9001"
|
|
331
|
+
boom = Class.new(StandardError)
|
|
332
|
+
session.progress_callback = Proc.new { raise boom, "from the callback" }
|
|
333
|
+
|
|
334
|
+
expect { session.get("/slow") }.to raise_error(boom, "from the callback")
|
|
335
|
+
|
|
336
|
+
session.progress_callback = nil
|
|
337
|
+
expect(session.get("/test").status).to be == 200
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it "lets a throw from the progress callback unwind to the catch around the request" do
|
|
341
|
+
# Timeout.timeout on Rubies before 3.1 unwinds with throw rather than raise
|
|
342
|
+
session = Patron::Session.new
|
|
343
|
+
session.timeout = 10
|
|
344
|
+
session.base_url = "http://localhost:9001"
|
|
345
|
+
session.progress_callback = Proc.new { throw :stop_right_there, :thrown }
|
|
346
|
+
|
|
347
|
+
expect(catch(:stop_right_there) { session.get("/slow") }).to be == :thrown
|
|
348
|
+
|
|
349
|
+
session.progress_callback = nil
|
|
350
|
+
expect(session.get("/test").status).to be == 200
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it "can be interrupted with Timeout.timeout while a progress callback is set", :timing do
|
|
354
|
+
session = Patron::Session.new
|
|
355
|
+
session.timeout = 10
|
|
356
|
+
session.base_url = "http://localhost:9001"
|
|
357
|
+
session.progress_callback = Proc.new { |*| }
|
|
358
|
+
|
|
359
|
+
started = Time.now.to_f
|
|
360
|
+
expect { Timeout.timeout(1) { session.get("/slow") } }.to raise_error(Timeout::Error)
|
|
361
|
+
expect(Time.now.to_f - started).to be < 3
|
|
362
|
+
|
|
363
|
+
session.progress_callback = nil
|
|
364
|
+
expect(session.get("/test").status).to be == 200
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it "raises the right exception class after the heap has been compacted" do
|
|
368
|
+
# The exception classes are looked up from C once at load time; if they
|
|
369
|
+
# were not registered with the GC, compaction would move them away
|
|
370
|
+
skip "no compaction on this Ruby" unless GC.respond_to?(:verify_compaction_references)
|
|
371
|
+
begin
|
|
372
|
+
begin
|
|
373
|
+
GC.verify_compaction_references(expand_heap: true, toward: :empty)
|
|
374
|
+
rescue ArgumentError # expand_heap: is double_heap: on Rubies before 3.2
|
|
375
|
+
GC.verify_compaction_references(double_heap: true, toward: :empty)
|
|
376
|
+
end
|
|
377
|
+
rescue NotImplementedError
|
|
378
|
+
skip "compaction is not supported on this platform"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
session = Patron::Session.new
|
|
382
|
+
expect { session.get("http://localhost:1/") }.to raise_error(Patron::ConnectionFailed)
|
|
383
|
+
end
|
|
384
|
+
|
|
295
385
|
it "should follow redirects by default" do
|
|
296
386
|
@session.max_redirects = 1
|
|
297
387
|
response = @session.get("/redirect")
|
|
@@ -405,6 +495,26 @@ describe Patron::Session do
|
|
|
405
495
|
expect(body.header['content-length']).to be == [data.size.to_s]
|
|
406
496
|
end
|
|
407
497
|
|
|
498
|
+
it "keeps a request body built by #to_s alive for the duration of the transfer" do
|
|
499
|
+
# The body only exists as the return value of #to_s, and libcurl reads it
|
|
500
|
+
# while the GVL is released - GC.stress makes that window reliable
|
|
501
|
+
payload = Random.new.bytes(64 * 1024)
|
|
502
|
+
body_object = Object.new
|
|
503
|
+
body_object.define_singleton_method(:to_s) { payload + '' }
|
|
504
|
+
|
|
505
|
+
begin
|
|
506
|
+
GC.stress = true
|
|
507
|
+
[:post, :put, :delete].each do |verb|
|
|
508
|
+
response = @session.request(verb, "/test", {}, :data => body_object)
|
|
509
|
+
body = yaml_load(response.body)
|
|
510
|
+
expect(body.request_method).to be == verb.to_s.upcase
|
|
511
|
+
expect(body.body.b).to be == payload
|
|
512
|
+
end
|
|
513
|
+
ensure
|
|
514
|
+
GC.stress = false
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
408
518
|
it "should raise when no data is provided to :put" do
|
|
409
519
|
expect { @session.put("/test", nil) }.to raise_error(ArgumentError)
|
|
410
520
|
end
|
|
@@ -419,10 +529,20 @@ describe Patron::Session do
|
|
|
419
529
|
expect { @session.put_file("/test", nil) }.to raise_error(ArgumentError)
|
|
420
530
|
end
|
|
421
531
|
|
|
422
|
-
it "should
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
532
|
+
it "should upload a file with :put using chunked transfer-encoding" do
|
|
533
|
+
# Transfer-Encoding is a hop-by-hop header (RFC 7230 3.3.1): a conformant server
|
|
534
|
+
# decodes the chunked framing and never exposes the header to the application, so
|
|
535
|
+
# we capture the raw request to assert how Patron actually frames the upload.
|
|
536
|
+
captured = capture_request do |url|
|
|
537
|
+
session = Patron::Session.new
|
|
538
|
+
session.base_url = url
|
|
539
|
+
session.timeout = 5
|
|
540
|
+
session.put_file("/test", "LICENSE")
|
|
541
|
+
end
|
|
542
|
+
expect(captured.request_line).to start_with("PUT ")
|
|
543
|
+
expect(captured.headers["transfer-encoding"]).to be == "chunked"
|
|
544
|
+
expect(captured.headers).to_not have_key("content-length")
|
|
545
|
+
expect(captured.body.bytesize).to be == File.size("LICENSE")
|
|
426
546
|
end
|
|
427
547
|
|
|
428
548
|
it "should call to_s on the data being uploaded via POST if it is not already a String" do
|
|
@@ -465,14 +585,36 @@ describe Patron::Session do
|
|
|
465
585
|
expect(body.request_method).to be == "POST"
|
|
466
586
|
end
|
|
467
587
|
|
|
588
|
+
it "sends multipart field names and binary values intact" do
|
|
589
|
+
# A Symbol key gets stringified into a temporary libcurl must copy,
|
|
590
|
+
# and a value with NUL bytes must survive past the first one
|
|
591
|
+
value = "before\0middle\0after".b
|
|
592
|
+
response = @session.post_multipart("/test", { :sym_key => value }, { :test_file => "LICENSE" })
|
|
593
|
+
body = yaml_load(response.body)
|
|
594
|
+
raw = body.body.b
|
|
595
|
+
expect(raw).to include('name="sym_key"')
|
|
596
|
+
expect(raw).to include(value)
|
|
597
|
+
expect(raw).to include('name="test_file"; filename="LICENSE"')
|
|
598
|
+
expect(raw).to include(File.read("LICENSE").b)
|
|
599
|
+
end
|
|
600
|
+
|
|
468
601
|
it "should raise when no file is provided to :post" do
|
|
469
602
|
expect { @session.post_file("/test", nil) }.to raise_error(ArgumentError)
|
|
470
603
|
end
|
|
471
604
|
|
|
472
|
-
it "should
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
605
|
+
it "should upload a file with :post using chunked transfer-encoding" do
|
|
606
|
+
# See the :put counterpart above: Transfer-Encoding is hop-by-hop and invisible
|
|
607
|
+
# to the application, so we capture the raw request to assert the framing.
|
|
608
|
+
captured = capture_request do |url|
|
|
609
|
+
session = Patron::Session.new
|
|
610
|
+
session.base_url = url
|
|
611
|
+
session.timeout = 5
|
|
612
|
+
session.post_file("/test", "LICENSE")
|
|
613
|
+
end
|
|
614
|
+
expect(captured.request_line).to start_with("POST ")
|
|
615
|
+
expect(captured.headers["transfer-encoding"]).to be == "chunked"
|
|
616
|
+
expect(captured.headers).to_not have_key("content-length")
|
|
617
|
+
expect(captured.body.bytesize).to be == File.size("LICENSE")
|
|
476
618
|
end
|
|
477
619
|
|
|
478
620
|
it "should pass credentials as http basic auth" do
|
|
@@ -526,6 +668,31 @@ describe Patron::Session do
|
|
|
526
668
|
expect { @session.handle_cookies("/trash/clash/foo") }.to raise_error(ArgumentError)
|
|
527
669
|
end
|
|
528
670
|
|
|
671
|
+
it "refuses to run two requests on one Session from two threads at the same time" do
|
|
672
|
+
session = Patron::Session.new
|
|
673
|
+
session.timeout = 10
|
|
674
|
+
session.base_url = "http://localhost:9001"
|
|
675
|
+
|
|
676
|
+
outcomes = Array.new(2) do
|
|
677
|
+
Thread.new do
|
|
678
|
+
begin
|
|
679
|
+
session.get("/slow")
|
|
680
|
+
:completed
|
|
681
|
+
rescue Patron::Error => e
|
|
682
|
+
e
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
end.map(&:value)
|
|
686
|
+
|
|
687
|
+
refused = outcomes.grep(Patron::Error)
|
|
688
|
+
expect(refused.size).to be == 1
|
|
689
|
+
expect(refused.first.message).to include("another thread")
|
|
690
|
+
expect(outcomes - refused).to be == [:completed]
|
|
691
|
+
|
|
692
|
+
# ...and is free again once the in-flight request is done
|
|
693
|
+
expect(session.get("/test").status).to be == 200
|
|
694
|
+
end
|
|
695
|
+
|
|
529
696
|
it "should work with multiple threads" do
|
|
530
697
|
threads = []
|
|
531
698
|
3.times do
|
data/spec/session_ssl_spec.rb
CHANGED
|
@@ -18,6 +18,8 @@ describe Patron::Session do
|
|
|
18
18
|
@session = Patron::Session.new
|
|
19
19
|
@session.base_url = "https://localhost:9043"
|
|
20
20
|
@session.insecure = true
|
|
21
|
+
# The first TLS handshake takes a few seconds when both ends run under valgrind
|
|
22
|
+
@session.connect_timeout = 10
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
it "should retrieve a url with :get" do
|
|
@@ -161,10 +163,21 @@ describe Patron::Session do
|
|
|
161
163
|
expect { @session.put_file("/test", nil) }.to raise_error(ArgumentError)
|
|
162
164
|
end
|
|
163
165
|
|
|
164
|
-
it "should
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
it "should upload a file with :put using chunked transfer-encoding" do
|
|
167
|
+
# Transfer-Encoding is a hop-by-hop header (RFC 7230 3.3.1): a conformant server
|
|
168
|
+
# decodes the chunked framing and never exposes the header to the application, so
|
|
169
|
+
# we capture the raw request to assert how Patron actually frames the upload.
|
|
170
|
+
captured = capture_request(ssl: true) do |url|
|
|
171
|
+
session = Patron::Session.new
|
|
172
|
+
session.base_url = url
|
|
173
|
+
session.insecure = true
|
|
174
|
+
session.timeout = 5
|
|
175
|
+
session.put_file("/test", "LICENSE")
|
|
176
|
+
end
|
|
177
|
+
expect(captured.request_line).to start_with("PUT ")
|
|
178
|
+
expect(captured.headers["transfer-encoding"]).to be == "chunked"
|
|
179
|
+
expect(captured.headers).to_not have_key("content-length")
|
|
180
|
+
expect(captured.body.bytesize).to be == File.size("LICENSE")
|
|
168
181
|
end
|
|
169
182
|
|
|
170
183
|
it "should upload data with :post" do
|
|
@@ -204,10 +217,20 @@ describe Patron::Session do
|
|
|
204
217
|
expect { @session.post_file("/test", nil) }.to raise_error(ArgumentError)
|
|
205
218
|
end
|
|
206
219
|
|
|
207
|
-
it "should
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
220
|
+
it "should upload a file with :post using chunked transfer-encoding" do
|
|
221
|
+
# See the :put counterpart above: Transfer-Encoding is hop-by-hop and invisible
|
|
222
|
+
# to the application, so we capture the raw request to assert the framing.
|
|
223
|
+
captured = capture_request(ssl: true) do |url|
|
|
224
|
+
session = Patron::Session.new
|
|
225
|
+
session.base_url = url
|
|
226
|
+
session.insecure = true
|
|
227
|
+
session.timeout = 5
|
|
228
|
+
session.post_file("/test", "LICENSE")
|
|
229
|
+
end
|
|
230
|
+
expect(captured.request_line).to start_with("POST ")
|
|
231
|
+
expect(captured.headers["transfer-encoding"]).to be == "chunked"
|
|
232
|
+
expect(captured.headers).to_not have_key("content-length")
|
|
233
|
+
expect(captured.body.bytesize).to be == File.size("LICENSE")
|
|
211
234
|
end
|
|
212
235
|
|
|
213
236
|
it "should handle cookies if set" do
|
|
@@ -310,4 +333,99 @@ describe Patron::Session do
|
|
|
310
333
|
end
|
|
311
334
|
end
|
|
312
335
|
|
|
336
|
+
describe 'with a server that requires a client certificate' do
|
|
337
|
+
let(:certs) { File.join(__dir__, 'support', 'certs') }
|
|
338
|
+
|
|
339
|
+
# libcurl's GnuTLS backend reports any client certificate it can't load as CURLE_SSL_CONNECT_ERROR
|
|
340
|
+
let(:gnutls) { Patron.libcurl_version.include?('GnuTLS') }
|
|
341
|
+
|
|
342
|
+
def refused_by_server
|
|
343
|
+
raise_error { |e|
|
|
344
|
+
expect(e).to be_kind_of(Patron::Error)
|
|
345
|
+
expect(e).not_to be_kind_of(Patron::ClientCertificateError)
|
|
346
|
+
expect(e).not_to be_kind_of(Patron::TimeoutError)
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
before(:each) do
|
|
351
|
+
@session.base_url = "https://localhost:9044"
|
|
352
|
+
@session.insecure = false
|
|
353
|
+
@session.cacert = File.join(certs, 'cacert.pem')
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
it "presents a PKCS#12 client certificate" do
|
|
357
|
+
@session.ssl_cert = File.join(certs, 'client.p12')
|
|
358
|
+
@session.ssl_cert_type = 'P12'
|
|
359
|
+
@session.ssl_key_password = 'patron'
|
|
360
|
+
response = @session.get("/peercert")
|
|
361
|
+
expect(response.status).to be == 200
|
|
362
|
+
expect(response.body).to include('CN=patron-test-client')
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it "presents a PEM client certificate with a separate encrypted key" do
|
|
366
|
+
@session.ssl_cert = File.join(certs, 'client_cert.pem')
|
|
367
|
+
@session.ssl_cert_type = 'PEM'
|
|
368
|
+
@session.ssl_key = File.join(certs, 'client_key.pem')
|
|
369
|
+
@session.ssl_key_password = 'patron'
|
|
370
|
+
response = @session.get("/peercert")
|
|
371
|
+
expect(response.status).to be == 200
|
|
372
|
+
expect(response.body).to include('CN=patron-test-client')
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it "accepts the client certificate as a per-request option" do
|
|
376
|
+
response = @session.request(:get, "/peercert", {},
|
|
377
|
+
ssl_cert: File.join(certs, 'client.p12'), ssl_cert_type: 'P12', ssl_key_password: 'patron')
|
|
378
|
+
expect(response.body).to include('CN=patron-test-client')
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it "accepts Pathnames for the certificate and key and a Symbol for the type" do
|
|
382
|
+
@session.cacert = Pathname(certs).join('cacert.pem')
|
|
383
|
+
@session.ssl_cert = Pathname(certs).join('client_cert.pem')
|
|
384
|
+
@session.ssl_cert_type = :PEM
|
|
385
|
+
@session.ssl_key = Pathname(certs).join('client_key.pem')
|
|
386
|
+
@session.ssl_key_password = 'patron'
|
|
387
|
+
response = @session.get("/peercert")
|
|
388
|
+
expect(response.body).to include('CN=patron-test-client')
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it "does not leak a failed request's options into the next one" do
|
|
392
|
+
@session.ssl_cert = File.join(certs, 'client.p12')
|
|
393
|
+
@session.ssl_cert_type = 'P12'
|
|
394
|
+
@session.ssl_key_password = 'patron'
|
|
395
|
+
expect {
|
|
396
|
+
@session.request(:get, "/test", {'X-Leaked' => 'yes'}, ssl_key_password: 1234)
|
|
397
|
+
}.to raise_error(TypeError)
|
|
398
|
+
|
|
399
|
+
body = yaml_load(@session.get("/test").body)
|
|
400
|
+
expect(body.header).not_to have_key('x-leaked')
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
it "raises ClientCertificateError when the key password is wrong" do
|
|
404
|
+
skip "GnuTLS does not tell this apart from a handshake failure" if gnutls
|
|
405
|
+
@session.ssl_cert = File.join(certs, 'client.p12')
|
|
406
|
+
@session.ssl_cert_type = 'P12'
|
|
407
|
+
@session.ssl_key_password = 'wrong'
|
|
408
|
+
expect { @session.get("/peercert") }.to raise_error(Patron::ClientCertificateError)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it "raises ClientCertificateError when the certificate file does not exist" do
|
|
412
|
+
skip "GnuTLS does not tell this apart from a handshake failure" if gnutls
|
|
413
|
+
@session.ssl_cert = File.join(certs, 'nonexistent.p12')
|
|
414
|
+
@session.ssl_cert_type = 'P12'
|
|
415
|
+
@session.ssl_key_password = 'patron'
|
|
416
|
+
expect { @session.get("/peercert") }.to raise_error(Patron::ClientCertificateError)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
it "fails when the server does not trust the client certificate" do
|
|
420
|
+
@session.ssl_cert = File.join(certs, 'rogue_client.p12')
|
|
421
|
+
@session.ssl_cert_type = 'P12'
|
|
422
|
+
@session.ssl_key_password = 'patron'
|
|
423
|
+
expect { @session.get("/peercert") }.to refused_by_server
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
it "fails when no client certificate is presented" do
|
|
427
|
+
# Depending on the TLS version the server rejects us either during or right after the handshake
|
|
428
|
+
expect { @session.get("/peercert") }.to refused_by_server
|
|
429
|
+
end
|
|
430
|
+
end
|
|
313
431
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -24,11 +24,17 @@ sleep 0.1 # Don't interfere the start up output of two processes.
|
|
|
24
24
|
|
|
25
25
|
https_server = Fork.new { PatronTestServer.start(true, 9043) }
|
|
26
26
|
|
|
27
|
+
mtls_server = Fork.new do
|
|
28
|
+
PatronTestServer.start(true, 9044, client_ca: File.join(__dir__, 'support', 'certs', 'client_ca.pem'))
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
RSpec.configure do |c|
|
|
28
32
|
c.after(:suite) do
|
|
29
33
|
http_server.kill("TERM")
|
|
30
34
|
https_server.kill("TERM")
|
|
35
|
+
mtls_server.kill("TERM")
|
|
31
36
|
http_server.wait
|
|
32
37
|
https_server.wait
|
|
38
|
+
mtls_server.wait
|
|
33
39
|
end
|
|
34
40
|
end
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIDWzCCAkOgAwIBAgIBATANBgkqhkiG9w0BAQsFADBPMQswCQYDVQQGEwJVUzEg
|
|
3
|
+
MB4GA1UECgwXUGF0cm9uIFRlc3QgQ2VydGlmaWNhdGUxHjAcBgNVBAMMFVBhdHJv
|
|
4
|
+
biBUZXN0IENsaWVudCBDQTAeFw0yNTAxMDEwMDAwMDBaFw00NTAxMDEwMDAwMDBa
|
|
5
|
+
ME8xCzAJBgNVBAYTAlVTMSAwHgYDVQQKDBdQYXRyb24gVGVzdCBDZXJ0aWZpY2F0
|
|
6
|
+
ZTEeMBwGA1UEAwwVUGF0cm9uIFRlc3QgQ2xpZW50IENBMIIBIjANBgkqhkiG9w0B
|
|
7
|
+
AQEFAAOCAQ8AMIIBCgKCAQEA1iqOoIm5gW9MP0onsmSuOdlAdFZkP5r7rRSn+100
|
|
8
|
+
5ceHDpRdaR+k8ZIoi7noesryrf7ibZL+9VzZEBGlCcUGEIjQjL/BHsHa5vLfAJnT
|
|
9
|
+
0zbeQFk5MjGZOUPXC33CgidsxF3DsRFDNmOqGBiCf3A/ae/VOMDEyv6aFdKc6JQC
|
|
10
|
+
OoudnCGzVNJuLIa+/yGx9HhIjobbYGuZ7gwLjJk3HW9WixTupUKJME9f7cTIPW+k
|
|
11
|
+
ahw7ZQGzIUHejqy8cDnmBRYNpZ8NU5oTuhr5NFDWhOSGfM70NYFlGDifI2kMPhdc
|
|
12
|
+
s4nEUajG1b3NLBfKMM1HioqG2ivMQv6WNRWGJCn3sdJEXQIDAQABo0IwQDAPBgNV
|
|
13
|
+
HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUd1Pmdua66Xyn
|
|
14
|
+
qKIbXkVUC389WA8wDQYJKoZIhvcNAQELBQADggEBAD+6HGi8mdkHDnegma3AvQ0K
|
|
15
|
+
4EoTtKljui93zVhsaXpycr/UtOe7p6jBApBdh93up6Fx9cYu2OtTLh0t9rnLXBWD
|
|
16
|
+
GZQt1jajEwvdRg2ncFfoWkxeftUttd5uTVMN6Apv3nGw3VXZnNmVaRyZnGG5j1JO
|
|
17
|
+
lnN94DfE37MyePhuFCDd5RZXegIsel62AMPJNV/RtwPf5b7T59f/Ol1oTMkaWEtM
|
|
18
|
+
wYU/2cFTqGjtqeTv7IewTERbqBxJcvIU3pNVHthq/jyiiuaLeAybbx1AZoI3fkY8
|
|
19
|
+
FO1LAtZx1/xZwjqtSuTZn8CbryLoMetpvVqsUlI72FxStQf5JAFjO+H4kWEEORU=
|
|
20
|
+
-----END CERTIFICATE-----
|