chef-api 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  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