| 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 | import os |
| 7 | |
| 8 | // Credentials carries the authentication material plus endpoint and addressing |
| 9 | // preferences. It is intentionally kept small; defaults (region, endpoint, etc.) |
| 10 | // are derived only when the request is signed, never stored implicitly, so the |
| 11 | // same Credentials value can be reused across regions/endpoints. |
| 12 | // |
| 13 | // Field naming matches V conventions (snake_case). The `from_env` helper |
| 14 | // recognises several provider conventions — see `from_env`. |
| 15 | pub struct Credentials { |
| 16 | pub: |
| 17 | access_key_id string |
| 18 | secret_access_key string |
| 19 | session_token string |
| 20 | region string |
| 21 | bucket string |
| 22 | endpoint string // 'https://s3.fr-par.scw.cloud' or 'host:port' — host part is what gets signed |
| 23 | virtual_hosted_style bool // when true, '<bucket>.<endpoint-host>' addressing |
| 24 | insecure_http bool // permit `http://` endpoints (false by default — never silently downgrades) |
| 25 | } |
| 26 | |
| 27 | // Credentials.from_env reads credentials from environment variables, trying several |
| 28 | // provider conventions in order so the same code works against many hosts |
| 29 | // without reconfiguration. Each field is resolved independently; the first |
| 30 | // non-empty value wins. |
| 31 | // |
| 32 | // Lookup order per field: |
| 33 | // key id : S3_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID, |
| 34 | // CELLAR_ADDON_KEY_ID, SCW_ACCESS_KEY, |
| 35 | // B2_APPLICATION_KEY_ID, R2_ACCESS_KEY_ID, SPACES_KEY |
| 36 | // secret : S3_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY, |
| 37 | // CELLAR_ADDON_KEY_SECRET, SCW_SECRET_KEY, |
| 38 | // B2_APPLICATION_KEY, R2_SECRET_ACCESS_KEY, SPACES_SECRET |
| 39 | // session : S3_SESSION_TOKEN, AWS_SESSION_TOKEN |
| 40 | // region : S3_REGION, AWS_REGION, AWS_DEFAULT_REGION, SCW_DEFAULT_REGION |
| 41 | // bucket : S3_BUCKET |
| 42 | // endpoint : S3_ENDPOINT, AWS_ENDPOINT, AWS_ENDPOINT_URL, |
| 43 | // CELLAR_ADDON_HOST, B2_ENDPOINT, R2_ENDPOINT, SPACES_ENDPOINT |
| 44 | pub fn Credentials.from_env() Credentials { |
| 45 | endpoint := env_first('S3_ENDPOINT', 'AWS_ENDPOINT', 'AWS_ENDPOINT_URL', 'CELLAR_ADDON_HOST', |
| 46 | 'B2_ENDPOINT', 'R2_ENDPOINT', 'SPACES_ENDPOINT') |
| 47 | insecure := endpoint.starts_with('http://') |
| 48 | return Credentials{ |
| 49 | access_key_id: env_first('S3_ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID', |
| 50 | 'CELLAR_ADDON_KEY_ID', 'SCW_ACCESS_KEY', 'B2_APPLICATION_KEY_ID', 'R2_ACCESS_KEY_ID', |
| 51 | 'SPACES_KEY') |
| 52 | secret_access_key: env_first('S3_SECRET_ACCESS_KEY', 'AWS_SECRET_ACCESS_KEY', |
| 53 | 'CELLAR_ADDON_KEY_SECRET', 'SCW_SECRET_KEY', 'B2_APPLICATION_KEY', |
| 54 | 'R2_SECRET_ACCESS_KEY', 'SPACES_SECRET') |
| 55 | session_token: env_first('S3_SESSION_TOKEN', 'AWS_SESSION_TOKEN') |
| 56 | region: env_first('S3_REGION', 'AWS_REGION', 'AWS_DEFAULT_REGION', |
| 57 | 'SCW_DEFAULT_REGION') |
| 58 | bucket: env_first('S3_BUCKET') |
| 59 | endpoint: endpoint |
| 60 | virtual_hosted_style: false |
| 61 | insecure_http: insecure |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // merge produces a copy of `c` with non-empty fields from `other` overriding. |
| 66 | // Useful when callers pass per-call overrides while keeping a default Client. |
| 67 | pub fn (c Credentials) merge(other Credentials) Credentials { |
| 68 | return Credentials{ |
| 69 | access_key_id: if other.access_key_id != '' { |
| 70 | other.access_key_id |
| 71 | } else { |
| 72 | c.access_key_id |
| 73 | } |
| 74 | secret_access_key: if other.secret_access_key != '' { |
| 75 | other.secret_access_key |
| 76 | } else { |
| 77 | c.secret_access_key |
| 78 | } |
| 79 | session_token: if other.session_token != '' { |
| 80 | other.session_token |
| 81 | } else { |
| 82 | c.session_token |
| 83 | } |
| 84 | region: if other.region != '' { other.region } else { c.region } |
| 85 | bucket: if other.bucket != '' { other.bucket } else { c.bucket } |
| 86 | endpoint: if other.endpoint != '' { other.endpoint } else { c.endpoint } |
| 87 | virtual_hosted_style: c.virtual_hosted_style || other.virtual_hosted_style |
| 88 | insecure_http: c.insecure_http || other.insecure_http |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // resolved_region returns the region to use for signing. Order: |
| 93 | // 1. explicit `c.region` |
| 94 | // 2. parsed from `c.endpoint` when it follows the `s3.<region>.amazonaws.com` pattern |
| 95 | // 3. `'auto'` for Cloudflare R2 |
| 96 | // 4. `'us-east-1'` (S3 historical default) when no endpoint is set |
| 97 | pub fn (c Credentials) resolved_region() string { |
| 98 | if c.region != '' { |
| 99 | return c.region |
| 100 | } |
| 101 | return guess_region(c.endpoint) |
| 102 | } |
| 103 | |
| 104 | // validate ensures the credentials carry the minimum needed to sign a |
| 105 | // request. Also rejects credentials / region / bucket / endpoint values that |
| 106 | // contain CR or LF — those would let an attacker who controls *any* config |
| 107 | // field smuggle headers into the Authorization line. |
| 108 | pub fn (c Credentials) validate() ! { |
| 109 | if c.access_key_id == '' || c.secret_access_key == '' { |
| 110 | return new_error('MissingCredentials', |
| 111 | "Missing S3 credentials. 'access_key_id' and 'secret_access_key' are required.") |
| 112 | } |
| 113 | for name, value in { |
| 114 | 'access_key_id': c.access_key_id |
| 115 | 'secret_access_key': c.secret_access_key |
| 116 | 'session_token': c.session_token |
| 117 | 'region': c.region |
| 118 | 'bucket': c.bucket |
| 119 | 'endpoint': c.endpoint |
| 120 | } { |
| 121 | if contains_crlf(value) { |
| 122 | return new_error('InvalidCredentials', |
| 123 | 'Credential field "${name}" contains CR or LF — refused (header-injection guard)') |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // host_only returns the bare host[:port] from `c.endpoint`, stripping any |
| 129 | // scheme and trailing path. Returned value is what gets signed in the |
| 130 | // `host` header for SigV4. |
| 131 | pub fn (c Credentials) host_only() string { |
| 132 | mut ep := c.endpoint |
| 133 | if i := ep.index('://') { |
| 134 | ep = ep[i + 3..] |
| 135 | } |
| 136 | if j := ep.index('/') { |
| 137 | ep = ep[..j] |
| 138 | } |
| 139 | return ep |
| 140 | } |
| 141 | |
| 142 | // extra_path returns the path component of `c.endpoint`, including any |
| 143 | // leading '/'. Useful for proxies that mount S3 under a sub-path. |
| 144 | pub fn (c Credentials) extra_path() string { |
| 145 | mut ep := c.endpoint |
| 146 | if i := ep.index('://') { |
| 147 | ep = ep[i + 3..] |
| 148 | } |
| 149 | if j := ep.index('/') { |
| 150 | return ep[j..] |
| 151 | } |
| 152 | return '' |
| 153 | } |
| 154 | |
| 155 | // scheme returns 'http' or 'https' based on the endpoint's explicit scheme |
| 156 | // when present, falling back to `insecure_http`. So all three of these work: |
| 157 | // endpoint: 'https://s3.example.com' → https |
| 158 | // endpoint: 's3.example.com' → https (default) |
| 159 | // endpoint: 'http://localhost:9000' → http (auto-detected) |
| 160 | // endpoint: 'localhost:9000', insecure_http: true → http |
| 161 | pub fn (c Credentials) scheme() string { |
| 162 | if c.endpoint.starts_with('http://') { |
| 163 | return 'http' |
| 164 | } |
| 165 | if c.endpoint.starts_with('https://') { |
| 166 | return 'https' |
| 167 | } |
| 168 | return if c.insecure_http { 'http' } else { 'https' } |
| 169 | } |
| 170 | |
| 171 | // guess_region derives the SigV4 region from an endpoint URL. Public so it |
| 172 | // can be reused by the higher-level Client for log / inspect output. |
| 173 | pub fn guess_region(endpoint string) string { |
| 174 | if endpoint == '' { |
| 175 | return 'us-east-1' |
| 176 | } |
| 177 | if endpoint.ends_with('.r2.cloudflarestorage.com') { |
| 178 | return 'auto' |
| 179 | } |
| 180 | if amz_end := endpoint.index('.amazonaws.com') { |
| 181 | if s3_pos := endpoint.index('s3.') { |
| 182 | start := s3_pos + 3 |
| 183 | if start < amz_end { |
| 184 | return endpoint[start..amz_end] |
| 185 | } |
| 186 | } |
| 187 | // AWS global endpoint (`s3.amazonaws.com`) and legacy forms |
| 188 | // (`s3-external-1.amazonaws.com`) sign with `us-east-1`. SigV4 |
| 189 | // rejects `auto` against AWS, so fall back here instead. |
| 190 | return 'us-east-1' |
| 191 | } |
| 192 | return 'auto' |
| 193 | } |
| 194 | |
| 195 | // env_first returns the first non-empty env var among the names provided. |
| 196 | fn env_first(names ...string) string { |
| 197 | for n in names { |
| 198 | v := os.getenv(n) |
| 199 | if v != '' { |
| 200 | return v |
| 201 | } |
| 202 | } |
| 203 | return '' |
| 204 | } |
| 205 | |