v / vlib / net / s3 / credentials.v
204 lines · 193 sloc · 7.39 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
6import 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`.
15pub struct Credentials {
16pub:
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
44pub 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.
67pub 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
97pub 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.
108pub 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.
131pub 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.
144pub 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
161pub 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.
173pub 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.
196fn 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