v / vlib / net / s3 / bucket.v
192 lines · 181 sloc · 5.86 KB · 4142432483c4e8de44ab7b0d6ac944f3251e03c8
Raw
1// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved.
2// Use of this source code is governed by an MIT license
3// that can be found in the LICENSE file.
4module s3
5
6// BucketOptions configures bucket-level operations. `region_constraint`
7// lets `create_bucket` pin the bucket to a specific region (S3 sends a
8// `<CreateBucketConfiguration>` body when this is non-empty).
9@[params]
10pub struct BucketOptions {
11pub:
12 bucket string
13 acl Acl
14 region_constraint string
15}
16
17// create_bucket creates a new bucket. Returns:
18// - nil on success (HTTP 200)
19// - S3Error("BucketAlreadyOwnedByYou") if you already own this bucket
20// - S3Error("BucketAlreadyExists") if someone else owns it
21// - S3Error("InvalidBucketName") for non-conformant names
22//
23// The S3 wire response for these states is HTTP 409, parsed from the
24// returned XML body.
25pub fn (c &Client) create_bucket(opts BucketOptions) ! {
26 bucket := pick_bucket(c.credentials, opts.bucket)!
27 validate_bucket_name(bucket)!
28 creds := c.creds_for(bucket)
29 path := bucket_path(creds, bucket)
30 body := if opts.region_constraint != '' {
31 '<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LocationConstraint>${escape_xml(opts.region_constraint)}</LocationConstraint></CreateBucketConfiguration>'
32 } else {
33 ''
34 }
35 mut headers := map[string]string{}
36 if opts.acl != .unset {
37 headers['x-amz-acl'] = opts.acl.to_header_value()
38 }
39 if body != '' {
40 headers['content-type'] = 'application/xml'
41 }
42 signed := sign_request(creds, SignRequest{
43 method: 'PUT'
44 path: path
45 payload_hash: if body == '' { empty_sha256 } else { sha256_hex(body.bytes()) }
46 extra_headers: headers
47 })!
48 resp := c.do_http(signed, body)!
49 if resp.status_code !in [200, 204] {
50 return new_http_error(resp.status_code, bucket, resp.body)
51 }
52}
53
54// delete_bucket removes an empty bucket. S3 returns 409 BucketNotEmpty if it
55// still has keys — caller is expected to clean up first or handle the error.
56pub fn (c &Client) delete_bucket(opts BucketOptions) ! {
57 bucket := pick_bucket(c.credentials, opts.bucket)!
58 creds := c.creds_for(bucket)
59 path := bucket_path(creds, bucket)
60 signed := sign_request(creds, SignRequest{
61 method: 'DELETE'
62 path: path
63 payload_hash: empty_sha256
64 })!
65 resp := c.do_http(signed, '')!
66 if resp.status_code !in [200, 204] {
67 return new_http_error(resp.status_code, bucket, resp.body)
68 }
69}
70
71// bucket_exists checks bucket existence/access. Returns true if accessible (200),
72// false on 404 / 403 (no such bucket or no read permission), error on other
73// statuses. Uses HEAD under the hood — no body is fetched.
74pub fn (c &Client) bucket_exists(opts BucketOptions) !bool {
75 bucket := pick_bucket(c.credentials, opts.bucket)!
76 creds := c.creds_for(bucket)
77 path := bucket_path(creds, bucket)
78 signed := sign_request(creds, SignRequest{
79 method: 'HEAD'
80 path: path
81 payload_hash: empty_sha256
82 })!
83 resp := c.do_http(signed, '')!
84 if resp.status_code == 404 || resp.status_code == 403 {
85 return false
86 }
87 if resp.status_code != 200 {
88 return new_http_error(resp.status_code, bucket, resp.body)
89 }
90 return true
91}
92
93// validate_bucket_name applies the S3 bucket naming rules honoured by most
94// providers:
95// - 3..63 chars
96// - lowercase letters, digits, dots, hyphens only
97// - must start/end with letter or digit
98// - no consecutive dots, no `.-` / `-.`
99// - cannot look like an IPv4 address
100//
101// Provider-specific reservations (e.g. `xn--`, `sthree-`) are intentionally
102// not checked here — they vary, so the server-side error is authoritative.
103pub fn validate_bucket_name(name string) ! {
104 if name.len < 3 || name.len > 63 {
105 return new_error('InvalidBucketName', 'Bucket name must be 3..63 characters: ${name}')
106 }
107 first := name[0]
108 last := name[name.len - 1]
109 if !is_lower_alnum(first) || !is_lower_alnum(last) {
110 return new_error('InvalidBucketName',
111 'Bucket name must start and end with a lowercase letter or digit: ${name}')
112 }
113 mut prev := u8(0)
114 for b in name.bytes() {
115 if !is_bucket_char(b) {
116 return new_error('InvalidBucketName', 'Bucket name "${name}" contains invalid character: ${[
117 b,
118 ].bytestr()}')
119 }
120 if prev == `.` && b == `.` {
121 return new_error('InvalidBucketName', 'Bucket name "${name}" contains consecutive dots')
122 }
123 if (prev == `.` && b == `-`) || (prev == `-` && b == `.`) {
124 return new_error('InvalidBucketName',
125 'Bucket name "${name}" contains adjacent dot/hyphen')
126 }
127 prev = b
128 }
129 if looks_like_ipv4(name) {
130 return new_error('InvalidBucketName', 'Bucket name "${name}" looks like an IP address')
131 }
132}
133
134fn is_lower_alnum(b u8) bool {
135 return (b >= `a` && b <= `z`) || (b >= `0` && b <= `9`)
136}
137
138fn is_bucket_char(b u8) bool {
139 return is_lower_alnum(b) || b == `-` || b == `.`
140}
141
142fn looks_like_ipv4(s string) bool {
143 parts := s.split('.')
144 if parts.len != 4 {
145 return false
146 }
147 for p in parts {
148 if p.len == 0 || p.len > 3 {
149 return false
150 }
151 for b in p.bytes() {
152 if b < `0` || b > `9` {
153 return false
154 }
155 }
156 }
157 return true
158}
159
160fn pick_bucket(c Credentials, override string) !string {
161 b := if override != '' { override } else { c.bucket }
162 if b == '' {
163 return new_error('InvalidPath',
164 'No bucket given (set Credentials.bucket or pass bucket via options)')
165 }
166 return b
167}
168
169fn bucket_path(creds Credentials, bucket string) string {
170 extra := creds.extra_path()
171 if creds.virtual_hosted_style {
172 return if extra == '' { '/' } else { extra }
173 }
174 encoded := uri_encode_path(strip_slashes(bucket))
175 return '${extra}/${encoded}'
176}
177
178// escape_xml does the minimal XML special-char escape we need for body content.
179fn escape_xml(s string) string {
180 mut out := []u8{cap: s.len}
181 for b in s.bytes() {
182 match b {
183 `<` { out << '<'.bytes() }
184 `>` { out << '>'.bytes() }
185 `&` { out << '&'.bytes() }
186 `"` { out << '"'.bytes() }
187 `'` { out << '''.bytes() }
188 else { out << b }
189 }
190 }
191 return out.bytestr()
192}
193