bucketrb 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d1714eff021fd232f90528369c18271c656b3a39ef914d13e27bf75bb2d33ef6
4
+ data.tar.gz: 13b57a0369936a711c5a7df997b1dc876e4ce2f1a1367a763fe58f76c1611a0b
5
+ SHA512:
6
+ metadata.gz: 38e2991e62d516a57c3dc580bc58be208fb8fa741c767e425470fa96674ef9f72dcd05b8bcf11fe785d5917521475ec27de2a516de1b60753d0caba251ce9282
7
+ data.tar.gz: 7c72297714fb65261fd9a8abd8c83282c9fed3cd95bfc3e411b8baac07d702709e757f2dd39910e8a73d06fcf8941a55317375c4c5164b18d53147d0a83cf139
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bucketrb contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # Bucketrb
2
+
3
+ Bucketrb is a small local S3-compatible server for Ruby development and test
4
+ environments. It is intentionally narrower than MinIO or LocalStack: it focuses
5
+ on the S3 API surface commonly used by Rails apps, `aws-sdk-s3`, ActiveStorage
6
+ adjacent integrations, and browser direct uploads.
7
+
8
+ ## Status
9
+
10
+ This project is an early MVP. It currently implements:
11
+
12
+ - path-style S3 requests
13
+ - unsigned and presigned `GET`, `HEAD`, `PUT`, `POST`, and `DELETE`
14
+ - `CreateBucket` and `HeadBucket`
15
+ - `GetObject`, `HeadObject`, `PutObject`, `CopyObject`, and `DeleteObject`
16
+ - `ListObjects` and `ListObjectsV2`
17
+ - `DeleteObjects`
18
+ - browser-style presigned POST multipart uploads
19
+ - object metadata through `x-amz-meta-*`
20
+ - filesystem persistence
21
+ - permissive CORS for local browser uploads
22
+
23
+ It does not currently implement S3 authorization, bucket policies, ACL
24
+ semantics, multipart upload APIs, object versioning, lifecycle rules, virtual
25
+ host-style bucket addressing, or service APIs outside S3.
26
+
27
+ See [ROADMAP.md](ROADMAP.md) for planned work and explicit non-goals.
28
+
29
+ ## Install
30
+
31
+ From a local checkout:
32
+
33
+ ```sh
34
+ bundle install
35
+ bundle exec exe/bucketrb serve --root ./data --bucket my-bucket
36
+ ```
37
+
38
+ After release:
39
+
40
+ ```sh
41
+ gem install bucketrb
42
+ bucketrb serve --root ./data --bucket my-bucket
43
+ ```
44
+
45
+ ## CLI
46
+
47
+ ```sh
48
+ bucketrb serve \
49
+ --host 127.0.0.1 \
50
+ --port 4566 \
51
+ --root ./data/bucketrb \
52
+ --bucket app-dev-bucket
53
+ ```
54
+
55
+ Options:
56
+
57
+ - `--host HOST`: bind host. Default: `127.0.0.1`
58
+ - `--port PORT`: bind port. Default: `4566`
59
+ - `--root PATH`: filesystem storage root. Default: `data/bucketrb`
60
+ - `--bucket NAME`: create a bucket at startup. May be repeated.
61
+ - `--buckets-from-env`: create buckets from environment variables ending in
62
+ `_BUCKET` or `_S3_BUCKET`
63
+
64
+ ## Ruby SDK Example
65
+
66
+ ```ruby
67
+ require "aws-sdk-s3"
68
+
69
+ client = Aws::S3::Client.new(
70
+ endpoint: "http://127.0.0.1:4566",
71
+ region: "ap-northeast-1",
72
+ access_key_id: "bucketrb",
73
+ secret_access_key: "bucketrb",
74
+ force_path_style: true
75
+ )
76
+
77
+ client.create_bucket(bucket: "example")
78
+ client.put_object(bucket: "example", key: "hello.txt", body: "hello")
79
+ puts client.get_object(bucket: "example", key: "hello.txt").body.read
80
+ ```
81
+
82
+ ## Docker
83
+
84
+ Build locally:
85
+
86
+ ```sh
87
+ docker build -t bucketrb .
88
+ docker run --rm -p 4566:4566 -v "$PWD/data:/data" \
89
+ -e APP_S3_BUCKET=app-dev-bucket \
90
+ bucketrb
91
+ ```
92
+
93
+ ## Migration Sketch
94
+
95
+ An application that currently uses a local S3 emulator can run Bucketrb on the
96
+ same local port and point its S3 SDK configuration at that endpoint:
97
+
98
+ ```yaml
99
+ services:
100
+ bucketrb:
101
+ image: bucketrb:latest
102
+ ports:
103
+ - 127.0.0.1:${S3_EMULATOR_PORT:-4566}:4566
104
+ environment:
105
+ - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:-ap-northeast-1}
106
+ - APP_S3_BUCKET=${APP_S3_BUCKET:-app-dev-bucket}
107
+ - PUBLIC_ASSETS_BUCKET=${PUBLIC_ASSETS_BUCKET:-public-assets-dev}
108
+ - PRIVATE_FILES_BUCKET=${PRIVATE_FILES_BUCKET:-private-files-dev}
109
+ volumes:
110
+ - ./data/bucketrb:/data:delegate
111
+ ```
112
+
113
+ For `aws-sdk-s3`, use path-style addressing:
114
+
115
+ ```ruby
116
+ endpoint: ENV.fetch("S3_ENDPOINT", "http://127.0.0.1:4566")
117
+ force_path_style: true
118
+ ```
119
+
120
+ ## Test
121
+
122
+ ```sh
123
+ ruby -Ilib:test test/bucketrb/object_store_test.rb
124
+ ruby -Ilib:test test/bucketrb/s3_contract_test.rb
125
+ ```
126
+
127
+ The contract test starts a localhost HTTP server and exercises it through
128
+ `aws-sdk-s3`.
data/ROADMAP.md ADDED
@@ -0,0 +1,62 @@
1
+ # Roadmap
2
+
3
+ Bucketrb is intended to be a focused local S3-compatible server for development
4
+ and test environments. It is not intended to become a production object store or
5
+ a full cloud service emulator.
6
+
7
+ ## Near Term
8
+
9
+ - Validate Bucketrb against a real application migration from an existing local
10
+ S3 emulator.
11
+ - Expand contract tests for the S3 API subset already implemented.
12
+ - Add request/response fixtures for AWS SDK behavior that is easy to regress.
13
+ - Verify browser direct upload flows with presigned POST and presigned PUT.
14
+ - Document supported and unsupported S3 behavior in a compatibility matrix.
15
+
16
+ ## S3 Compatibility
17
+
18
+ - Support `Range` requests for `GetObject`.
19
+ - Support `delimiter` and `CommonPrefixes` in object listing responses.
20
+ - Improve pagination behavior for `ListObjects` and `ListObjectsV2`.
21
+ - Add optional checksum response fields when clients send checksum headers.
22
+ - Add multipart upload APIs: create, upload part, complete, abort, and list
23
+ parts.
24
+ - Add optional virtual-host-style bucket addressing.
25
+ - Add more complete error response parity for common AWS SDK code paths.
26
+
27
+ ## Configuration And Runtime
28
+
29
+ - Make CORS behavior configurable instead of always permissive.
30
+ - Add a health endpoint for container orchestration.
31
+ - Add structured access logs with request IDs.
32
+ - Add configurable startup bucket creation from files as well as environment
33
+ variables.
34
+ - Add an option to auto-create buckets on first object write for development
35
+ convenience.
36
+ - Add graceful shutdown coverage in tests.
37
+
38
+ ## Security Model
39
+
40
+ - Keep permissive local-development mode as the default.
41
+ - Add optional SigV4 validation for teams that want stricter local behavior.
42
+ - Add optional presigned POST policy validation for content type, key prefix,
43
+ and content length constraints.
44
+ - Document clearly that Bucketrb is not designed for production exposure.
45
+
46
+ ## Packaging And Release
47
+
48
+ - Add CI for supported Ruby versions.
49
+ - Add release automation for RubyGems.
50
+ - Publish a container image.
51
+ - Add a minimal changelog.
52
+ - Add contribution guidelines.
53
+ - Add a security policy for vulnerability reports.
54
+
55
+ ## Explicitly Out Of Scope
56
+
57
+ - Production durability guarantees.
58
+ - Bucket policies and IAM emulation beyond optional local checks.
59
+ - Object versioning.
60
+ - Lifecycle rules.
61
+ - Replication.
62
+ - Non-S3 cloud service APIs.
data/exe/bucketrb ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
6
+ require "bucketrb/cli"
7
+
8
+ exit Bucketrb::CLI.new(ARGV).run
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rexml/document"
5
+ require "uri"
6
+
7
+ require_relative "errors"
8
+ require_relative "object_store"
9
+ require_relative "xml_response"
10
+
11
+ module Bucketrb
12
+ class App
13
+ XML_CONTENT_TYPE = "application/xml"
14
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
15
+
16
+ def initialize(store:)
17
+ @store = store
18
+ end
19
+
20
+ def call(env)
21
+ request = Rack::Request.new(env)
22
+ return options_response if request.options?
23
+
24
+ bucket, key = path_parts(request.path_info)
25
+
26
+ if bucket.nil?
27
+ return root_request(request)
28
+ end
29
+
30
+ dispatch(request, bucket, key)
31
+ rescue Bucketrb::BucketNotFoundError => error
32
+ error_response("NoSuchBucket", error.message, 404, head: env["REQUEST_METHOD"] == "HEAD")
33
+ rescue Bucketrb::ObjectNotFoundError => error
34
+ error_response("NoSuchKey", error.message, 404, head: env["REQUEST_METHOD"] == "HEAD")
35
+ rescue Bucketrb::InvalidBucketNameError, Bucketrb::InvalidObjectKeyError, ArgumentError => error
36
+ error_response("InvalidArgument", error.message, 400, head: env["REQUEST_METHOD"] == "HEAD")
37
+ rescue StandardError => error
38
+ warn "#{error.class}: #{error.message}"
39
+ warn error.backtrace.join("\n") if ENV["BUCKETRB_DEBUG"]
40
+ error_response("InternalError", error.message, 500, head: env["REQUEST_METHOD"] == "HEAD")
41
+ end
42
+
43
+ private
44
+
45
+ def root_request(request)
46
+ if request.get?
47
+ xml_response(XMLResponse.list_all_my_buckets(@store.buckets))
48
+ else
49
+ error_response("NotImplemented", "Unsupported root request", 501)
50
+ end
51
+ end
52
+
53
+ def dispatch(request, bucket, key)
54
+ case request.request_method
55
+ when "GET"
56
+ key ? get_object(request, bucket, key) : list_objects(request, bucket)
57
+ when "HEAD"
58
+ key ? head_object(bucket, key) : head_bucket(bucket)
59
+ when "PUT"
60
+ key ? put_or_copy_object(request, bucket, key) : create_bucket(bucket)
61
+ when "POST"
62
+ post_request(request, bucket, key)
63
+ when "DELETE"
64
+ key ? delete_object(bucket, key) : error_response("NotImplemented", "DeleteBucket is not implemented", 501)
65
+ else
66
+ error_response("NotImplemented", "Unsupported method: #{request.request_method}", 501)
67
+ end
68
+ end
69
+
70
+ def create_bucket(bucket)
71
+ @store.create_bucket(bucket)
72
+ empty_response(200, "Location" => "/#{bucket}")
73
+ end
74
+
75
+ def head_bucket(bucket)
76
+ @store.ensure_bucket!(bucket)
77
+ empty_response(200)
78
+ end
79
+
80
+ def put_or_copy_object(request, bucket, key)
81
+ copy_source = request.get_header("HTTP_X_AMZ_COPY_SOURCE")
82
+ return copy_object(copy_source, bucket, key) if copy_source && !copy_source.empty?
83
+
84
+ entry = @store.put_object(
85
+ bucket: bucket,
86
+ key: key,
87
+ body: request.body,
88
+ content_type: request.content_type || DEFAULT_CONTENT_TYPE,
89
+ metadata: header_metadata(request.env)
90
+ )
91
+
92
+ empty_response(200, "ETag" => entry.quoted_etag)
93
+ end
94
+
95
+ def copy_object(copy_source, target_bucket, target_key)
96
+ source_bucket, source_key = parse_copy_source(copy_source)
97
+ entry = @store.copy_object(
98
+ source_bucket: source_bucket,
99
+ source_key: source_key,
100
+ target_bucket: target_bucket,
101
+ target_key: target_key
102
+ )
103
+
104
+ xml_response(XMLResponse.copy_result(entry), 200, "ETag" => entry.quoted_etag)
105
+ end
106
+
107
+ def post_request(request, bucket, key)
108
+ return error_response("InvalidRequest", "POST object keys are not supported", 400) if key
109
+ return delete_objects(request, bucket) if query_has?(request, "delete")
110
+
111
+ presigned_post(request, bucket)
112
+ end
113
+
114
+ def presigned_post(request, bucket)
115
+ params = request.POST
116
+ file = params["file"]
117
+ return error_response("InvalidRequest", "Missing multipart file field", 400) unless file
118
+ return error_response("InvalidRequest", "Missing multipart key field", 400) unless params["key"]
119
+
120
+ key = expand_post_key(params["key"], file)
121
+ body = file[:tempfile] || file["tempfile"]
122
+ return error_response("InvalidRequest", "Missing multipart tempfile", 400) unless body
123
+
124
+ content_type = params["Content-Type"] || file[:type] || file["type"] || DEFAULT_CONTENT_TYPE
125
+
126
+ @store.put_object(
127
+ bucket: bucket,
128
+ key: key,
129
+ body: body,
130
+ content_type: content_type,
131
+ metadata: form_metadata(params)
132
+ )
133
+
134
+ empty_response(success_action_status(params))
135
+ end
136
+
137
+ def get_object(_request, bucket, key)
138
+ entry = @store.get_object(bucket: bucket, key: key)
139
+ headers = object_headers(entry)
140
+ response(200, File.binread(entry.path), headers)
141
+ end
142
+
143
+ def head_object(bucket, key)
144
+ entry = @store.head_object(bucket: bucket, key: key)
145
+ empty_response(200, object_headers(entry))
146
+ end
147
+
148
+ def delete_object(bucket, key)
149
+ @store.delete_object(bucket: bucket, key: key)
150
+ empty_response(204)
151
+ end
152
+
153
+ def delete_objects(request, bucket)
154
+ keys = delete_keys(request.body.read)
155
+ deleted = @store.delete_objects(bucket: bucket, keys: keys)
156
+ xml_response(XMLResponse.delete_result(deleted))
157
+ end
158
+
159
+ def list_objects(request, bucket)
160
+ version = request.GET["list-type"].to_s == "2" ? 2 : 1
161
+ result = @store.list_objects(
162
+ bucket: bucket,
163
+ prefix: request.GET.fetch("prefix", ""),
164
+ marker: request.GET["marker"],
165
+ continuation_token: request.GET["continuation-token"],
166
+ max_keys: request.GET.fetch("max-keys", ObjectStore::DEFAULT_MAX_KEYS)
167
+ )
168
+
169
+ xml_response(XMLResponse.list_bucket(result, version: version))
170
+ end
171
+
172
+ def object_headers(entry)
173
+ headers = {
174
+ "Content-Type" => entry.content_type,
175
+ "Content-Length" => entry.size.to_s,
176
+ "ETag" => entry.quoted_etag,
177
+ "Last-Modified" => entry.last_modified.httpdate,
178
+ "Accept-Ranges" => "bytes"
179
+ }
180
+
181
+ entry.metadata.each do |key, value|
182
+ headers["x-amz-meta-#{key}"] = value
183
+ end
184
+
185
+ headers
186
+ end
187
+
188
+ def path_parts(path_info)
189
+ raw = path_info.to_s.sub(%r{\A/+}, "")
190
+ return [nil, nil] if raw.empty?
191
+
192
+ bucket, key = raw.split("/", 2)
193
+ [unescape_path(bucket), key ? unescape_path(key) : nil]
194
+ end
195
+
196
+ def unescape_path(value)
197
+ URI::RFC2396_PARSER.unescape(value.to_s)
198
+ end
199
+
200
+ def parse_copy_source(copy_source)
201
+ source = copy_source.split("?", 2).first.sub(%r{\A/+}, "")
202
+ bucket, key = unescape_path(source).split("/", 2)
203
+ raise ArgumentError, "Invalid x-amz-copy-source: #{copy_source.inspect}" if bucket.nil? || key.nil?
204
+
205
+ [bucket, key]
206
+ end
207
+
208
+ def header_metadata(env)
209
+ env.each_with_object({}) do |(key, value), metadata|
210
+ next unless key.start_with?("HTTP_X_AMZ_META_")
211
+
212
+ metadata[key.delete_prefix("HTTP_X_AMZ_META_").downcase.tr("_", "-")] = value
213
+ end
214
+ end
215
+
216
+ def form_metadata(params)
217
+ params.each_with_object({}) do |(key, value), metadata|
218
+ next unless key.to_s.downcase.start_with?("x-amz-meta-")
219
+
220
+ metadata[key.to_s.downcase.delete_prefix("x-amz-meta-")] = value
221
+ end
222
+ end
223
+
224
+ def expand_post_key(key, file)
225
+ filename = file[:filename] || file["filename"] || ""
226
+ key.to_s.gsub("${filename}", filename)
227
+ end
228
+
229
+ def success_action_status(params)
230
+ status = params["success_action_status"].to_i
231
+ [200, 201, 204].include?(status) ? status : 204
232
+ end
233
+
234
+ def delete_keys(xml)
235
+ document = REXML::Document.new(xml)
236
+ keys = []
237
+ collect_delete_keys(document.root, keys)
238
+ keys
239
+ end
240
+
241
+ def collect_delete_keys(element, keys)
242
+ return unless element
243
+
244
+ element.elements.each do |child|
245
+ keys << child.text.to_s if child.name == "Key"
246
+ collect_delete_keys(child, keys)
247
+ end
248
+ end
249
+
250
+ def query_has?(request, name)
251
+ request.query_string.split("&").any? do |part|
252
+ part == name || part.start_with?("#{name}=")
253
+ end
254
+ end
255
+
256
+ def xml_response(body, status = 200, headers = {})
257
+ response(status, body, {"Content-Type" => XML_CONTENT_TYPE}.merge(headers))
258
+ end
259
+
260
+ def empty_response(status, headers = {})
261
+ response(status, "", headers)
262
+ end
263
+
264
+ def error_response(code, message, status, head: false)
265
+ body = head ? "" : XMLResponse.error(code, message)
266
+ xml_response(body, status)
267
+ end
268
+
269
+ def options_response
270
+ empty_response(204)
271
+ end
272
+
273
+ def response(status, body, headers = {})
274
+ final_headers = cors_headers.merge(headers)
275
+ final_headers["Content-Length"] ||= body.bytesize.to_s if body.is_a?(String)
276
+
277
+ [status, final_headers, [body]]
278
+ end
279
+
280
+ def cors_headers
281
+ {
282
+ "Access-Control-Allow-Origin" => "*",
283
+ "Access-Control-Allow-Methods" => "GET, HEAD, PUT, POST, DELETE, OPTIONS",
284
+ "Access-Control-Allow-Headers" => "*",
285
+ "Access-Control-Expose-Headers" => "ETag, Last-Modified, x-amz-request-id, x-amz-id-2"
286
+ }
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "app"
6
+ require_relative "object_store"
7
+ require_relative "server"
8
+ require_relative "version"
9
+
10
+ module Bucketrb
11
+ class CLI
12
+ DEFAULT_HOST = "127.0.0.1"
13
+ DEFAULT_PORT = 4566
14
+ DEFAULT_ROOT = "data/bucketrb"
15
+
16
+ BUCKET_ENV_PATTERN = /(?:^|_)(?:S3_)?BUCKET$/
17
+
18
+ def initialize(argv)
19
+ @argv = argv.dup
20
+ end
21
+
22
+ def run
23
+ command = @argv.shift
24
+
25
+ case command
26
+ when "serve"
27
+ serve
28
+ when "version", "--version", "-v"
29
+ puts Bucketrb::VERSION
30
+ 0
31
+ when "help", "--help", "-h", nil
32
+ puts parser
33
+ 0
34
+ else
35
+ warn "Unknown command: #{command}"
36
+ warn parser
37
+ 1
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def serve
44
+ options = {
45
+ host: DEFAULT_HOST,
46
+ port: DEFAULT_PORT,
47
+ root: DEFAULT_ROOT,
48
+ buckets: [],
49
+ buckets_from_env: false
50
+ }
51
+
52
+ parser(options).parse!(@argv)
53
+
54
+ buckets = options[:buckets]
55
+ buckets |= env_buckets if options[:buckets_from_env]
56
+
57
+ store = Bucketrb::ObjectStore.new(options[:root])
58
+ buckets.each { |bucket| store.create_bucket(bucket) }
59
+
60
+ app = Bucketrb::App.new(store: store)
61
+ server = Bucketrb::Server.new(app: app, host: options[:host], port: options[:port])
62
+
63
+ puts "Bucketrb #{Bucketrb::VERSION} listening on http://#{options[:host]}:#{options[:port]}"
64
+ puts "Storage root: #{File.expand_path(options[:root])}"
65
+ puts "Buckets: #{buckets.sort.join(", ")}" unless buckets.empty?
66
+
67
+ server.start
68
+ 0
69
+ end
70
+
71
+ def parser(options = nil)
72
+ OptionParser.new do |opts|
73
+ opts.banner = "Usage: bucketrb serve [options]"
74
+
75
+ opts.on("--host HOST", "Bind host. Default: #{DEFAULT_HOST}") do |host|
76
+ options[:host] = host
77
+ end
78
+
79
+ opts.on("--port PORT", Integer, "Bind port. Default: #{DEFAULT_PORT}") do |port|
80
+ options[:port] = port
81
+ end
82
+
83
+ opts.on("--root PATH", "Filesystem storage root. Default: #{DEFAULT_ROOT}") do |root|
84
+ options[:root] = root
85
+ end
86
+
87
+ opts.on("--bucket NAME", "Create a bucket at startup. May be repeated.") do |bucket|
88
+ options[:buckets] << bucket
89
+ end
90
+
91
+ opts.on("--buckets-from-env", "Create buckets from *_BUCKET and *_S3_BUCKET env vars.") do
92
+ options[:buckets_from_env] = true
93
+ end
94
+ end
95
+ end
96
+
97
+ def env_buckets
98
+ ENV.each_with_object([]) do |(key, value), buckets|
99
+ next unless key.match?(BUCKET_ENV_PATTERN)
100
+ next if value.nil? || value.empty?
101
+ next if value.start_with?("s3://", "gs://", "http://", "https://")
102
+
103
+ buckets << value
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bucketrb
4
+ class Error < StandardError; end
5
+
6
+ class InvalidBucketNameError < Error; end
7
+ class InvalidObjectKeyError < Error; end
8
+ class BucketNotFoundError < Error; end
9
+ class ObjectNotFoundError < Error; end
10
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require "securerandom"
8
+ require "time"
9
+
10
+ require_relative "errors"
11
+
12
+ module Bucketrb
13
+ class ObjectStore
14
+ ObjectEntry = Struct.new(
15
+ :bucket,
16
+ :key,
17
+ :path,
18
+ :etag,
19
+ :size,
20
+ :last_modified,
21
+ :content_type,
22
+ :metadata,
23
+ keyword_init: true
24
+ ) do
25
+ def quoted_etag
26
+ %("#{etag}")
27
+ end
28
+ end
29
+
30
+ ListResult = Struct.new(
31
+ :bucket,
32
+ :prefix,
33
+ :marker,
34
+ :max_keys,
35
+ :objects,
36
+ :is_truncated,
37
+ :next_marker,
38
+ :next_continuation_token,
39
+ keyword_init: true
40
+ )
41
+
42
+ DEFAULT_MAX_KEYS = 1000
43
+
44
+ attr_reader :root
45
+
46
+ def initialize(root)
47
+ @root = Pathname.new(root).expand_path
48
+ FileUtils.mkdir_p(@root)
49
+ end
50
+
51
+ def create_bucket(bucket)
52
+ validate_bucket!(bucket)
53
+
54
+ FileUtils.mkdir_p(objects_dir(bucket))
55
+ FileUtils.mkdir_p(metadata_dir(bucket))
56
+ true
57
+ end
58
+
59
+ def buckets
60
+ return [] unless root.directory?
61
+
62
+ root.children.select { |path| path.directory? && path.join("objects").directory? }.map { |path| path.basename.to_s }.sort
63
+ end
64
+
65
+ def bucket?(bucket)
66
+ bucket_dir(bucket).join("objects").directory?
67
+ end
68
+
69
+ def ensure_bucket!(bucket)
70
+ validate_bucket!(bucket)
71
+ raise BucketNotFoundError, "No such bucket: #{bucket}" unless bucket?(bucket)
72
+ end
73
+
74
+ def put_object(bucket:, key:, body:, content_type: nil, metadata: {})
75
+ ensure_bucket!(bucket)
76
+ validate_key!(key)
77
+
78
+ path = object_path(bucket, key)
79
+ FileUtils.mkdir_p(path.dirname)
80
+
81
+ tmp_path = Pathname.new("#{path}.tmp-#{SecureRandom.hex(8)}")
82
+ File.open(tmp_path, "wb") do |file|
83
+ if body.respond_to?(:read)
84
+ IO.copy_stream(body, file)
85
+ else
86
+ file.write(body.to_s)
87
+ end
88
+ end
89
+ FileUtils.mv(tmp_path, path)
90
+
91
+ entry = build_entry(
92
+ bucket: bucket,
93
+ key: key,
94
+ path: path,
95
+ content_type: content_type || "application/octet-stream",
96
+ metadata: normalize_metadata(metadata),
97
+ etag: Digest::MD5.file(path).hexdigest,
98
+ last_modified: Time.now.utc
99
+ )
100
+ write_metadata(entry)
101
+ entry
102
+ ensure
103
+ FileUtils.rm_f(tmp_path) if tmp_path && tmp_path.exist?
104
+ end
105
+
106
+ def get_object(bucket:, key:)
107
+ ensure_bucket!(bucket)
108
+ entry_for(bucket, key)
109
+ end
110
+
111
+ def head_object(bucket:, key:)
112
+ get_object(bucket: bucket, key: key)
113
+ end
114
+
115
+ def delete_object(bucket:, key:)
116
+ ensure_bucket!(bucket)
117
+ validate_key!(key)
118
+
119
+ path = object_path(bucket, key)
120
+ FileUtils.rm_f(path)
121
+ FileUtils.rm_f(metadata_path(bucket, key))
122
+ true
123
+ end
124
+
125
+ def delete_objects(bucket:, keys:)
126
+ keys.each { |key| delete_object(bucket: bucket, key: key) }
127
+ keys
128
+ end
129
+
130
+ def copy_object(source_bucket:, source_key:, target_bucket:, target_key:)
131
+ source = get_object(bucket: source_bucket, key: source_key)
132
+
133
+ File.open(source.path, "rb") do |body|
134
+ put_object(
135
+ bucket: target_bucket,
136
+ key: target_key,
137
+ body: body,
138
+ content_type: source.content_type,
139
+ metadata: source.metadata
140
+ )
141
+ end
142
+ end
143
+
144
+ def list_objects(bucket:, prefix: "", marker: nil, continuation_token: nil, max_keys: DEFAULT_MAX_KEYS)
145
+ ensure_bucket!(bucket)
146
+
147
+ prefix = prefix.to_s
148
+ max_keys = max_keys.to_i
149
+ max_keys = DEFAULT_MAX_KEYS if max_keys <= 0
150
+ start_after = continuation_token || marker
151
+
152
+ keys = object_keys(bucket).select { |key| key.start_with?(prefix) }.sort
153
+ keys = keys.drop_while { |key| key <= start_after } if start_after && !start_after.empty?
154
+
155
+ limited_keys = keys.first(max_keys)
156
+ is_truncated = keys.length > limited_keys.length
157
+ objects = limited_keys.map { |key| entry_for(bucket, key) }
158
+ next_token = is_truncated && objects.any? ? objects.last.key : nil
159
+
160
+ ListResult.new(
161
+ bucket: bucket,
162
+ prefix: prefix,
163
+ marker: marker,
164
+ max_keys: max_keys,
165
+ objects: objects,
166
+ is_truncated: is_truncated,
167
+ next_marker: next_token,
168
+ next_continuation_token: next_token
169
+ )
170
+ end
171
+
172
+ private
173
+
174
+ def validate_bucket!(bucket)
175
+ unless bucket && !bucket.empty? && !bucket.include?("/") && bucket != "." && bucket != ".."
176
+ raise InvalidBucketNameError, "Invalid bucket name: #{bucket.inspect}"
177
+ end
178
+ end
179
+
180
+ def validate_key!(key)
181
+ unless key && !key.empty?
182
+ raise InvalidObjectKeyError, "Invalid object key: #{key.inspect}"
183
+ end
184
+ end
185
+
186
+ def bucket_dir(bucket)
187
+ root.join(bucket)
188
+ end
189
+
190
+ def objects_dir(bucket)
191
+ bucket_dir(bucket).join("objects")
192
+ end
193
+
194
+ def metadata_dir(bucket)
195
+ bucket_dir(bucket).join("metadata")
196
+ end
197
+
198
+ def object_path(bucket, key)
199
+ validate_key!(key)
200
+
201
+ base = objects_dir(bucket).expand_path
202
+ path = base.join(key).cleanpath.expand_path
203
+ unless path.to_s == base.to_s || path.to_s.start_with?("#{base}#{File::SEPARATOR}")
204
+ raise InvalidObjectKeyError, "Object key escapes bucket root: #{key.inspect}"
205
+ end
206
+
207
+ path
208
+ end
209
+
210
+ def metadata_path(bucket, key)
211
+ metadata_dir(bucket).join("#{Digest::SHA256.hexdigest(key)}.json")
212
+ end
213
+
214
+ def object_keys(bucket)
215
+ base = objects_dir(bucket)
216
+ return [] unless base.directory?
217
+
218
+ Dir.glob(base.join("**", "*"), File::FNM_DOTMATCH)
219
+ .map { |path| Pathname.new(path) }
220
+ .reject { |path| path.directory? || [".", ".."].include?(path.basename.to_s) }
221
+ .map { |path| path.relative_path_from(base).to_s }
222
+ end
223
+
224
+ def entry_for(bucket, key)
225
+ validate_key!(key)
226
+
227
+ path = object_path(bucket, key)
228
+ raise ObjectNotFoundError, "No such key: #{key}" unless path.file?
229
+
230
+ metadata = read_metadata(bucket, key)
231
+ build_entry(
232
+ bucket: bucket,
233
+ key: key,
234
+ path: path,
235
+ content_type: metadata.fetch("content_type", "application/octet-stream"),
236
+ metadata: metadata.fetch("metadata", {}),
237
+ etag: metadata.fetch("etag") { Digest::MD5.file(path).hexdigest },
238
+ last_modified: Time.parse(metadata.fetch("last_modified") { path.mtime.utc.iso8601 })
239
+ )
240
+ end
241
+
242
+ def build_entry(bucket:, key:, path:, content_type:, metadata:, etag:, last_modified:)
243
+ ObjectEntry.new(
244
+ bucket: bucket,
245
+ key: key,
246
+ path: path,
247
+ etag: etag,
248
+ size: path.size,
249
+ last_modified: last_modified.utc,
250
+ content_type: content_type,
251
+ metadata: metadata
252
+ )
253
+ end
254
+
255
+ def read_metadata(bucket, key)
256
+ path = metadata_path(bucket, key)
257
+ return {} unless path.file?
258
+
259
+ JSON.parse(path.read)
260
+ rescue JSON::ParserError
261
+ {}
262
+ end
263
+
264
+ def write_metadata(entry)
265
+ path = metadata_path(entry.bucket, entry.key)
266
+ FileUtils.mkdir_p(path.dirname)
267
+ path.write(
268
+ JSON.pretty_generate(
269
+ "key" => entry.key,
270
+ "etag" => entry.etag,
271
+ "content_type" => entry.content_type,
272
+ "metadata" => entry.metadata,
273
+ "last_modified" => entry.last_modified.utc.iso8601(3)
274
+ )
275
+ )
276
+ end
277
+
278
+ def normalize_metadata(metadata)
279
+ metadata.each_with_object({}) do |(key, value), normalized|
280
+ normalized[key.to_s.downcase] = value.to_s
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/handler/webrick"
4
+ require "webrick"
5
+
6
+ module Bucketrb
7
+ class Server
8
+ attr_reader :host, :port
9
+
10
+ def initialize(app:, host:, port:, logger: nil)
11
+ @app = app
12
+ @host = host
13
+ @port = port
14
+ @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::INFO)
15
+ @server = build_server
16
+ end
17
+
18
+ def start
19
+ install_signal_traps if Thread.current == Thread.main
20
+ @server.start
21
+ end
22
+
23
+ def shutdown
24
+ @server.shutdown
25
+ end
26
+
27
+ private
28
+
29
+ def install_signal_traps
30
+ trap("INT") { shutdown }
31
+ trap("TERM") { shutdown }
32
+ end
33
+
34
+ def build_server
35
+ WEBrick::HTTPServer.new(
36
+ BindAddress: host,
37
+ Port: port,
38
+ AccessLog: [],
39
+ Logger: @logger
40
+ ).tap do |server|
41
+ server.mount("/", Rack::Handler::WEBrick, @app)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bucketrb
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "time"
5
+
6
+ module Bucketrb
7
+ module XMLResponse
8
+ module_function
9
+
10
+ def list_all_my_buckets(buckets)
11
+ bucket_xml = buckets.map do |bucket|
12
+ <<~XML
13
+ <Bucket>
14
+ <Name>#{escape(bucket)}</Name>
15
+ <CreationDate>#{timestamp(Time.now.utc)}</CreationDate>
16
+ </Bucket>
17
+ XML
18
+ end.join
19
+
20
+ <<~XML
21
+ <?xml version="1.0" encoding="UTF-8"?>
22
+ <ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
23
+ <Owner>
24
+ <ID>bucketrb</ID>
25
+ <DisplayName>bucketrb</DisplayName>
26
+ </Owner>
27
+ <Buckets>
28
+ #{bucket_xml}
29
+ </Buckets>
30
+ </ListAllMyBucketsResult>
31
+ XML
32
+ end
33
+
34
+ def list_bucket(result, version: 1)
35
+ contents = result.objects.map { |entry| content(entry) }.join
36
+
37
+ if version == 2
38
+ <<~XML
39
+ <?xml version="1.0" encoding="UTF-8"?>
40
+ <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
41
+ <Name>#{escape(result.bucket)}</Name>
42
+ <Prefix>#{escape(result.prefix)}</Prefix>
43
+ <KeyCount>#{result.objects.length}</KeyCount>
44
+ <MaxKeys>#{result.max_keys}</MaxKeys>
45
+ <IsTruncated>#{result.is_truncated}</IsTruncated>
46
+ #{continuation_token_xml(result)}
47
+ #{contents}
48
+ </ListBucketResult>
49
+ XML
50
+ else
51
+ <<~XML
52
+ <?xml version="1.0" encoding="UTF-8"?>
53
+ <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
54
+ <Name>#{escape(result.bucket)}</Name>
55
+ <Prefix>#{escape(result.prefix)}</Prefix>
56
+ <Marker>#{escape(result.marker)}</Marker>
57
+ <MaxKeys>#{result.max_keys}</MaxKeys>
58
+ <IsTruncated>#{result.is_truncated}</IsTruncated>
59
+ #{next_marker_xml(result)}
60
+ #{contents}
61
+ </ListBucketResult>
62
+ XML
63
+ end
64
+ end
65
+
66
+ def delete_result(keys)
67
+ deleted = keys.map do |key|
68
+ <<~XML
69
+ <Deleted>
70
+ <Key>#{escape(key)}</Key>
71
+ </Deleted>
72
+ XML
73
+ end.join
74
+
75
+ <<~XML
76
+ <?xml version="1.0" encoding="UTF-8"?>
77
+ <DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
78
+ #{deleted}
79
+ </DeleteResult>
80
+ XML
81
+ end
82
+
83
+ def copy_result(entry)
84
+ <<~XML
85
+ <?xml version="1.0" encoding="UTF-8"?>
86
+ <CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
87
+ <LastModified>#{timestamp(entry.last_modified)}</LastModified>
88
+ <ETag>#{entry.quoted_etag}</ETag>
89
+ </CopyObjectResult>
90
+ XML
91
+ end
92
+
93
+ def error(code, message)
94
+ <<~XML
95
+ <?xml version="1.0" encoding="UTF-8"?>
96
+ <Error>
97
+ <Code>#{escape(code)}</Code>
98
+ <Message>#{escape(message)}</Message>
99
+ <RequestId>bucketrb</RequestId>
100
+ </Error>
101
+ XML
102
+ end
103
+
104
+ def content(entry)
105
+ <<~XML
106
+ <Contents>
107
+ <Key>#{escape(entry.key)}</Key>
108
+ <LastModified>#{timestamp(entry.last_modified)}</LastModified>
109
+ <ETag>#{entry.quoted_etag}</ETag>
110
+ <Size>#{entry.size}</Size>
111
+ <StorageClass>STANDARD</StorageClass>
112
+ </Contents>
113
+ XML
114
+ end
115
+
116
+ def timestamp(time)
117
+ time.utc.iso8601(3)
118
+ end
119
+
120
+ def escape(value)
121
+ CGI.escapeHTML(value.to_s)
122
+ end
123
+
124
+ def next_marker_xml(result)
125
+ return "" unless result.next_marker
126
+
127
+ "<NextMarker>#{escape(result.next_marker)}</NextMarker>"
128
+ end
129
+
130
+ def continuation_token_xml(result)
131
+ return "" unless result.next_continuation_token
132
+
133
+ "<NextContinuationToken>#{escape(result.next_continuation_token)}</NextContinuationToken>"
134
+ end
135
+ end
136
+ end
data/lib/bucketrb.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bucketrb/version"
4
+ require_relative "bucketrb/app"
5
+ require_relative "bucketrb/object_store"
6
+ require_relative "bucketrb/server"
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bucketrb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Bucketrb contributors
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rexml
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '3.2'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '4'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.2'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '4'
52
+ - !ruby/object:Gem::Dependency
53
+ name: webrick
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '1.8'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '1.8'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '2'
72
+ - !ruby/object:Gem::Dependency
73
+ name: aws-sdk-s3
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '1.190'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '2'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.190'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '2'
92
+ - !ruby/object:Gem::Dependency
93
+ name: minitest
94
+ requirement: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '5.20'
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '6'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '5.20'
109
+ - - "<"
110
+ - !ruby/object:Gem::Version
111
+ version: '6'
112
+ - !ruby/object:Gem::Dependency
113
+ name: rake
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '13'
119
+ - - "<"
120
+ - !ruby/object:Gem::Version
121
+ version: '14'
122
+ type: :development
123
+ prerelease: false
124
+ version_requirements: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '13'
129
+ - - "<"
130
+ - !ruby/object:Gem::Version
131
+ version: '14'
132
+ description: Bucketrb is a lightweight local S3-compatible HTTP server backed by the
133
+ filesystem.
134
+ email: []
135
+ executables:
136
+ - bucketrb
137
+ extensions: []
138
+ extra_rdoc_files: []
139
+ files:
140
+ - LICENSE.txt
141
+ - README.md
142
+ - ROADMAP.md
143
+ - exe/bucketrb
144
+ - lib/bucketrb.rb
145
+ - lib/bucketrb/app.rb
146
+ - lib/bucketrb/cli.rb
147
+ - lib/bucketrb/errors.rb
148
+ - lib/bucketrb/object_store.rb
149
+ - lib/bucketrb/server.rb
150
+ - lib/bucketrb/version.rb
151
+ - lib/bucketrb/xml_response.rb
152
+ homepage: https://rubygems.org/gems/bucketrb
153
+ licenses:
154
+ - MIT
155
+ metadata:
156
+ allowed_push_host: https://rubygems.org
157
+ homepage_uri: https://rubygems.org/gems/bucketrb
158
+ rubygems_mfa_required: 'true'
159
+ rdoc_options: []
160
+ require_paths:
161
+ - lib
162
+ required_ruby_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '3.2'
167
+ required_rubygems_version: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - ">="
170
+ - !ruby/object:Gem::Version
171
+ version: '0'
172
+ requirements: []
173
+ rubygems_version: 3.6.9
174
+ specification_version: 4
175
+ summary: A small local S3-compatible server for Ruby development.
176
+ test_files: []