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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +128 -0
- data/ROADMAP.md +62 -0
- data/exe/bucketrb +8 -0
- data/lib/bucketrb/app.rb +289 -0
- data/lib/bucketrb/cli.rb +107 -0
- data/lib/bucketrb/errors.rb +10 -0
- data/lib/bucketrb/object_store.rb +284 -0
- data/lib/bucketrb/server.rb +45 -0
- data/lib/bucketrb/version.rb +5 -0
- data/lib/bucketrb/xml_response.rb +136 -0
- data/lib/bucketrb.rb +6 -0
- metadata +176 -0
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
data/lib/bucketrb/app.rb
ADDED
|
@@ -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
|
data/lib/bucketrb/cli.rb
ADDED
|
@@ -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,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
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: []
|