| 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. |
| 4 | module 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] |
| 10 | pub struct BucketOptions { |
| 11 | pub: |
| 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. |
| 25 | pub 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. |
| 56 | pub 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. |
| 74 | pub 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. |
| 103 | pub 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 | |
| 134 | fn is_lower_alnum(b u8) bool { |
| 135 | return (b >= `a` && b <= `z`) || (b >= `0` && b <= `9`) |
| 136 | } |
| 137 | |
| 138 | fn is_bucket_char(b u8) bool { |
| 139 | return is_lower_alnum(b) || b == `-` || b == `.` |
| 140 | } |
| 141 | |
| 142 | fn 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 | |
| 160 | fn 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 | |
| 169 | fn 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. |
| 179 | fn 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 | |