plntr-fakes3 1.0.0.pre.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.
@@ -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