| 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 | // S3Error is the structured error returned for every signing or service |
| 7 | // failure. `code` is stable (e.g. `NoSuchKey`, `MissingCredentials`), |
| 8 | // `message` is human-readable, `status` is the HTTP status (0 for |
| 9 | // client-side errors), and `path` is the offending object key when known. |
| 10 | // |
| 11 | // V's `IError` interface only requires `msg()` and `code()`, so this type can |
| 12 | // be returned via `!T` and inspected with type-assertion / `as`. |
| 13 | pub struct S3Error { |
| 14 | pub: |
| 15 | code string |
| 16 | message string |
| 17 | status int |
| 18 | path string |
| 19 | resource string // S3's <Resource> field, when present |
| 20 | request_id string // S3 RequestId, useful for support tickets |
| 21 | } |
| 22 | |
| 23 | // msg renders the error in a single line. Includes the S3 error code so users |
| 24 | // can switch on it without parsing the prose. Path is appended when known. |
| 25 | pub fn (e &S3Error) msg() string { |
| 26 | mut buf := '[${e.code}] ${e.message}' |
| 27 | if e.status != 0 { |
| 28 | buf += ' (HTTP ${e.status})' |
| 29 | } |
| 30 | if e.path != '' { |
| 31 | buf += ' — ${e.path}' |
| 32 | } |
| 33 | return buf |
| 34 | } |
| 35 | |
| 36 | // code returns a stable numeric code so callers can use `if err.code() == ...`. |
| 37 | // We map a few well-known S3 codes; everything else returns 0 so callers fall |
| 38 | // back to string comparison on `e.code`. |
| 39 | pub fn (e &S3Error) code() int { |
| 40 | return match e.code { |
| 41 | 'NoSuchKey' { |
| 42 | 404 |
| 43 | } |
| 44 | 'NoSuchBucket' { |
| 45 | 404 |
| 46 | } |
| 47 | 'BucketAlreadyExists', 'BucketAlreadyOwnedByYou' { |
| 48 | 409 |
| 49 | } |
| 50 | 'AccessDenied', 'SignatureDoesNotMatch' { |
| 51 | 403 |
| 52 | } |
| 53 | 'MissingCredentials', 'InvalidEndpoint', 'InvalidPath', 'InvalidMethod', |
| 54 | 'InvalidSessionToken' { |
| 55 | 400 |
| 56 | } |
| 57 | else { |
| 58 | e.status |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // new_error builds an S3Error from a code + message. Use this for client-side |
| 64 | // validation failures (missing creds, invalid path, etc.). |
| 65 | pub fn new_error(code string, message string) IError { |
| 66 | return &S3Error{ |
| 67 | code: code |
| 68 | message: message |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // new_http_error wraps an HTTP-level failure. Body is the raw response body |
| 73 | // — `parse_xml_error` is responsible for digging out the structured |
| 74 | // `<Error>` envelope when the server returns one. |
| 75 | pub fn new_http_error(status int, path string, body string) IError { |
| 76 | parsed := parse_xml_error(body) |
| 77 | if parsed.code != '' { |
| 78 | return &S3Error{ |
| 79 | code: parsed.code |
| 80 | message: parsed.message |
| 81 | status: status |
| 82 | path: path |
| 83 | resource: parsed.resource |
| 84 | request_id: parsed.request_id |
| 85 | } |
| 86 | } |
| 87 | // Body was empty or unparseable — fall back to a generic code keyed by status. |
| 88 | return &S3Error{ |
| 89 | code: fallback_code_for(status) |
| 90 | message: if body.len > 0 { body } else { 'HTTP ${status}' } |
| 91 | status: status |
| 92 | path: path |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // XmlErrorFields is what we pull out of an `<Error>` XML envelope. |
| 97 | struct XmlErrorFields { |
| 98 | code string |
| 99 | message string |
| 100 | resource string |
| 101 | request_id string |
| 102 | } |
| 103 | |
| 104 | // parse_xml_error extracts the standard S3 error XML: |
| 105 | // |
| 106 | // <Error> |
| 107 | // <Code>...</Code> |
| 108 | // <Message>...</Message> |
| 109 | // <Resource>...</Resource> |
| 110 | // <RequestId>...</RequestId> |
| 111 | // </Error> |
| 112 | // |
| 113 | // We use a tiny tag scanner (no DOM) — the format is rigid enough that this |
| 114 | // stays correct, faster than spinning up the full XML parser, and there is no |
| 115 | // attribute parsing to worry about. |
| 116 | fn parse_xml_error(body string) XmlErrorFields { |
| 117 | return XmlErrorFields{ |
| 118 | code: extract_xml_tag(body, 'Code') |
| 119 | message: extract_xml_tag(body, 'Message') |
| 120 | resource: extract_xml_tag(body, 'Resource') |
| 121 | request_id: extract_xml_tag(body, 'RequestId') |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // extract_xml_tag returns the inner text of `<tag>...</tag>` (first match, |
| 126 | // case-sensitive) with the five predefined XML entities decoded, or '' if |
| 127 | // the tag is absent. |
| 128 | pub fn extract_xml_tag(body string, tag string) string { |
| 129 | open := '<' + tag + '>' |
| 130 | close := '</' + tag + '>' |
| 131 | start := body.index(open) or { return '' } |
| 132 | rest_off := start + open.len |
| 133 | end := body.index_after(close, rest_off) or { return '' } |
| 134 | return decode_xml_entities(body[rest_off..end]) |
| 135 | } |
| 136 | |
| 137 | // decode_xml_entities decodes the five predefined XML entities. We don't try |
| 138 | // to handle arbitrary `nn;` sequences because S3 only ever uses these five. |
| 139 | pub fn decode_xml_entities(s string) string { |
| 140 | if !s.contains('&') { |
| 141 | return s |
| 142 | } |
| 143 | mut out := []u8{cap: s.len} |
| 144 | mut i := 0 |
| 145 | for i < s.len { |
| 146 | if s[i] != `&` { |
| 147 | out << s[i] |
| 148 | i++ |
| 149 | continue |
| 150 | } |
| 151 | matched := match true { |
| 152 | s.len - i >= 5 && s[i..i + 5] == '&' { |
| 153 | out << `&` |
| 154 | i += 5 |
| 155 | true |
| 156 | } |
| 157 | s.len - i >= 4 && s[i..i + 4] == '<' { |
| 158 | out << `<` |
| 159 | i += 4 |
| 160 | true |
| 161 | } |
| 162 | s.len - i >= 4 && s[i..i + 4] == '>' { |
| 163 | out << `>` |
| 164 | i += 4 |
| 165 | true |
| 166 | } |
| 167 | s.len - i >= 6 && s[i..i + 6] == '"' { |
| 168 | out << `"` |
| 169 | i += 6 |
| 170 | true |
| 171 | } |
| 172 | s.len - i >= 6 && s[i..i + 6] == ''' { |
| 173 | out << `'` |
| 174 | i += 6 |
| 175 | true |
| 176 | } |
| 177 | else { |
| 178 | false |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | if !matched { |
| 183 | out << s[i] |
| 184 | i++ |
| 185 | } |
| 186 | } |
| 187 | return out.bytestr() |
| 188 | } |
| 189 | |
| 190 | fn fallback_code_for(status int) string { |
| 191 | return match status { |
| 192 | 301, 307 { 'PermanentRedirect' } |
| 193 | 400 { 'BadRequest' } |
| 194 | 401, 403 { 'AccessDenied' } |
| 195 | 404 { 'NotFound' } |
| 196 | 405 { 'MethodNotAllowed' } |
| 197 | 409 { 'Conflict' } |
| 198 | 411 { 'LengthRequired' } |
| 199 | 412 { 'PreconditionFailed' } |
| 200 | 416 { 'InvalidRange' } |
| 201 | 429 { 'TooManyRequests' } |
| 202 | 500 { 'InternalError' } |
| 203 | 503 { 'ServiceUnavailable' } |
| 204 | else { 'HTTPError' } |
| 205 | } |
| 206 | } |
| 207 | |