google-cloud-storage 1.18.1 → 1.44.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/AUTHENTICATION.md +17 -30
  3. data/CHANGELOG.md +312 -0
  4. data/CONTRIBUTING.md +4 -5
  5. data/LOGGING.md +1 -1
  6. data/OVERVIEW.md +37 -5
  7. data/TROUBLESHOOTING.md +2 -8
  8. data/lib/google/cloud/storage/bucket/acl.rb +40 -40
  9. data/lib/google/cloud/storage/bucket/cors.rb +4 -1
  10. data/lib/google/cloud/storage/bucket/lifecycle.rb +259 -44
  11. data/lib/google/cloud/storage/bucket/list.rb +3 -3
  12. data/lib/google/cloud/storage/bucket.rb +1096 -172
  13. data/lib/google/cloud/storage/convert.rb +4 -3
  14. data/lib/google/cloud/storage/credentials.rb +16 -14
  15. data/lib/google/cloud/storage/errors.rb +7 -2
  16. data/lib/google/cloud/storage/file/acl.rb +181 -20
  17. data/lib/google/cloud/storage/file/list.rb +10 -8
  18. data/lib/google/cloud/storage/file/signer_v2.rb +36 -18
  19. data/lib/google/cloud/storage/file/signer_v4.rb +249 -61
  20. data/lib/google/cloud/storage/file/verifier.rb +2 -2
  21. data/lib/google/cloud/storage/file.rb +450 -84
  22. data/lib/google/cloud/storage/hmac_key/list.rb +182 -0
  23. data/lib/google/cloud/storage/hmac_key.rb +316 -0
  24. data/lib/google/cloud/storage/policy/binding.rb +246 -0
  25. data/lib/google/cloud/storage/policy/bindings.rb +196 -0
  26. data/lib/google/cloud/storage/policy/condition.rb +138 -0
  27. data/lib/google/cloud/storage/policy.rb +277 -24
  28. data/lib/google/cloud/storage/post_object.rb +20 -2
  29. data/lib/google/cloud/storage/project.rb +249 -50
  30. data/lib/google/cloud/storage/service.rb +479 -288
  31. data/lib/google/cloud/storage/version.rb +1 -1
  32. data/lib/google/cloud/storage.rb +86 -16
  33. data/lib/google-cloud-storage.rb +54 -7
  34. metadata +74 -27
@@ -39,37 +39,83 @@ module Google
39
39
  new bucket.name, file_name, bucket.service
40
40
  end
41
41
 
42
- def signed_url method: "GET", expires: nil, headers: nil,
43
- issuer: nil, client_email: nil, signing_key: nil,
44
- private_key: nil, query: nil
45
- issuer, signer = issuer_and_signer issuer, client_email,
46
- signing_key, private_key
42
+ def post_object issuer: nil,
43
+ client_email: nil,
44
+ signing_key: nil,
45
+ private_key: nil,
46
+ signer: nil,
47
+ expires: nil,
48
+ fields: nil,
49
+ conditions: nil,
50
+ scheme: "https",
51
+ virtual_hosted_style: nil,
52
+ bucket_bound_hostname: nil
53
+ i = determine_issuer issuer, client_email
54
+ s = determine_signing_key signing_key, private_key, signer
55
+
56
+ now = Time.now.utc
57
+ base_fields = required_fields i, now
58
+ post_fields = fields.dup || {}
59
+ post_fields.merge! base_fields
60
+
61
+ p = {}
62
+ p["conditions"] = policy_conditions base_fields, conditions, fields
63
+ expires ||= 60 * 60 * 24
64
+ p["expiration"] = (now + expires).strftime "%Y-%m-%dT%H:%M:%SZ"
65
+
66
+ policy_str = escape_characters p.to_json
67
+
68
+ policy = Base64.strict_encode64(policy_str).force_encoding "utf-8"
69
+ signature = generate_signature s, policy
70
+
71
+ post_fields["x-goog-signature"] = signature
72
+ post_fields["policy"] = policy
73
+ url = post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
74
+ hostname = "#{url}#{bucket_path path_style?(virtual_hosted_style, bucket_bound_hostname)}"
75
+ Google::Cloud::Storage::PostObject.new hostname, post_fields
76
+ end
77
+
78
+ def signed_url method: "GET",
79
+ expires: nil,
80
+ headers: nil,
81
+ issuer: nil,
82
+ client_email: nil,
83
+ signing_key: nil,
84
+ private_key: nil,
85
+ signer: nil,
86
+ query: nil,
87
+ scheme: "https",
88
+ virtual_hosted_style: nil,
89
+ bucket_bound_hostname: nil
90
+ raise ArgumentError, "method is required" unless method
91
+ issuer, signer = issuer_and_signer issuer, client_email, signing_key, private_key, signer
47
92
  datetime_now = Time.now.utc
48
93
  goog_date = datetime_now.strftime "%Y%m%dT%H%M%SZ"
49
94
  datestamp = datetime_now.strftime "%Y%m%d"
50
95
  # goog4_request is not checked.
51
96
  scope = "#{datestamp}/auto/storage/goog4_request"
52
97
 
53
- canonical_headers_str, signed_headers_str = \
54
- canonical_and_signed_headers headers
98
+ canonical_headers_str, signed_headers_str = canonical_and_signed_headers \
99
+ headers, virtual_hosted_style, bucket_bound_hostname
55
100
 
56
101
  algorithm = "GOOG4-RSA-SHA256"
57
102
  expires = determine_expires expires
58
- credential = CGI.escape issuer + "/" + scope
59
- canonical_query_str = canonical_query query, algorithm,
60
- credential, goog_date,
61
- expires, signed_headers_str
103
+ credential = "#{issuer}/#{scope}"
104
+ canonical_query_str = canonical_query query, algorithm, credential, goog_date, expires, signed_headers_str
62
105
 
63
106
  # From AWS: You don't include a payload hash in the Canonical
64
107
  # Request, because when you create a presigned URL, you don't know
65
108
  # the payload content because the URL is used to upload an arbitrary
66
109
  # payload. Instead, you use a constant string UNSIGNED-PAYLOAD.
110
+ payload = headers&.key?("X-Goog-Content-SHA256") ? headers["X-Goog-Content-SHA256"] : "UNSIGNED-PAYLOAD"
111
+
67
112
  canonical_request = [method,
68
- ext_path,
113
+ file_path(!(virtual_hosted_style || bucket_bound_hostname)),
69
114
  canonical_query_str,
70
115
  canonical_headers_str,
71
116
  signed_headers_str,
72
- "UNSIGNED-PAYLOAD"].join("\n")
117
+ payload].join("\n")
118
+
73
119
  # Construct string to sign
74
120
  req_sha = Digest::SHA256.hexdigest canonical_request
75
121
  string_to_sign = [algorithm, goog_date, scope, req_sha].join "\n"
@@ -78,65 +124,131 @@ module Google
78
124
  signature = signer.call string_to_sign
79
125
 
80
126
  # Construct signed URL
81
- "#{ext_url}?#{canonical_query_str}&X-Goog-Signature=#{signature}"
127
+ hostname = signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
128
+ "#{hostname}?#{canonical_query_str}&X-Goog-Signature=#{signature}"
129
+ end
130
+
131
+ # methods below are public visibility only for unit testing
132
+ # rubocop:disable Style/StringLiterals
133
+ def escape_characters str
134
+ str.split("").map do |s|
135
+ if s.ascii_only?
136
+ case s
137
+ when "\\"
138
+ '\\'
139
+ when "\b"
140
+ '\b'
141
+ when "\f"
142
+ '\f'
143
+ when "\n"
144
+ '\n'
145
+ when "\r"
146
+ '\r'
147
+ when "\t"
148
+ '\t'
149
+ when "\v"
150
+ '\v'
151
+ else
152
+ s
153
+ end
154
+ else
155
+ escape_special_unicode s
156
+ end
157
+ end.join
158
+ end
159
+ # rubocop:enable Style/StringLiterals
160
+
161
+ def escape_special_unicode str
162
+ str.unpack("U*").map { |i| "\\u#{i.to_s(16).rjust(4, '0')}" }.join
82
163
  end
83
164
 
84
165
  protected
85
166
 
167
+ def required_fields issuer, time
168
+ {
169
+ "key" => @file_name,
170
+ "x-goog-date" => time.strftime("%Y%m%dT%H%M%SZ"),
171
+ "x-goog-credential" => "#{issuer}/#{time.strftime '%Y%m%d'}/auto/storage/goog4_request",
172
+ "x-goog-algorithm" => "GOOG4-RSA-SHA256"
173
+ }.freeze
174
+ end
175
+
176
+ def policy_conditions base_fields, user_conditions, user_fields
177
+ # Convert each pair in base_fields hash to a single-entry hash in an array.
178
+ conditions = base_fields.to_a.map { |f| Hash[*f] }
179
+ # Add the bucket to the head of the base_fields. This is not returned in the PostObject fields.
180
+ conditions.unshift "bucket" => @bucket_name
181
+ # Add user-provided conditions to the head of the conditions array.
182
+ conditions = user_conditions + conditions if user_conditions
183
+ if user_fields
184
+ # Convert each pair in fields hash to a single-entry hash and add it to the head of the conditions array.
185
+ user_fields.to_a.reverse.each { |f| conditions.unshift Hash[*f] }
186
+ end
187
+ conditions.freeze
188
+ end
189
+
190
+ def signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
191
+ url = ext_url scheme, virtual_hosted_style, bucket_bound_hostname
192
+ "#{url}#{file_path path_style?(virtual_hosted_style, bucket_bound_hostname)}"
193
+ end
194
+
86
195
  def determine_issuer issuer, client_email
87
196
  # Parse the Service Account and get client id and private key
88
197
  issuer = issuer || client_email || @service.credentials.issuer
89
- unless issuer
90
- raise SignedUrlUnavailable, "issuer (client_email) missing"
91
- end
198
+ raise SignedUrlUnavailable, error_msg("issuer (client_email)") unless issuer
92
199
  issuer
93
200
  end
94
201
 
95
- def determine_signing_key signing_key, private_key
96
- signing_key = signing_key || private_key ||
97
- @service.credentials.signing_key
98
- unless signing_key
99
- raise SignedUrlUnavailable, "signing_key (private_key) missing"
100
- end
202
+ def determine_signing_key signing_key, private_key, signer
203
+ signing_key = signing_key || private_key || signer || @service.credentials.signing_key
204
+ raise SignedUrlUnavailable, error_msg("signing_key (private_key, signer)") unless signing_key
101
205
  signing_key
102
206
  end
103
207
 
208
+ def error_msg attr_name
209
+ "Service account credentials '#{attr_name}' is missing. To generate service account credentials " \
210
+ "see https://cloud.google.com/iam/docs/service-accounts"
211
+ end
212
+
104
213
  def service_account_signer signer
105
- unless signer.respond_to? :sign
106
- signer = OpenSSL::PKey::RSA.new signer
107
- end
108
- # Sign string to sign
109
- lambda do |string_to_sign|
110
- sig = signer.sign OpenSSL::Digest::SHA256.new, string_to_sign
111
- sig.unpack("H*").first
214
+ if signer.is_a? Proc
215
+ lambda do |string_to_sign|
216
+ sig = signer.call string_to_sign
217
+ sig.unpack1 "H*"
218
+ end
219
+ else
220
+ signer = OpenSSL::PKey::RSA.new signer unless signer.respond_to? :sign
221
+ # Sign string to sign
222
+ lambda do |string_to_sign|
223
+ sig = signer.sign OpenSSL::Digest::SHA256.new, string_to_sign
224
+ sig.unpack1 "H*"
225
+ end
112
226
  end
113
227
  end
114
228
 
115
- def issuer_and_signer issuer, client_email, signing_key, private_key
229
+ def issuer_and_signer issuer, client_email, signing_key, private_key, signer
116
230
  issuer = determine_issuer issuer, client_email
117
- signing_key = determine_signing_key signing_key, private_key
231
+ signing_key = determine_signing_key signing_key, private_key, signer
118
232
  signer = service_account_signer signing_key
119
233
  [issuer, signer]
120
234
  end
121
235
 
122
- def canonical_and_signed_headers headers
123
- # Headers needs to be in alpha order.
236
+ def canonical_and_signed_headers headers, virtual_hosted_style, bucket_bound_hostname
237
+ if virtual_hosted_style && bucket_bound_hostname
238
+ raise "virtual_hosted_style: #{virtual_hosted_style} and bucket_bound_hostname: " \
239
+ "#{bucket_bound_hostname} params cannot both be passed together"
240
+ end
241
+
124
242
  canonical_headers = headers || {}
125
- canonical_headers = Hash[canonical_headers.map do |k, v|
126
- [k.downcase, v.strip.gsub(/\s+/, " ")]
127
- end]
128
- canonical_headers["host"] = "storage.googleapis.com"
129
-
130
- canonical_headers = canonical_headers.sort_by do |k, _|
131
- k.downcase
132
- end.to_h
133
- canonical_headers_str = ""
134
- canonical_headers.each do |k, v|
135
- canonical_headers_str += "#{k}:#{v}\n"
243
+ headers_arr = canonical_headers.map do |k, v|
244
+ [k.downcase, v.strip.gsub(/[^\S\t]+/, " ").gsub(/\t+/, " ")]
136
245
  end
137
- signed_headers_str = ""
138
- canonical_headers.each_key { |k| signed_headers_str += "#{k};" }
139
- signed_headers_str = signed_headers_str.chomp ";"
246
+ canonical_headers = Hash[headers_arr]
247
+ canonical_headers["host"] = host_name virtual_hosted_style, bucket_bound_hostname
248
+
249
+ canonical_headers = canonical_headers.sort_by(&:first).to_h
250
+ canonical_headers_str = canonical_headers.map { |k, v| "#{k}:#{v}\n" }.join
251
+ signed_headers_str = canonical_headers.keys.join ";"
140
252
  [canonical_headers_str, signed_headers_str]
141
253
  end
142
254
 
@@ -148,32 +260,108 @@ module Google
148
260
  expires
149
261
  end
150
262
 
151
- def canonical_query query, algorithm, credential, goog_date, expires,
152
- signed_headers_str
263
+ def canonical_query query, algorithm, credential, goog_date, expires, signed_headers_str
153
264
  query ||= {}
154
265
  query["X-Goog-Algorithm"] = algorithm
155
266
  query["X-Goog-Credential"] = credential
156
267
  query["X-Goog-Date"] = goog_date
157
268
  query["X-Goog-Expires"] = expires
158
- query["X-Goog-SignedHeaders"] = CGI.escape signed_headers_str
159
- query = query.sort_by { |k, _| k.to_s.downcase }.to_h
160
- canonical_query_str = ""
161
- query.each { |k, v| canonical_query_str += "#{k}=#{v}&" }
162
- canonical_query_str.chomp "&"
269
+ query["X-Goog-SignedHeaders"] = signed_headers_str
270
+ query = query.map { |k, v| [escape_query_param(k), escape_query_param(v)] }.sort_by(&:first).to_h
271
+ query.map { |k, v| "#{k}=#{v}" }.join "&"
272
+ end
273
+
274
+ ##
275
+ # Only the characters in the regex set [A-Za-z0-9.~_-] must be left un-escaped; all others must be
276
+ # percent-encoded using %XX UTF-8 style.
277
+ def escape_query_param str
278
+ CGI.escape(str.to_s).gsub("%7E", "~").gsub "+", "%20"
279
+ end
280
+
281
+ def host_name virtual_hosted_style, bucket_bound_hostname
282
+ return bucket_bound_hostname if bucket_bound_hostname
283
+ virtual_hosted_style ? "#{@bucket_name}.storage.googleapis.com" : "storage.googleapis.com"
163
284
  end
164
285
 
165
286
  ##
166
287
  # The URI-encoded (percent encoded) external path to the file.
167
- def ext_path
168
- path = "/#{@bucket_name}"
169
- path += "/#{String(@file_name)}" if @file_name && !@file_name.empty?
170
- Addressable::URI.escape path
288
+ def file_path path_style
289
+ path = []
290
+ path << "/#{@bucket_name}" if path_style
291
+ path << "/#{String(@file_name)}" if @file_name && !@file_name.empty?
292
+ CGI.escape(path.join).gsub("%2F", "/").gsub "+", "%20"
293
+ end
294
+
295
+ ##
296
+ # The external path to the bucket, with trailing slash.
297
+ def bucket_path path_style
298
+ return "/#{@bucket_name}/" if path_style
171
299
  end
172
300
 
173
301
  ##
174
302
  # The external url to the file.
175
- def ext_url
176
- "#{GOOGLEAPIS_URL}#{ext_path}"
303
+ def ext_url scheme, virtual_hosted_style, bucket_bound_hostname
304
+ url = GOOGLEAPIS_URL.dup
305
+ if virtual_hosted_style
306
+ parts = url.split "//"
307
+ parts[1] = "#{@bucket_name}.#{parts[1]}"
308
+ parts.join "//"
309
+ elsif bucket_bound_hostname
310
+ raise ArgumentError, "scheme is required" unless scheme
311
+ URI "#{scheme.to_s.downcase}://#{bucket_bound_hostname}"
312
+ else
313
+ url
314
+ end
315
+ end
316
+
317
+ def path_style? virtual_hosted_style, bucket_bound_hostname
318
+ !(virtual_hosted_style || bucket_bound_hostname)
319
+ end
320
+
321
+ ##
322
+ # The external path to the file, URI-encoded.
323
+ # Will not URI encode the special `${filename}` variable.
324
+ # "You can also use the ${filename} variable..."
325
+ # https://cloud.google.com/storage/docs/xml-api/post-object
326
+ #
327
+ def post_object_ext_path
328
+ path = "/#{@bucket_name}/#{@file_name}"
329
+ escaped = Addressable::URI.encode_component path, Addressable::URI::CharacterClasses::PATH
330
+ special_var = "${filename}"
331
+ # Restore the unencoded `${filename}` variable, if present.
332
+ if path.include? special_var
333
+ return escaped.gsub "$%7Bfilename%7D", special_var
334
+ end
335
+ escaped
336
+ end
337
+
338
+ ##
339
+ # The external url to the file.
340
+ def post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
341
+ url = GOOGLEAPIS_URL.dup
342
+ if virtual_hosted_style
343
+ parts = url.split "//"
344
+ parts[1] = "#{@bucket_name}.#{parts[1]}/"
345
+ parts.join "//"
346
+ elsif bucket_bound_hostname
347
+ raise ArgumentError, "scheme is required" unless scheme
348
+ URI "#{scheme.to_s.downcase}://#{bucket_bound_hostname}/"
349
+ else
350
+ url
351
+ end
352
+ end
353
+
354
+ def generate_signature signing_key, data
355
+ packed_signature = nil
356
+ if signing_key.is_a? Proc
357
+ packed_signature = signing_key.call data
358
+ else
359
+ unless signing_key.respond_to? :sign
360
+ signing_key = OpenSSL::PKey::RSA.new signing_key
361
+ end
362
+ packed_signature = signing_key.sign OpenSSL::Digest::SHA256.new, data
363
+ end
364
+ packed_signature.unpack1("H*").force_encoding "utf-8"
177
365
  end
178
366
  end
179
367
  end
@@ -50,7 +50,7 @@ module Google
50
50
  end
51
51
 
52
52
  def self.md5_for local_file
53
- if local_file.respond_to? :path
53
+ if local_file.respond_to? :to_path
54
54
  ::File.open Pathname(local_file).to_path, "rb" do |f|
55
55
  ::Digest::MD5.file(f).base64digest
56
56
  end
@@ -63,7 +63,7 @@ module Google
63
63
  end
64
64
 
65
65
  def self.crc32c_for local_file
66
- if local_file.respond_to? :path
66
+ if local_file.respond_to? :to_path
67
67
  ::File.open Pathname(local_file).to_path, "rb" do |f|
68
68
  ::Digest::CRC32c.file(f).base64digest
69
69
  end