plntr-fakes3 1.0.0.pre.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,560 @@
1
+ require 'time'
2
+ require 'webrick'
3
+ require 'webrick/https'
4
+ require 'openssl'
5
+ require 'securerandom'
6
+ require 'cgi'
7
+ require 'fakes3/util'
8
+ require 'fakes3/file_store'
9
+ require 'fakes3/xml_adapter'
10
+ require 'fakes3/bucket_query'
11
+ require 'fakes3/unsupported_operation'
12
+ require 'fakes3/errors'
13
+ require 'ipaddr'
14
+
15
+ module FakeS3
16
+ class Request
17
+ CREATE_BUCKET = "CREATE_BUCKET"
18
+ LIST_BUCKETS = "LIST_BUCKETS"
19
+ LS_BUCKET = "LS_BUCKET"
20
+ HEAD = "HEAD"
21
+ STORE = "STORE"
22
+ COPY = "COPY"
23
+ GET = "GET"
24
+ GET_ACL = "GET_ACL"
25
+ SET_ACL = "SET_ACL"
26
+ MOVE = "MOVE"
27
+ DELETE_OBJECT = "DELETE_OBJECT"
28
+ DELETE_BUCKET = "DELETE_BUCKET"
29
+
30
+ attr_accessor :bucket, :object, :type, :src_bucket,
31
+ :src_object, :method, :webrick_request,
32
+ :path, :is_path_style, :query, :http_verb
33
+
34
+ def inspect
35
+ puts "-----Inspect FakeS3 Request"
36
+ puts "Type: #{@type}"
37
+ puts "Is Path Style: #{@is_path_style}"
38
+ puts "Request Method: #{@method}"
39
+ puts "Bucket: #{@bucket}"
40
+ puts "Object: #{@object}"
41
+ puts "Src Bucket: #{@src_bucket}"
42
+ puts "Src Object: #{@src_object}"
43
+ puts "Query: #{@query}"
44
+ puts "-----Done"
45
+ end
46
+ end
47
+
48
+ class Servlet < WEBrick::HTTPServlet::AbstractServlet
49
+ def initialize(server,store,hostname)
50
+ super(server)
51
+ @store = store
52
+ @hostname = hostname
53
+ @port = server.config[:Port]
54
+ @root_hostnames = [hostname,'localhost','s3.amazonaws.com','s3.localhost']
55
+ end
56
+
57
+ def validate_request(request)
58
+ req = request.webrick_request
59
+ return if req.nil?
60
+ return if not req.header.has_key?('expect')
61
+ req.continue if req.header['expect'].first=='100-continue'
62
+ end
63
+
64
+ def do_GET(request, response)
65
+ s_req = normalize_request(request)
66
+
67
+ case s_req.type
68
+ when 'LIST_BUCKETS'
69
+ response.status = 200
70
+ response['Content-Type'] = 'application/xml'
71
+ buckets = @store.buckets
72
+ response.body = XmlAdapter.buckets(buckets)
73
+ when 'LS_BUCKET'
74
+ bucket_obj = @store.get_bucket(s_req.bucket)
75
+ if bucket_obj
76
+ response.status = 200
77
+ response['Content-Type'] = "application/xml"
78
+ query = {
79
+ :marker => s_req.query["marker"] ? s_req.query["marker"].to_s : nil,
80
+ :prefix => s_req.query["prefix"] ? s_req.query["prefix"].to_s : nil,
81
+ :max_keys => s_req.query["max-keys"] ? s_req.query["max-keys"].to_i : nil,
82
+ :delimiter => s_req.query["delimiter"] ? s_req.query["delimiter"].to_s : nil
83
+ }
84
+ bq = bucket_obj.query_for_range(query)
85
+ response.body = XmlAdapter.bucket_query(bq)
86
+ else
87
+ response.status = 404
88
+ response.body = XmlAdapter.error_no_such_bucket(s_req.bucket)
89
+ response['Content-Type'] = "application/xml"
90
+ end
91
+ when 'GET_ACL'
92
+ response.status = 200
93
+ response.body = XmlAdapter.acl
94
+ response['Content-Type'] = 'application/xml'
95
+ when 'GET'
96
+ real_obj = @store.get_object(s_req.bucket, s_req.object, request)
97
+ if !real_obj
98
+ response.status = 404
99
+ response.body = XmlAdapter.error_no_such_key(s_req.object)
100
+ response['Content-Type'] = "application/xml"
101
+ return
102
+ end
103
+
104
+ if_none_match = request["If-None-Match"]
105
+ if if_none_match == "\"#{real_obj.md5}\"" or if_none_match == "*"
106
+ response.status = 304
107
+ return
108
+ end
109
+
110
+ if_modified_since = request["If-Modified-Since"]
111
+ if if_modified_since
112
+ time = Time.httpdate(if_modified_since)
113
+ if time >= Time.iso8601(real_obj.modified_date)
114
+ response.status = 304
115
+ return
116
+ end
117
+ end
118
+
119
+ response.status = 200
120
+ response['Content-Type'] = real_obj.content_type
121
+
122
+ if real_obj.content_encoding
123
+ response.header['X-Content-Encoding'] = real_obj.content_encoding
124
+ response.header['Content-Encoding'] = real_obj.content_encoding
125
+ end
126
+
127
+ stat = File::Stat.new(real_obj.io.path)
128
+
129
+ response['Last-Modified'] = Time.iso8601(real_obj.modified_date).httpdate
130
+ response.header['ETag'] = "\"#{real_obj.md5}\""
131
+ response['Accept-Ranges'] = "bytes"
132
+ response['Last-Ranges'] = "bytes"
133
+ response['Access-Control-Allow-Origin'] = '*'
134
+
135
+ real_obj.custom_metadata.each do |header, value|
136
+ response.header['x-amz-meta-' + header] = value
137
+ end
138
+
139
+ content_length = stat.size
140
+
141
+ # Added Range Query support
142
+ range = request.header["range"].first
143
+ if range
144
+ response.status = 206
145
+ if range =~ /bytes=(\d*)-(\d*)/
146
+ start = $1.to_i
147
+ finish = $2.to_i
148
+ finish_str = ""
149
+ if finish == 0
150
+ finish = content_length - 1
151
+ finish_str = "#{finish}"
152
+ else
153
+ finish_str = finish.to_s
154
+ end
155
+
156
+ bytes_to_read = finish - start + 1
157
+ response['Content-Range'] = "bytes #{start}-#{finish_str}/#{content_length}"
158
+ real_obj.io.pos = start
159
+ response.body = real_obj.io.read(bytes_to_read)
160
+ return
161
+ end
162
+ end
163
+ response['Content-Length'] = File::Stat.new(real_obj.io.path).size
164
+ if s_req.http_verb == 'HEAD'
165
+ response.body = ""
166
+ real_obj.io.close
167
+ else
168
+ response.body = real_obj.io
169
+ end
170
+ end
171
+ end
172
+
173
+ def do_PUT(request, response)
174
+ s_req = normalize_request(request)
175
+ query = CGI::parse(request.request_uri.query || "")
176
+
177
+ return do_multipartPUT(request, response) if query['uploadId'].first
178
+
179
+ response.status = 200
180
+ response.body = ""
181
+ response['Content-Type'] = "text/xml"
182
+ response['Access-Control-Allow-Origin'] = '*'
183
+
184
+ case s_req.type
185
+ when Request::COPY
186
+ object = @store.copy_object(s_req.src_bucket, s_req.src_object, s_req.bucket, s_req.object, request)
187
+ response.body = XmlAdapter.copy_object_result(object)
188
+ when Request::STORE
189
+ bucket_obj = @store.get_bucket(s_req.bucket)
190
+ if !bucket_obj
191
+ # Lazily create a bucket. TODO fix this to return the proper error
192
+ bucket_obj = @store.create_bucket(s_req.bucket)
193
+ end
194
+
195
+ real_obj = @store.store_object(bucket_obj, s_req.object, s_req.webrick_request)
196
+ response.header['ETag'] = "\"#{real_obj.md5}\""
197
+ when Request::CREATE_BUCKET
198
+ @store.create_bucket(s_req.bucket)
199
+ end
200
+ end
201
+
202
+ def do_multipartPUT(request, response)
203
+ s_req = normalize_request(request)
204
+ query = CGI::parse(request.request_uri.query)
205
+
206
+ part_number = query['partNumber'].first
207
+ upload_id = query['uploadId'].first
208
+ part_name = "#{upload_id}_#{s_req.object}_part#{part_number}"
209
+
210
+ # store the part
211
+ if s_req.type == Request::COPY
212
+ real_obj = @store.copy_object(
213
+ s_req.src_bucket, s_req.src_object,
214
+ s_req.bucket , part_name,
215
+ request
216
+ )
217
+
218
+ response['Content-Type'] = "text/xml"
219
+ response.body = XmlAdapter.copy_object_result real_obj
220
+ else
221
+ bucket_obj = @store.get_bucket(s_req.bucket)
222
+ if !bucket_obj
223
+ bucket_obj = @store.create_bucket(s_req.bucket)
224
+ end
225
+ real_obj = @store.store_object(
226
+ bucket_obj, part_name,
227
+ request
228
+ )
229
+
230
+ response.body = ""
231
+ response.header['ETag'] = "\"#{real_obj.md5}\""
232
+ end
233
+
234
+ response['Access-Control-Allow-Origin'] = '*'
235
+ response['Access-Control-Allow-Headers'] = 'Authorization, Content-Length'
236
+ response['Access-Control-Expose-Headers'] = 'ETag'
237
+
238
+ response.status = 200
239
+ end
240
+
241
+ def do_POST(request,response)
242
+ s_req = normalize_request(request)
243
+ key = request.query['key']
244
+ query = CGI::parse(request.request_uri.query || "")
245
+
246
+ if query.has_key?('uploads')
247
+ upload_id = SecureRandom.hex
248
+
249
+ response.body = <<-eos.strip
250
+ <?xml version="1.0" encoding="UTF-8"?>
251
+ <InitiateMultipartUploadResult>
252
+ <Bucket>#{ s_req.bucket }</Bucket>
253
+ <Key>#{ key }</Key>
254
+ <UploadId>#{ upload_id }</UploadId>
255
+ </InitiateMultipartUploadResult>
256
+ eos
257
+ elsif query.has_key?('uploadId')
258
+ upload_id = query['uploadId'].first
259
+ bucket_obj = @store.get_bucket(s_req.bucket)
260
+ real_obj = @store.combine_object_parts(
261
+ bucket_obj,
262
+ upload_id,
263
+ s_req.object,
264
+ parse_complete_multipart_upload(request),
265
+ request
266
+ )
267
+
268
+ response.body = XmlAdapter.complete_multipart_result real_obj
269
+ elsif request.content_type =~ /^multipart\/form-data; boundary=(.+)/
270
+ key = request.query['key']
271
+
272
+ success_action_redirect = request.query['success_action_redirect']
273
+ success_action_status = request.query['success_action_status']
274
+
275
+ filename = 'default'
276
+ filename = $1 if request.body =~ /filename="(.*)"/
277
+ key = key.gsub('${filename}', filename)
278
+
279
+ bucket_obj = @store.get_bucket(s_req.bucket) || @store.create_bucket(s_req.bucket)
280
+ real_obj = @store.store_object(bucket_obj, key, s_req.webrick_request)
281
+
282
+ response['Etag'] = "\"#{real_obj.md5}\""
283
+
284
+ if success_action_redirect
285
+ response.status = 307
286
+ response.body = ""
287
+ response['Location'] = success_action_redirect
288
+ else
289
+ response.status = success_action_status || 204
290
+ if response.status == "201"
291
+ response.body = <<-eos.strip
292
+ <?xml version="1.0" encoding="UTF-8"?>
293
+ <PostResponse>
294
+ <Location>http://#{s_req.bucket}.localhost:#{@port}/#{key}</Location>
295
+ <Bucket>#{s_req.bucket}</Bucket>
296
+ <Key>#{key}</Key>
297
+ <ETag>#{response['Etag']}</ETag>
298
+ </PostResponse>
299
+ eos
300
+ end
301
+ end
302
+ else
303
+ raise WEBrick::HTTPStatus::BadRequest
304
+ end
305
+
306
+ response['Content-Type'] = 'text/xml'
307
+ response['Access-Control-Allow-Origin'] = '*'
308
+ response['Access-Control-Allow-Headers'] = 'Authorization, Content-Length'
309
+ response['Access-Control-Expose-Headers'] = 'ETag'
310
+ end
311
+
312
+ def do_DELETE(request, response)
313
+ s_req = normalize_request(request)
314
+
315
+ case s_req.type
316
+ when Request::DELETE_OBJECT
317
+ bucket_obj = @store.get_bucket(s_req.bucket)
318
+ @store.delete_object(bucket_obj,s_req.object,s_req.webrick_request)
319
+ when Request::DELETE_BUCKET
320
+ @store.delete_bucket(s_req.bucket)
321
+ end
322
+
323
+ response.status = 204
324
+ response.body = ""
325
+ end
326
+
327
+ def do_OPTIONS(request, response)
328
+ super
329
+
330
+ response['Access-Control-Allow-Origin'] = '*'
331
+ response['Access-Control-Allow-Methods'] = 'PUT, POST, HEAD, GET, OPTIONS'
332
+ response['Access-Control-Allow-Headers'] = '*'
333
+ response['Access-Control-Expose-Headers'] = '*'
334
+ end
335
+
336
+ private
337
+
338
+ def normalize_delete(webrick_req, s_req)
339
+ path = webrick_req.path
340
+ path_len = path.size
341
+ query = webrick_req.query
342
+ if path == "/" and s_req.is_path_style
343
+ # Probably do a 404 here
344
+ else
345
+ if s_req.is_path_style
346
+ elems = path[1,path_len].split("/")
347
+ s_req.bucket = elems[0]
348
+ else
349
+ elems = path.split("/")
350
+ end
351
+
352
+ if elems.size == 0
353
+ raise UnsupportedOperation
354
+ elsif elems.size == 1
355
+ s_req.type = Request::DELETE_BUCKET
356
+ s_req.query = query
357
+ else
358
+ s_req.type = Request::DELETE_OBJECT
359
+ object = elems[1,elems.size].join('/')
360
+ s_req.object = object
361
+ end
362
+ end
363
+ end
364
+
365
+ def normalize_get(webrick_req, s_req)
366
+ path = webrick_req.path
367
+ path_len = path.size
368
+ query = webrick_req.query
369
+ if path == "/" and s_req.is_path_style
370
+ s_req.type = Request::LIST_BUCKETS
371
+ else
372
+ if s_req.is_path_style
373
+ elems = path[1,path_len].split("/")
374
+ s_req.bucket = elems[0]
375
+ else
376
+ elems = path.split("/")
377
+ end
378
+
379
+ if elems.size < 2
380
+ s_req.type = Request::LS_BUCKET
381
+ s_req.query = query
382
+ else
383
+ if query["acl"] == ""
384
+ s_req.type = Request::GET_ACL
385
+ else
386
+ s_req.type = Request::GET
387
+ end
388
+ object = elems[1,elems.size].join('/')
389
+ s_req.object = object
390
+ end
391
+ end
392
+ end
393
+
394
+ def normalize_put(webrick_req, s_req)
395
+ path = webrick_req.path
396
+ path_len = path.size
397
+ if path == "/"
398
+ if s_req.bucket
399
+ s_req.type = Request::CREATE_BUCKET
400
+ end
401
+ else
402
+ if s_req.is_path_style
403
+ elems = path[1,path_len].split("/")
404
+ s_req.bucket = elems[0]
405
+ if elems.size == 1
406
+ s_req.type = Request::CREATE_BUCKET
407
+ else
408
+ if webrick_req.request_line =~ /\?acl/
409
+ s_req.type = Request::SET_ACL
410
+ else
411
+ s_req.type = Request::STORE
412
+ end
413
+ s_req.object = elems[1,elems.size].join('/')
414
+ end
415
+ else
416
+ if webrick_req.request_line =~ /\?acl/
417
+ s_req.type = Request::SET_ACL
418
+ else
419
+ s_req.type = Request::STORE
420
+ end
421
+ s_req.object = webrick_req.path[1..-1]
422
+ end
423
+ end
424
+
425
+ # TODO: also parse the x-amz-copy-source-range:bytes=first-last header
426
+ # for multipart copy
427
+ copy_source = webrick_req.header["x-amz-copy-source"]
428
+ if copy_source and copy_source.size == 1
429
+ src_elems = copy_source.first.split("/")
430
+ root_offset = src_elems[0] == "" ? 1 : 0
431
+ s_req.src_bucket = src_elems[root_offset]
432
+ s_req.src_object = src_elems[1 + root_offset,src_elems.size].join("/")
433
+ s_req.type = Request::COPY
434
+ end
435
+
436
+ s_req.webrick_request = webrick_req
437
+ end
438
+
439
+ def normalize_post(webrick_req,s_req)
440
+ path = webrick_req.path
441
+ path_len = path.size
442
+
443
+ s_req.path = webrick_req.query['key']
444
+ s_req.webrick_request = webrick_req
445
+
446
+ if s_req.is_path_style
447
+ elems = path[1, path_len].split("/")
448
+ s_req.bucket = elems[0]
449
+ s_req.object = elems[1..-1].join('/') if elems.size >= 2
450
+ else
451
+ s_req.object = path[1..-1]
452
+ end
453
+ end
454
+
455
+ # This method takes a webrick request and generates a normalized FakeS3 request
456
+ def normalize_request(webrick_req)
457
+ host_header= webrick_req["Host"]
458
+ host = host_header.split(':')[0]
459
+
460
+ s_req = Request.new
461
+ s_req.path = webrick_req.path
462
+ s_req.is_path_style = true
463
+
464
+ if !@root_hostnames.include?(host) && !(IPAddr.new(host) rescue nil)
465
+ s_req.bucket = host.split(".")[0]
466
+ s_req.is_path_style = false
467
+ end
468
+
469
+ s_req.http_verb = webrick_req.request_method
470
+
471
+ case webrick_req.request_method
472
+ when 'PUT'
473
+ normalize_put(webrick_req,s_req)
474
+ when 'GET','HEAD'
475
+ normalize_get(webrick_req,s_req)
476
+ when 'DELETE'
477
+ normalize_delete(webrick_req,s_req)
478
+ when 'POST'
479
+ normalize_post(webrick_req,s_req)
480
+ else
481
+ raise "Unknown Request"
482
+ end
483
+
484
+ validate_request(s_req)
485
+
486
+ return s_req
487
+ end
488
+
489
+ def parse_complete_multipart_upload(request)
490
+ parts_xml = ""
491
+ request.body { |chunk| parts_xml << chunk }
492
+
493
+ # TODO: improve parsing xml
494
+ parts_xml = parts_xml.scan(/<Part>.*?<\/Part>/m)
495
+
496
+ parts_xml.collect do |xml|
497
+ {
498
+ number: xml[/<PartNumber>(\d+)<\/PartNumber>/, 1].to_i,
499
+ etag: FakeS3::Util.strip_before_and_after(xml[/\<ETag\>(.+)<\/ETag>/, 1], '"')
500
+ }
501
+ end
502
+ end
503
+
504
+ def dump_request(request)
505
+ puts "----------Dump Request-------------"
506
+ puts request.request_method
507
+ puts request.path
508
+ request.each do |k,v|
509
+ puts "#{k}:#{v}"
510
+ end
511
+ puts "----------End Dump -------------"
512
+ end
513
+ end
514
+
515
+
516
+ class Server
517
+ def initialize(address, port, store, hostname, ssl_cert_path, ssl_key_path, extra_options={})
518
+ @address = address
519
+ @port = port
520
+ @store = store
521
+ @hostname = hostname
522
+ @ssl_cert_path = ssl_cert_path
523
+ @ssl_key_path = ssl_key_path
524
+ webrick_config = {
525
+ :BindAddress => @address,
526
+ :Port => @port
527
+ }
528
+ if !@ssl_cert_path.to_s.empty?
529
+ webrick_config.merge!(
530
+ {
531
+ :SSLEnable => true,
532
+ :SSLCertificate => OpenSSL::X509::Certificate.new(File.read(@ssl_cert_path)),
533
+ :SSLPrivateKey => OpenSSL::PKey::RSA.new(File.read(@ssl_key_path))
534
+ }
535
+ )
536
+ end
537
+
538
+ if extra_options[:quiet]
539
+ webrick_config.merge!(
540
+ :Logger => WEBrick::Log.new("/dev/null"),
541
+ :AccessLog => []
542
+ )
543
+ end
544
+
545
+ @server = WEBrick::HTTPServer.new(webrick_config)
546
+ end
547
+
548
+ def serve
549
+ @server.mount "/", Servlet, @store, @hostname
550
+ shutdown = proc { @server.shutdown }
551
+ trap "INT", &shutdown
552
+ trap "TERM", &shutdown
553
+ @server.start
554
+ end
555
+
556
+ def shutdown
557
+ @server.shutdown
558
+ end
559
+ end
560
+ end
@@ -0,0 +1,137 @@
1
+ require 'set'
2
+ module FakeS3
3
+ class S3MatchSet
4
+ attr_accessor :matches,:is_truncated,:common_prefixes
5
+ def initialize
6
+ @matches = []
7
+ @is_truncated = false
8
+ @common_prefixes = []
9
+ end
10
+ end
11
+
12
+ # This class has some of the semantics necessary for how buckets can return
13
+ # their items
14
+ #
15
+ # It is currently implemented naively as a sorted set + hash If you are going
16
+ # to try to put massive lists inside buckets and ls them, you will be sorely
17
+ # disappointed about this performance.
18
+ class SortedObjectList
19
+
20
+ def initialize
21
+ @sorted_set = SortedSet.new
22
+ @object_map = {}
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def count
27
+ @sorted_set.count
28
+ end
29
+
30
+ def find(object_name)
31
+ @object_map[object_name]
32
+ end
33
+
34
+ # Add an S3 object into the sorted list
35
+ def add(s3_object)
36
+ return if !s3_object
37
+
38
+ @object_map[s3_object.name] = s3_object
39
+ @sorted_set << s3_object
40
+ end
41
+
42
+ def remove(s3_object)
43
+ return if !s3_object
44
+
45
+ @object_map.delete(s3_object.name)
46
+ @sorted_set.delete(s3_object)
47
+ end
48
+
49
+ # Return back a set of matches based on the passed in options
50
+ #
51
+ # options:
52
+ #
53
+ # :marker : a string to start the lexographical search (it is not included
54
+ # in the result)
55
+ # :max_keys : a maximum number of results
56
+ # :prefix : a string to filter the results by
57
+ # :delimiter : not supported yet
58
+ def list(options)
59
+ marker = options[:marker]
60
+ prefix = options[:prefix]
61
+ max_keys = options[:max_keys] || 1000
62
+ delimiter = options[:delimiter]
63
+
64
+ ms = S3MatchSet.new
65
+
66
+ marker_found = true
67
+ pseudo = nil
68
+ if marker
69
+ marker_found = false
70
+ if !@object_map[marker]
71
+ pseudo = S3Object.new
72
+ pseudo.name = marker
73
+ @sorted_set << pseudo
74
+ end
75
+ end
76
+
77
+ if delimiter
78
+ if prefix
79
+ base_prefix = prefix
80
+ else
81
+ base_prefix = ""
82
+ end
83
+ prefix_offset = base_prefix.length
84
+ end
85
+
86
+ count = 0
87
+ last_chunk = nil
88
+ @sorted_set.each do |s3_object|
89
+ if marker_found && (!prefix or s3_object.name.index(prefix) == 0)
90
+ if delimiter
91
+ name = s3_object.name
92
+ remainder = name.slice(prefix_offset, name.length)
93
+ chunks = remainder.split(delimiter, 2)
94
+ if chunks.length > 1
95
+ if (last_chunk != chunks[0])
96
+ # "All of the keys rolled up in a common prefix count as
97
+ # a single return when calculating the number of
98
+ # returns. See MaxKeys."
99
+ # (http://awsdocs.s3.amazonaws.com/S3/latest/s3-api.pdf)
100
+ count += 1
101
+ if count <= max_keys
102
+ ms.common_prefixes << base_prefix + chunks[0] + delimiter
103
+ last_chunk = chunks[0]
104
+ else
105
+ is_truncated = true
106
+ break
107
+ end
108
+ end
109
+
110
+ # Continue to the next key, since this one has a
111
+ # delimiter.
112
+ next
113
+ end
114
+ end
115
+
116
+ count += 1
117
+ if count <= max_keys
118
+ ms.matches << s3_object
119
+ else
120
+ is_truncated = true
121
+ break
122
+ end
123
+ end
124
+
125
+ if marker and marker == s3_object.name
126
+ marker_found = true
127
+ end
128
+ end
129
+
130
+ if pseudo
131
+ @sorted_set.delete(pseudo)
132
+ end
133
+
134
+ return ms
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,4 @@
1
+ module FakeS3
2
+ class UnsupportedOperation < RuntimeError
3
+ end
4
+ end
@@ -0,0 +1,8 @@
1
+ module FakeS3
2
+ module Util
3
+ def Util.strip_before_and_after(string, strip_this)
4
+ regex_friendly_strip_this = Regexp.escape(strip_this)
5
+ string.gsub(/\A[#{regex_friendly_strip_this}]+|[#{regex_friendly_strip_this}]+\z/, '')
6
+ end
7
+ end
8
+ end