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.
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
@@ -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 = data
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
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Patron
2
- VERSION = "0.13.4"
2
+ VERSION = "0.14.0"
3
3
  end
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 = ">= 1.2.0"
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 use chunked encoding when uploading a file with :put" do
423
- response = @session.put_file("/test", "LICENSE")
424
- body = yaml_load(response.body)
425
- expect(body.header['transfer-encoding'].first).to be == "chunked"
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 use chunked encoding when uploading a file with :post" do
473
- response = @session.post_file("/test", "LICENSE")
474
- body = yaml_load(response.body)
475
- expect(body.header['transfer-encoding'].first).to be == "chunked"
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
@@ -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 use chunked encoding when uploading a file with :put" do
165
- response = @session.put_file("/test", "LICENSE")
166
- body = yaml_load(response.body)
167
- expect(body.header['transfer-encoding'].first).to be == "chunked"
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 use chunked encoding when uploading a file with :post" do
208
- response = @session.post_file("/test", "LICENSE")
209
- body = yaml_load(response.body)
210
- expect(body.header['transfer-encoding'].first).to be == "chunked"
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-----