chef-api 0.4.0 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 80b62ceb2d3f3e1b7046d2115d6b741bc983499b
4
- data.tar.gz: ade937778aea1f66776c212918e73565ed5f1bd1
3
+ metadata.gz: f0f63c17b624c48909ad16a57e3aa8c104e5d8ca
4
+ data.tar.gz: 609d4327031d33d9816b1dc071e7df6ab70884f6
5
5
  SHA512:
6
- metadata.gz: ebb178741c859052b3762e52ec0d9da19b6b9bb9df52099319e028813e03b776e1ca8401dc9506bcd65a3be8a683e631c0acc89da3d1090a98a0e549e9d16b87
7
- data.tar.gz: bd0cb8413f6add6ddac6c2f60d38a68b87d63e905a20eb2e92b36a77b7afd61d204de15242be4287e8e3d9717c38034b63fc664eae086f7823c9444281552422
6
+ metadata.gz: fa907ef2a5761997afc8a0c6b0a07f68d287f775b6c6ae3946be2ee52797cbfe26c023640d87662d9309457dc772db43e3d4cf1c1793e3111c679b67247e8bff
7
+ data.tar.gz: df493cd920ea8a6ab12b8d73e15c8fe4eceb8f1c4a7c339dbf1dc4c2be9c521b8f3e023ddfe80668120257c707e95995f6a9ca31e506d3c7f2a6f41ed4edd247
@@ -11,4 +11,4 @@ branches:
11
11
  only:
12
12
  - master
13
13
 
14
- script: bundle exec rspec --color --format progress
14
+ script: bundle exec rake
@@ -1,6 +1,13 @@
1
1
  ChefAPI Changelog
2
2
  =================
3
3
 
4
+ v0.4.1 (2014-07-07)
5
+ -------------------
6
+ - Remove dependency on mixlib-authentication
7
+ - Fix a bug where Content-Type headers were not sent properly
8
+ - Switch to rake for test running
9
+ - Improve test coverage with fixtures
10
+
4
11
  v0.4.0 (2014-07-05)
5
12
  -------------------
6
13
  - Support multipart POST
data/Rakefile CHANGED
@@ -1 +1,11 @@
1
1
  require 'bundler/gem_tasks'
2
+
3
+ require 'rspec/core/rake_task'
4
+ RSpec::Core::RakeTask.new do |t|
5
+ t.rspec_opts = [
6
+ '--color',
7
+ '--format progress',
8
+ ].join(' ')
9
+ end
10
+
11
+ task default: :spec
@@ -20,7 +20,6 @@ Gem::Specification.new do |spec|
20
20
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
21
21
  spec.require_paths = ['lib']
22
22
 
23
- spec.add_dependency 'logify', '~> 0.1'
24
- spec.add_dependency 'mixlib-authentication', '~> 1.3'
25
- spec.add_dependency 'mime-types', '~> 2.3'
23
+ spec.add_dependency 'logify', '~> 0.1'
24
+ spec.add_dependency 'mime-types', '~> 2.3'
26
25
  end
@@ -7,12 +7,14 @@ require 'chef-api/version'
7
7
  JSON.create_id = nil
8
8
 
9
9
  module ChefAPI
10
+ autoload :Authentication, 'chef-api/authentication'
10
11
  autoload :Boolean, 'chef-api/boolean'
11
12
  autoload :Configurable, 'chef-api/configurable'
12
13
  autoload :Connection, 'chef-api/connection'
13
14
  autoload :Defaults, 'chef-api/defaults'
14
15
  autoload :Error, 'chef-api/errors'
15
16
  autoload :ErrorCollection, 'chef-api/error_collection'
17
+ autoload :Multipart, 'chef-api/multipart'
16
18
  autoload :Resource, 'chef-api/resource'
17
19
  autoload :Schema, 'chef-api/schema'
18
20
  autoload :Util, 'chef-api/util'
@@ -0,0 +1,298 @@
1
+ require 'base64'
2
+ require 'digest'
3
+ require 'openssl'
4
+ require 'time'
5
+
6
+ #
7
+ # DEBUG steps:
8
+ #
9
+ # check .chomp
10
+ #
11
+
12
+ module ChefAPI
13
+ class Authentication
14
+ include Logify
15
+
16
+ # @todo: Enable this in the future when Mixlib::Authentication supports
17
+ # signing the full request body instead of just the uploaded file parameter.
18
+ SIGN_FULL_BODY = false
19
+
20
+ SIGNATURE = 'algorithm=sha1;version=1.0;'.freeze
21
+
22
+ # Headers
23
+ X_OPS_SIGN = 'X-Ops-Sign'.freeze
24
+ X_OPS_USERID = 'X-Ops-Userid'.freeze
25
+ X_OPS_TIMESTAMP = 'X-Ops-Timestamp'.freeze
26
+ X_OPS_CONTENT_HASH = 'X-Ops-Content-Hash'.freeze
27
+ X_OPS_AUTHORIZATION = 'X-Ops-Authorization'.freeze
28
+
29
+ class << self
30
+ #
31
+ # Create a new signing object from the given options. All options are
32
+ # required.
33
+ #
34
+ # @see (#initialize)
35
+ #
36
+ # @option options [String] :user
37
+ # @option options [String, OpenSSL::PKey::RSA] :key
38
+ # @option options [String, Symbol] verb
39
+ # @option options [String] :path
40
+ # @option options [String, IO] :body
41
+ #
42
+ def from_options(options = {})
43
+ user = options.fetch(:user)
44
+ key = options.fetch(:key)
45
+ verb = options.fetch(:verb)
46
+ path = options.fetch(:path)
47
+ body = options.fetch(:body)
48
+
49
+ new(user, key, verb, path, body)
50
+ end
51
+ end
52
+
53
+ #
54
+ # Create a new Authentication object for signing. Creating an instance will
55
+ # not run any validations or perform any operations (this is on purpose).
56
+ #
57
+ # @param [String] user
58
+ # the username/client/user of the user to sign the request. In Hosted
59
+ # Chef land, this is your "client". In Supermarket land, this is your
60
+ # "username".
61
+ # @param [String, OpenSSL::PKey::RSA] key
62
+ # the path to a private key on disk, the raw private key (as a String),
63
+ # or the raw private key (as an OpenSSL::PKey::RSA instance)
64
+ # @param [Symbol, String] verb
65
+ # the verb for the request (e.g. +:get+)
66
+ # @param [String] path
67
+ # the "path" part of the URI (e.g. +/path/to/resource+)
68
+ # @param [String, IO] body
69
+ # the body to sign for the request, as a raw string or an IO object to be
70
+ # read in chunks
71
+ #
72
+ def initialize(user, key, verb, path, body)
73
+ @user = user
74
+ @key = key
75
+ @verb = verb
76
+ @path = path
77
+ @body = body
78
+ end
79
+
80
+ #
81
+ # The fully-qualified headers for this authentication object of the form:
82
+ #
83
+ # {
84
+ # 'X-Ops-Sign' => 'algorithm=sha1;version=1.1',
85
+ # 'X-Ops-Userid' => 'sethvargo',
86
+ # 'X-Ops-Timestamp' => '2014-07-07T02:17:15Z',
87
+ # 'X-Ops-Content-Hash' => '...',
88
+ # 'x-Ops-Authorization-1' => '...'
89
+ # 'x-Ops-Authorization-2' => '...'
90
+ # 'x-Ops-Authorization-3' => '...'
91
+ # # ...
92
+ # }
93
+ #
94
+ # @return [Hash]
95
+ # the signing headers
96
+ #
97
+ def headers
98
+ {
99
+ X_OPS_SIGN => SIGNATURE,
100
+ X_OPS_USERID => @user,
101
+ X_OPS_TIMESTAMP => canonical_timestamp,
102
+ X_OPS_CONTENT_HASH => content_hash,
103
+ }.merge(signature_lines)
104
+ end
105
+
106
+ #
107
+ # The canonical body. This could be an IO object (such as +#body_stream+),
108
+ # an actual string (such as +#body+), or just the empty string if the
109
+ # request's body and stream was nil.
110
+ #
111
+ # @return [String, IO]
112
+ #
113
+ def content_hash
114
+ return @content_hash if @content_hash
115
+
116
+ if SIGN_FULL_BODY
117
+ @content_hash = hash(@body || '').chomp
118
+ else
119
+ if @body.is_a?(Multipart::MultiIO)
120
+ filepart = @body.ios.find { |io| io.is_a?(Multipart::MultiIO) }
121
+ file = filepart.ios.find { |io| io.is_a?(File) }
122
+
123
+ @content_hash = hash(file).chomp
124
+ else
125
+ @content_hash = hash(@body || '').chomp
126
+ end
127
+ end
128
+
129
+ @content_hash
130
+ end
131
+
132
+ #
133
+ # Parse the given private key. Users can specify the private key as:
134
+ #
135
+ # - the path to the key on disk
136
+ # - the raw string key
137
+ # - an +OpenSSL::PKey::RSA object+
138
+ #
139
+ # Any other implementations are not supported and will likely explode.
140
+ #
141
+ # @todo
142
+ # Handle errors when the file cannot be read due to insufficient
143
+ # permissions
144
+ #
145
+ # @return [OpenSSL::PKey::RSA]
146
+ # the RSA private key as an OpenSSL object
147
+ #
148
+ def canonical_key
149
+ return @canonical_key if @canonical_key
150
+
151
+ log.info "Parsing private key..."
152
+
153
+ if @key.nil?
154
+ log.warn "No private key given!"
155
+ raise 'No private key given!'
156
+ end
157
+
158
+ if @key.is_a?(OpenSSL::PKey::RSA)
159
+ log.debug "Detected private key is an OpenSSL Ruby object"
160
+ @canonical_key = @key
161
+ elsif @key =~ /(.+)\.pem$/ || File.exists?(File.expand_path(@key))
162
+ log.debug "Detected private key is the path to a file"
163
+ contents = File.read(File.expand_path(@key))
164
+ @canonical_key = OpenSSL::PKey::RSA.new(contents)
165
+ else
166
+ log.debug "Detected private key was the literal string key"
167
+ @canonical_key = OpenSSL::PKey::RSA.new(@key)
168
+ end
169
+
170
+ @canonical_key
171
+ end
172
+
173
+
174
+ #
175
+ # The canonical path, with duplicate and trailing slashes removed. This
176
+ # value is then hashed.
177
+ #
178
+ # @example
179
+ # "/zip//zap/foo" #=> "/zip/zap/foo"
180
+ #
181
+ # @return [String]
182
+ #
183
+ def canonical_path
184
+ @canonical_path ||= hash(@path.squeeze('/').gsub(/(\/)+$/,'')).chomp
185
+ end
186
+
187
+ #
188
+ # The iso8601 timestamp for this request. This value must be cached so it
189
+ # is persisted throughout this entire request.
190
+ #
191
+ # @return [String]
192
+ #
193
+ def canonical_timestamp
194
+ @canonical_timestamp ||= Time.now.utc.iso8601
195
+ end
196
+
197
+ #
198
+ # The uppercase verb.
199
+ #
200
+ # @example
201
+ # :get #=> "GET"
202
+ #
203
+ # @return [String]
204
+ #
205
+ def canonical_method
206
+ @canonical_method ||= @verb.to_s.upcase
207
+ end
208
+
209
+ #
210
+ # The canonical request, from the path, body, user, and current timestamp.
211
+ #
212
+ # @return [String]
213
+ #
214
+ def canonical_request
215
+ [
216
+ "Method:#{canonical_method}",
217
+ "Hashed Path:#{canonical_path}",
218
+ "X-Ops-Content-Hash:#{content_hash}",
219
+ "X-Ops-Timestamp:#{canonical_timestamp}",
220
+ "X-Ops-UserId:#{@user}",
221
+ ].join("\n")
222
+ end
223
+
224
+ #
225
+ # The canonical request, encrypted by the given private key.
226
+ #
227
+ # @return [String]
228
+ #
229
+ def encrypted_request
230
+ canonical_key.private_encrypt(canonical_request).chomp
231
+ end
232
+
233
+ #
234
+ # The +X-Ops-Authorization-N+ headers. This method takes the encrypted
235
+ # request, splits on a newline, and creates a signed header authentication
236
+ # request. N begins at 1, not 0 because the original author of
237
+ # Mixlib::Authentication did not believe in computer science.
238
+ #
239
+ # @return [Hash]
240
+ #
241
+ def signature_lines
242
+ signature = Base64.encode64(encrypted_request)
243
+ signature.split(/\n/).each_with_index.inject({}) do |hash, (line, index)|
244
+ hash["#{X_OPS_AUTHORIZATION}-#{index + 1}"] = line
245
+ hash
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ #
252
+ # Hash the given object.
253
+ #
254
+ # @param [String, IO] object
255
+ # a string or IO object to hash
256
+ #
257
+ # @return [String]
258
+ # the hashed value
259
+ #
260
+ def hash(object)
261
+ if object.respond_to?(:read)
262
+ digest_io(object)
263
+ else
264
+ digest_string(object)
265
+ end
266
+ end
267
+
268
+ #
269
+ # Digest the given IO, reading in 1024 bytes at one time.
270
+ #
271
+ # @param [IO] io
272
+ # the IO (or File object)
273
+ #
274
+ # @return [String]
275
+ #
276
+ def digest_io(io)
277
+ digester = Digest::SHA1.new
278
+
279
+ while buffer = io.read(1024)
280
+ digester.update(buffer)
281
+ end
282
+
283
+ Base64.encode64(digester.digest)
284
+ end
285
+
286
+ #
287
+ # Digest a string.
288
+ #
289
+ # @param [String] string
290
+ # the string to digest
291
+ #
292
+ # @return [String]
293
+ #
294
+ def digest_string(string)
295
+ Base64.encode64(Digest::SHA1.digest(string))
296
+ end
297
+ end
298
+ end
@@ -202,25 +202,35 @@ module ChefAPI
202
202
  # Setup PATCH/POST/PUT
203
203
  if [:patch, :post, :put].include?(verb)
204
204
  if data.respond_to?(:read)
205
+ log.info "Detected file/io presence"
205
206
  request.body_stream = data
206
207
  elsif data.is_a?(Hash)
207
208
  # If any of the values in the hash are File-like, assume this is a
208
209
  # multi-part post
209
210
  if data.values.any? { |value| value.respond_to?(:read) }
211
+ log.info "Detected multipart body"
212
+
210
213
  multipart = Multipart::Body.new(data)
214
+
215
+ log.debug "Content-Type: #{multipart.content_type}"
216
+ log.debug "Content-Length: #{multipart.content_length}"
217
+
211
218
  request.content_length = multipart.content_length
212
219
  request.content_type = multipart.content_type
220
+
213
221
  request.body_stream = multipart.stream
214
222
  else
223
+ log.info "Detected form data"
215
224
  request.form_data = data
216
225
  end
217
226
  else
227
+ log.info "Detected regular body"
218
228
  request.body = data
219
229
  end
220
230
  end
221
231
 
222
232
  # Sign the request
223
- add_signing_headers(verb, uri, request, parsed_key)
233
+ add_signing_headers(verb, uri.path, request)
224
234
 
225
235
  # Create the HTTP connection object - since the proxy information defaults
226
236
  # to +nil+, we can just pass it to the initializer method instead of doing
@@ -351,49 +361,6 @@ module ChefAPI
351
361
 
352
362
  private
353
363
 
354
- #
355
- # Parse the given private key. Users can specify the private key as:
356
- #
357
- # - the path to the key on disk
358
- # - the raw string key
359
- # - an +OpenSSL::PKey::RSA object+
360
- #
361
- # Any other implementations are not supported and will likely explode.
362
- #
363
- # @todo
364
- # Handle errors when the file cannot be read due to insufficient
365
- # permissions
366
- #
367
- # @return [OpenSSL::PKey::RSA]
368
- # the RSA private key as an OpenSSL object
369
- #
370
- def parsed_key
371
- return @parsed_key if @parsed_key
372
-
373
- log.info "Parsing private key..."
374
-
375
- if key.nil?
376
- log.warn "No private key given!"
377
- raise 'No private key given!'
378
- end
379
-
380
- if key.is_a?(OpenSSL::PKey::RSA)
381
- log.debug "Detected private key is an OpenSSL Ruby object"
382
- @parsed_key = key
383
- end
384
-
385
- if key =~ /(.+)\.pem$/ || File.exists?(File.expand_path(key))
386
- log.debug "Detected private key is the path to a file"
387
- contents = File.read(File.expand_path(key))
388
- @parsed_key = OpenSSL::PKey::RSA.new(contents)
389
- else
390
- log.debug "Detected private key was the literal string key"
391
- @parsed_key = OpenSSL::PKey::RSA.new(key)
392
- end
393
-
394
- @parsed_key
395
- end
396
-
397
364
  #
398
365
  # Parse the response object and manipulate the result based on the given
399
366
  # +Content-Type+ header. For now, this method only parses JSON, but it
@@ -433,7 +400,7 @@ module ChefAPI
433
400
  when /json/
434
401
  log.debug "Detected error response as JSON"
435
402
  log.debug "Parsing error response as JSON"
436
- message = Array(JSON.parse(response.body)['error']).join(', ')
403
+ message = JSON.parse(response.body)
437
404
  else
438
405
  log.debug "Detected response as text/plain"
439
406
  message = response.body
@@ -485,191 +452,33 @@ module ChefAPI
485
452
  end
486
453
 
487
454
  #
488
- # Use mixlib-auth to create a signed header auth.
455
+ # Create a signed header authentication that can be consumed by
456
+ # +Mixlib::Authentication+.
489
457
  #
458
+ # @param [Symbol] verb
459
+ # the HTTP verb (e.g. +:get+)
460
+ # @param [String] path
461
+ # the requested URI path (e.g. +/resources/foo+)
490
462
  # @param [Net::HTTP::Request] request
491
463
  #
492
- def add_signing_headers(verb, uri, request, key)
464
+ def add_signing_headers(verb, path, request)
493
465
  log.info "Adding signed header authentication..."
494
466
 
495
- unless defined?(Mixlib::Authentication::SignedHeaderAuth)
496
- require 'mixlib/authentication/signedheaderauth'
497
- end
498
-
499
- headers = Mixlib::Authentication::SignedHeaderAuth.signing_object(
500
- :http_method => verb,
501
- :body => request.body || '',
502
- :host => "#{uri.host}:#{uri.port}",
503
- :path => uri.path,
504
- :timestamp => Time.now.utc.iso8601,
505
- :user_id => client,
506
- :file => '',
507
- ).sign(key)
467
+ authentication = Authentication.from_options(
468
+ user: client,
469
+ key: key,
470
+ verb: verb,
471
+ path: path,
472
+ body: request.body || request.body_stream,
473
+ )
508
474
 
509
- headers.each do |key, value|
475
+ authentication.headers.each do |key, value|
510
476
  log.debug "#{key}: #{value}"
511
477
  request[key] = value
512
478
  end
513
- end
514
- end
515
-
516
- require 'cgi'
517
- require 'mime/types'
518
-
519
- module Multipart
520
- BOUNDARY = '------ChefAPIMultipartBoundary'.freeze
521
-
522
- class Body
523
- def initialize(params = {})
524
- params.each do |key, value|
525
- if value.respond_to?(:read)
526
- parts << FilePart.new(key, value)
527
- else
528
- parts << ParamPart.new(key, value)
529
- end
530
- end
531
-
532
- parts << EndingPart.new
533
- end
534
-
535
- def stream
536
- MultiIO.new(*parts.map(&:io))
537
- end
538
-
539
- def content_type
540
- "multipart/form-data; boundary=#{BOUNDARY}"
541
- end
542
-
543
- def content_length
544
- parts.map(&:size).inject(:+)
545
- end
546
-
547
- private
548
-
549
- def parts
550
- @parts ||= []
551
- end
552
- end
553
-
554
- class MultiIO
555
- def initialize(*ios)
556
- @ios = ios
557
- @index = 0
558
- end
559
-
560
- # Read from IOs in order until `length` bytes have been received.
561
- def read(length = nil, outbuf = nil)
562
- got_result = false
563
- outbuf = outbuf ? outbuf.replace('') : ''
564
-
565
- while io = current_io
566
- if result = io.read(length)
567
- got_result ||= !result.nil?
568
- result.force_encoding('BINARY') if result.respond_to?(:force_encoding)
569
- outbuf << result
570
- length -= result.length if length
571
- break if length == 0
572
- end
573
- advance_io
574
- end
575
-
576
- (!got_result && length) ? nil : outbuf
577
- end
578
-
579
- def rewind
580
- @ios.each { |io| io.rewind }
581
- @index = 0
582
- end
583
-
584
- private
585
-
586
- def current_io
587
- @ios[@index]
588
- end
589
-
590
- def advance_io
591
- @index += 1
592
- end
593
- end
594
-
595
- #
596
- # A generic key => value part.
597
- #
598
- class ParamPart
599
- def initialize(name, value)
600
- @part = build(name, value)
601
- end
602
-
603
- def io
604
- @io ||= StringIO.new(@part)
605
- end
606
-
607
- def size
608
- @part.bytesize
609
- end
610
-
611
- private
612
-
613
- def build(name, value)
614
- part = %|--#{BOUNDARY}\r\n|
615
- part << %|Content-Disposition: form-data; name="#{CGI.escape(name)}"\r\n\r\n|
616
- part << %|#{value}\r\n|
617
- part
618
- end
619
- end
620
-
621
- #
622
- # A File part
623
- #
624
- class FilePart
625
- def initialize(name, file)
626
- @file = file
627
- @head = build(name, file)
628
- @foot = "\r\n"
629
- end
630
-
631
- def io
632
- @io ||= MultiIO.new(
633
- StringIO.new(@head),
634
- @file,
635
- StringIO.new(@foot)
636
- )
637
- end
638
-
639
- def size
640
- @head.bytesize + @file.size + @foot.bytesize
641
- end
642
-
643
- private
644
-
645
- def build(name, file)
646
- filename = File.basename(file.path)
647
- mime_type = MIME::Types.type_for(filename)[0] || MIME::Types['application/octet-stream'][0]
648
-
649
- part = %|--#{BOUNDARY}\r\n|
650
- part << %|Content-Disposition: form-data; name="#{CGI.escape(name)}"; filename="#{filename}"\r\n|
651
- part << %|Content-Length: #{file.size}\r\n|
652
- part << %|Content-Type: #{mime_type.simplified}|
653
- part << %|Content-Transfer-Encoding: binary\r\n|
654
- part << %|\r\n|
655
- part
656
- end
657
- end
658
-
659
- #
660
- # The end of the entire request
661
- #
662
- class EndingPart
663
- def initialize
664
- @part = "--#{BOUNDARY}--\r\n\r\n"
665
- end
666
-
667
- def io
668
- @io ||= StringIO.new(@part)
669
- end
670
479
 
671
- def size
672
- @part.bytesize
480
+ if request.body_stream
481
+ request.body_stream.rewind
673
482
  end
674
483
  end
675
484
  end
@@ -0,0 +1,164 @@
1
+ require 'cgi'
2
+ require 'mime/types'
3
+
4
+ module ChefAPI
5
+ module Multipart
6
+ BOUNDARY = '------ChefAPIMultipartBoundary'.freeze
7
+
8
+ class Body
9
+ def initialize(params = {})
10
+ params.each do |key, value|
11
+ if value.respond_to?(:read)
12
+ parts << FilePart.new(key, value)
13
+ else
14
+ parts << ParamPart.new(key, value)
15
+ end
16
+ end
17
+
18
+ parts << EndingPart.new
19
+ end
20
+
21
+ def stream
22
+ MultiIO.new(*parts.map(&:io))
23
+ end
24
+
25
+ def content_type
26
+ "multipart/form-data; boundary=#{BOUNDARY}"
27
+ end
28
+
29
+ def content_length
30
+ parts.map(&:size).inject(:+)
31
+ end
32
+
33
+ private
34
+
35
+ def parts
36
+ @parts ||= []
37
+ end
38
+ end
39
+
40
+ class MultiIO
41
+ attr_reader :ios
42
+
43
+ def initialize(*ios)
44
+ @ios = ios
45
+ @index = 0
46
+ end
47
+
48
+ # Read from IOs in order until `length` bytes have been received.
49
+ def read(length = nil, outbuf = nil)
50
+ got_result = false
51
+ outbuf = outbuf ? outbuf.replace('') : ''
52
+
53
+ while io = current_io
54
+ if result = io.read(length)
55
+ got_result ||= !result.nil?
56
+ result.force_encoding('BINARY') if result.respond_to?(:force_encoding)
57
+ outbuf << result
58
+ length -= result.length if length
59
+ break if length == 0
60
+ end
61
+ advance_io
62
+ end
63
+
64
+ (!got_result && length) ? nil : outbuf
65
+ end
66
+
67
+ def rewind
68
+ @ios.each { |io| io.rewind }
69
+ @index = 0
70
+ end
71
+
72
+ private
73
+
74
+ def current_io
75
+ @ios[@index]
76
+ end
77
+
78
+ def advance_io
79
+ @index += 1
80
+ end
81
+ end
82
+
83
+ #
84
+ # A generic key => value part.
85
+ #
86
+ class ParamPart
87
+ def initialize(name, value)
88
+ @part = build(name, value)
89
+ end
90
+
91
+ def io
92
+ @io ||= StringIO.new(@part)
93
+ end
94
+
95
+ def size
96
+ @part.bytesize
97
+ end
98
+
99
+ private
100
+
101
+ def build(name, value)
102
+ part = %|--#{BOUNDARY}\r\n|
103
+ part << %|Content-Disposition: form-data; name="#{CGI.escape(name)}"\r\n\r\n|
104
+ part << %|#{value}\r\n|
105
+ part
106
+ end
107
+ end
108
+
109
+ #
110
+ # A File part
111
+ #
112
+ class FilePart
113
+ def initialize(name, file)
114
+ @file = file
115
+ @head = build(name, file)
116
+ @foot = "\r\n"
117
+ end
118
+
119
+ def io
120
+ @io ||= MultiIO.new(
121
+ StringIO.new(@head),
122
+ @file,
123
+ StringIO.new(@foot)
124
+ )
125
+ end
126
+
127
+ def size
128
+ @head.bytesize + @file.size + @foot.bytesize
129
+ end
130
+
131
+ private
132
+
133
+ def build(name, file)
134
+ filename = File.basename(file.path)
135
+ mime_type = MIME::Types.type_for(filename)[0] || MIME::Types['application/octet-stream'][0]
136
+
137
+ part = %|--#{BOUNDARY}\r\n|
138
+ part << %|Content-Disposition: form-data; name="#{CGI.escape(name)}"; filename="#{filename}"\r\n|
139
+ part << %|Content-Length: #{file.size}\r\n|
140
+ part << %|Content-Type: #{mime_type.simplified}\r\n|
141
+ part << %|Content-Transfer-Encoding: binary\r\n|
142
+ part << %|\r\n|
143
+ part
144
+ end
145
+ end
146
+
147
+ #
148
+ # The end of the entire request
149
+ #
150
+ class EndingPart
151
+ def initialize
152
+ @part = "--#{BOUNDARY}--\r\n\r\n"
153
+ end
154
+
155
+ def io
156
+ @io ||= StringIO.new(@part)
157
+ end
158
+
159
+ def size
160
+ @part.bytesize
161
+ end
162
+ end
163
+ end
164
+ end
@@ -1,3 +1,3 @@
1
1
  module ChefAPI
2
- VERSION = '0.4.0'
2
+ VERSION = '0.4.1'
3
3
  end
@@ -23,3 +23,10 @@ RSpec.configure do |config|
23
23
  # --seed 1234
24
24
  config.order = 'random'
25
25
  end
26
+
27
+ #
28
+ # @return [String]
29
+ #
30
+ def rspec_support_file(*joins)
31
+ File.join(File.expand_path('../support', __FILE__), *joins)
32
+ end
@@ -0,0 +1,27 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ MIIEowIBAAKCAQEA1xzQLQxDANx/Yu73NbqATU898uvHcVaMglg4FjMMOhqLTE3g
3
+ MIDMUeBH05c167kRmF+6a3l3dNlDyt8cBtWo277Yfd3FPOzeaf/g7umKY0Ids9S4
4
+ fZBP+wcG8mpk2ReNiAcIwJlTZHWSdUSoHIZeDXd8eE5tU1WfdWeXfd+yQFVTv5fj
5
+ U0VwU/c4+UzMjySbHiv94mKIfels/M7hnlRoJyepJFLThTi5bE4bmyizhvBp6j8V
6
+ HbaLdSfBis1FgDuaajSjTu6m41X7HshSuWkRp1w+2PWQnXwoCIBIogHcTlE+UGt6
7
+ LNa23/jiMCzRGYQiS/gl5+6gTByhCcV7f2aXXwIDAQABAoIBADYZVvmdVdSHn7nf
8
+ 42gtyUqoHSpUxcnpPFkjmqdqmy6ZsmK0SyennLsSrr22D6eC2bv6h0W0PKi0Y2pI
9
+ BiJp5ZeuPYAaIBqcb6s04Pr03Qrte87YNrXNb2/wanzY6Rf35m5JZpgZd3GSaAz6
10
+ AVV7LXgxjqoq/y+wHvRF40GS2p924BePIzSgwWld2X7s39YdgSrxk2vytuqU2B8m
11
+ iH5fhJDghO7MQCX5aa+6YgLAvP28mKTPBOz8kCbiWPgDPxu9NpI4WGgzGgZqofjZ
12
+ GIyqZcDQ15oILdi0awWaVAK6Ob/24QQm33QaHzKZTKaRzWUeIJpnqPcGndjRC4Yt
13
+ FN/yVKECgYEA7ahqlovJN1AeZ1tDqP9UiGHSKyJmo3psnv3+u5DV+/cUUwDvbLF5
14
+ atCaGWZbdd0oSejeNeNX+JKZZE5xxSrKmuFnQe9lljylrWEOZ0TBngi4MABVJvst
15
+ vUG21vYVEZGiQzWZqZ92zxJQB3V0hOPNsyyAKVnyKctwhSlO3slbYhcCgYEA57bz
16
+ ueFcwQsQuJFLK2fX6ZmMJD0bwMXMOZIb+1s/URAIEClFqJYnYJuT7GGthz8099FL
17
+ 2HyrGScrTYL+ekrAFTHmx/hBLdPwYhCunyptUvPPQwU8+mEhKSsqVtjHKnFfyHRB
18
+ T2c8AZNQNXhMMeJYANY3Gm1/4catWJ6RF/U1qfkCgYEApDqFrZLbcYXD/NhsYRRQ
19
+ bg5rFbOoCcBH33bV2Pe1Z3DOcq1qxkm+BboxQuwgt8okVS6+n66C1Bs6NL6gkAeK
20
+ Co1ItZ+hK7itJKq1MVeqFHMiFMmmDlH0wZvvpYxX8tQYtSkNDtJLX7zf4MehxVNG
21
+ ilJuHiUx2v/iuaJaBkpPA/ECgYAxNXthOGkYXh848zJBj5Yc+Az5DTk9oUQT3eGv
22
+ adtyfbMYq4stmGXYcHHju4K8vEGld39iBGfZuaXKmk0s738HgUd/pEtDTkU4rk5H
23
+ Yx1AhqK3mv8uNT5zncUqGHODofwzd+z+ze/CbeSU1m1oEqeZ1eRx6ltEOYtKzLIH
24
+ on25EQKBgEpxoKGGbp6EpY/wGCONeapjB/27gRAdLB5Nh/9HCAfVfoM/K31ECmL9
25
+ ZWWiwM6U2Qlmh8jGrhN4su8hpsNGbjvZ+kpA0MqJnJQGr6Y7iiSKDtd+Xc1cLh1g
26
+ YtL+yxlvdE9ue8oZut4Mfn0xQg2sns+OYi7mWQpssKeR/faPcGkK
27
+ -----END RSA PRIVATE KEY-----
@@ -0,0 +1,70 @@
1
+ require 'spec_helper'
2
+
3
+ module ChefAPI
4
+ describe Authentication do
5
+ let(:user) { 'sethvargo' }
6
+ let(:key) { rspec_support_file('user.pem') }
7
+ let(:body) { nil }
8
+ let(:verb) { :get }
9
+ let(:path) { '/foo/bar' }
10
+ let(:timestamp) { '1991-07-23T03:00:54Z' }
11
+
12
+ let(:headers) { described_class.new(user, key, verb, path, body).headers }
13
+
14
+ before do
15
+ allow(Time).to receive_message_chain(:now, :utc, :iso8601)
16
+ .and_return(timestamp)
17
+ end
18
+
19
+ context 'when given a request with no body' do
20
+ let(:body) { nil }
21
+
22
+ it 'returns the signed headers' do
23
+ expect(headers['X-Ops-Sign']).to eq('algorithm=sha1;version=1.0;')
24
+ expect(headers['X-Ops-Userid']).to eq('sethvargo')
25
+ expect(headers['X-Ops-Timestamp']).to eq('1991-07-23T03:00:54Z')
26
+ expect(headers['X-Ops-Content-Hash']).to eq('2jmj7l5rSw0yVb/vlWAYkK/YBwk=')
27
+ expect(headers['X-Ops-Authorization-1']).to eq('UuadIvkZTeZDcFW6oNilet0QzTcP/9JsRhSjIKCiZiqUeBG9jz4mU9w+TWsm')
28
+ expect(headers['X-Ops-Authorization-2']).to eq('2R3IiEKOW0S+UZpN19tPZ3nTdUluEvguidnsjuM/UpHymgY7M560pN4idXt5')
29
+ expect(headers['X-Ops-Authorization-3']).to eq('MQYAEHhFHTOfBX8ihOPkA5gkbLw6ehftjL10W/7H3bTrl1tiHHkv2Lmz4e+e')
30
+ expect(headers['X-Ops-Authorization-4']).to eq('9dJNeNDYVEaR1Efj7B7rnKjSD6SvRdqq0gbwiTfE7P2B88yjnq+a9eEoYgG3')
31
+ expect(headers['X-Ops-Authorization-5']).to eq('lmNnVT5pqJPHiE1YFj1OITywAi/5pMzJCzYzVyWxQT+4r+SIRtRESrRFi1Re')
32
+ expect(headers['X-Ops-Authorization-6']).to eq('OfHqhynKfmxMHAxVLJbfdjH3yX8Z8bq3tGPbdXxYAw==')
33
+ end
34
+ end
35
+
36
+ context 'when given a request with a string body' do
37
+ let(:body) { '{ "some": { "json": true } }' }
38
+
39
+ it 'returns the signed headers' do
40
+ expect(headers['X-Ops-Sign']).to eq('algorithm=sha1;version=1.0;')
41
+ expect(headers['X-Ops-Userid']).to eq('sethvargo')
42
+ expect(headers['X-Ops-Timestamp']).to eq('1991-07-23T03:00:54Z')
43
+ expect(headers['X-Ops-Content-Hash']).to eq('D3+ox1HKmuzp3SLWiSU/5RdnbdY=')
44
+ expect(headers['X-Ops-Authorization-1']).to eq('fbV8dt51y832DJS0bfR1LJ+EF/HHiDEgqJawNZyKMkgMHZ0Bv78kQVtH73fS')
45
+ expect(headers['X-Ops-Authorization-2']).to eq('s3JQkMpZOwsNO8n2iduexmTthJe/JXG4sUgBKkS2qtKxpBy5snFSb6wD5ZuC')
46
+ expect(headers['X-Ops-Authorization-3']).to eq('VJuC1YpOF6bGM8CyUG0O0SZBZRFZVgyC5TFACJn8ymMIx0FznWSPLyvoSjsZ')
47
+ expect(headers['X-Ops-Authorization-4']).to eq('pdVOjhPV2+EQaj3c01dBFx5FSXgnBhWSmu2DCel/74TDt5RBraPcB4wczwpz')
48
+ expect(headers['X-Ops-Authorization-5']).to eq('VIeVqGMuQ71OE0tabej4OKyf1+BopOedxVH1+KF5ETisxqrNhmEtUY5WrmSS')
49
+ expect(headers['X-Ops-Authorization-6']).to eq('hjhiBXFdieV24Sojq6PKBhEEwpJqrPVP1lZNkRXdoA==')
50
+ end
51
+ end
52
+
53
+ context 'when given an IO object' do
54
+ let(:body) { File.open(rspec_support_file('cookbook.tar.gz')) }
55
+
56
+ it 'returns the signed headers' do
57
+ expect(headers['X-Ops-Sign']).to eq('algorithm=sha1;version=1.0;')
58
+ expect(headers['X-Ops-Userid']).to eq('sethvargo')
59
+ expect(headers['X-Ops-Timestamp']).to eq('1991-07-23T03:00:54Z')
60
+ expect(headers['X-Ops-Content-Hash']).to eq('AWFSGfxiL2XltqdgSKCpdm84H9o=')
61
+ expect(headers['X-Ops-Authorization-1']).to eq('oRvANxtLQanzqdC28l0szONjTni9zLRBiybYNyxyxos7M1X3kSs5LknmMA/E')
62
+ expect(headers['X-Ops-Authorization-2']).to eq('i6Izk87dCcG3LLiGqRh0x/BoayS9SyoctdfMRR5ivrKRUzuQU9elHRpXnmjw')
63
+ expect(headers['X-Ops-Authorization-3']).to eq('7i/tlbLPrJQ/0+di9BU4m+BBD/vbh80KajmsaszxHx1wwNEBkNAymSLSDqXX')
64
+ expect(headers['X-Ops-Authorization-4']).to eq('gVAjNiaEzV9/EPQyGAYaU40SOdDwKzBthxgCpM9sfpfQsXj4Oj4SvSmO+4sy')
65
+ expect(headers['X-Ops-Authorization-5']).to eq('eJ0l7vpR0MyQqnhqbJHkQAGsG/HUhuhG0E9T7dClk08EB+sdsnDxr+5laei3')
66
+ expect(headers['X-Ops-Authorization-6']).to eq('YtCw2spOnumfdqx2hWvLmxR3y2eOuLBv77tZXUQ4Ug==')
67
+ end
68
+ end
69
+ end
70
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chef-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seth Vargo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-07-06 00:00:00.000000000 Z
11
+ date: 2014-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logify
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.1'
27
- - !ruby/object:Gem::Dependency
28
- name: mixlib-authentication
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '1.3'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '1.3'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: mime-types
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -68,12 +54,14 @@ files:
68
54
  - Rakefile
69
55
  - chef-api.gemspec
70
56
  - lib/chef-api.rb
57
+ - lib/chef-api/authentication.rb
71
58
  - lib/chef-api/boolean.rb
72
59
  - lib/chef-api/configurable.rb
73
60
  - lib/chef-api/connection.rb
74
61
  - lib/chef-api/defaults.rb
75
62
  - lib/chef-api/error_collection.rb
76
63
  - lib/chef-api/errors.rb
64
+ - lib/chef-api/multipart.rb
77
65
  - lib/chef-api/resource.rb
78
66
  - lib/chef-api/resources/base.rb
79
67
  - lib/chef-api/resources/client.rb
@@ -106,7 +94,10 @@ files:
106
94
  - spec/integration/resources/user_spec.rb
107
95
  - spec/spec_helper.rb
108
96
  - spec/support/chef_server.rb
97
+ - spec/support/cookbook.tar.gz
109
98
  - spec/support/shared/chef_api_resource.rb
99
+ - spec/support/user.pem
100
+ - spec/unit/authentication_spec.rb
110
101
  - spec/unit/errors_spec.rb
111
102
  - spec/unit/resources/base_spec.rb
112
103
  - spec/unit/resources/client_spec.rb
@@ -166,7 +157,10 @@ test_files:
166
157
  - spec/integration/resources/user_spec.rb
167
158
  - spec/spec_helper.rb
168
159
  - spec/support/chef_server.rb
160
+ - spec/support/cookbook.tar.gz
169
161
  - spec/support/shared/chef_api_resource.rb
162
+ - spec/support/user.pem
163
+ - spec/unit/authentication_spec.rb
170
164
  - spec/unit/errors_spec.rb
171
165
  - spec/unit/resources/base_spec.rb
172
166
  - spec/unit/resources/client_spec.rb