Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -0,0 +1,204 @@
|
||||
// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved.
|
||||
// Use of this source code is governed by an MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
module s3
|
||||
|
||||
import os
|
||||
|
||||
// Credentials carries the authentication material plus endpoint and addressing
|
||||
// preferences. It is intentionally kept small; defaults (region, endpoint, etc.)
|
||||
// are derived only when the request is signed, never stored implicitly, so the
|
||||
// same Credentials value can be reused across regions/endpoints.
|
||||
//
|
||||
// Field naming matches V conventions (snake_case). The `from_env` helper
|
||||
// recognises several provider conventions — see `from_env`.
|
||||
pub struct Credentials {
|
||||
pub:
|
||||
access_key_id string
|
||||
secret_access_key string
|
||||
session_token string
|
||||
region string
|
||||
bucket string
|
||||
endpoint string // 'https://s3.fr-par.scw.cloud' or 'host:port' — host part is what gets signed
|
||||
virtual_hosted_style bool // when true, '<bucket>.<endpoint-host>' addressing
|
||||
insecure_http bool // permit `http://` endpoints (false by default — never silently downgrades)
|
||||
}
|
||||
|
||||
// Credentials.from_env reads credentials from environment variables, trying several
|
||||
// provider conventions in order so the same code works against many hosts
|
||||
// without reconfiguration. Each field is resolved independently; the first
|
||||
// non-empty value wins.
|
||||
//
|
||||
// Lookup order per field:
|
||||
// key id : S3_ACCESS_KEY_ID, AWS_ACCESS_KEY_ID,
|
||||
// CELLAR_ADDON_KEY_ID, SCW_ACCESS_KEY,
|
||||
// B2_APPLICATION_KEY_ID, R2_ACCESS_KEY_ID, SPACES_KEY
|
||||
// secret : S3_SECRET_ACCESS_KEY, AWS_SECRET_ACCESS_KEY,
|
||||
// CELLAR_ADDON_KEY_SECRET, SCW_SECRET_KEY,
|
||||
// B2_APPLICATION_KEY, R2_SECRET_ACCESS_KEY, SPACES_SECRET
|
||||
// session : S3_SESSION_TOKEN, AWS_SESSION_TOKEN
|
||||
// region : S3_REGION, AWS_REGION, AWS_DEFAULT_REGION, SCW_DEFAULT_REGION
|
||||
// bucket : S3_BUCKET
|
||||
// endpoint : S3_ENDPOINT, AWS_ENDPOINT, AWS_ENDPOINT_URL,
|
||||
// CELLAR_ADDON_HOST, B2_ENDPOINT, R2_ENDPOINT, SPACES_ENDPOINT
|
||||
pub fn Credentials.from_env() Credentials {
|
||||
endpoint := env_first('S3_ENDPOINT', 'AWS_ENDPOINT', 'AWS_ENDPOINT_URL', 'CELLAR_ADDON_HOST',
|
||||
'B2_ENDPOINT', 'R2_ENDPOINT', 'SPACES_ENDPOINT')
|
||||
insecure := endpoint.starts_with('http://')
|
||||
return Credentials{
|
||||
access_key_id: env_first('S3_ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID',
|
||||
'CELLAR_ADDON_KEY_ID', 'SCW_ACCESS_KEY', 'B2_APPLICATION_KEY_ID', 'R2_ACCESS_KEY_ID',
|
||||
'SPACES_KEY')
|
||||
secret_access_key: env_first('S3_SECRET_ACCESS_KEY', 'AWS_SECRET_ACCESS_KEY',
|
||||
'CELLAR_ADDON_KEY_SECRET', 'SCW_SECRET_KEY', 'B2_APPLICATION_KEY',
|
||||
'R2_SECRET_ACCESS_KEY', 'SPACES_SECRET')
|
||||
session_token: env_first('S3_SESSION_TOKEN', 'AWS_SESSION_TOKEN')
|
||||
region: env_first('S3_REGION', 'AWS_REGION', 'AWS_DEFAULT_REGION',
|
||||
'SCW_DEFAULT_REGION')
|
||||
bucket: env_first('S3_BUCKET')
|
||||
endpoint: endpoint
|
||||
virtual_hosted_style: false
|
||||
insecure_http: insecure
|
||||
}
|
||||
}
|
||||
|
||||
// merge produces a copy of `c` with non-empty fields from `other` overriding.
|
||||
// Useful when callers pass per-call overrides while keeping a default Client.
|
||||
pub fn (c Credentials) merge(other Credentials) Credentials {
|
||||
return Credentials{
|
||||
access_key_id: if other.access_key_id != '' {
|
||||
other.access_key_id
|
||||
} else {
|
||||
c.access_key_id
|
||||
}
|
||||
secret_access_key: if other.secret_access_key != '' {
|
||||
other.secret_access_key
|
||||
} else {
|
||||
c.secret_access_key
|
||||
}
|
||||
session_token: if other.session_token != '' {
|
||||
other.session_token
|
||||
} else {
|
||||
c.session_token
|
||||
}
|
||||
region: if other.region != '' { other.region } else { c.region }
|
||||
bucket: if other.bucket != '' { other.bucket } else { c.bucket }
|
||||
endpoint: if other.endpoint != '' { other.endpoint } else { c.endpoint }
|
||||
virtual_hosted_style: c.virtual_hosted_style || other.virtual_hosted_style
|
||||
insecure_http: c.insecure_http || other.insecure_http
|
||||
}
|
||||
}
|
||||
|
||||
// resolved_region returns the region to use for signing. Order:
|
||||
// 1. explicit `c.region`
|
||||
// 2. parsed from `c.endpoint` when it follows the `s3.<region>.amazonaws.com` pattern
|
||||
// 3. `'auto'` for Cloudflare R2
|
||||
// 4. `'us-east-1'` (S3 historical default) when no endpoint is set
|
||||
pub fn (c Credentials) resolved_region() string {
|
||||
if c.region != '' {
|
||||
return c.region
|
||||
}
|
||||
return guess_region(c.endpoint)
|
||||
}
|
||||
|
||||
// validate ensures the credentials carry the minimum needed to sign a
|
||||
// request. Also rejects credentials / region / bucket / endpoint values that
|
||||
// contain CR or LF — those would let an attacker who controls *any* config
|
||||
// field smuggle headers into the Authorization line.
|
||||
pub fn (c Credentials) validate() ! {
|
||||
if c.access_key_id == '' || c.secret_access_key == '' {
|
||||
return new_error('MissingCredentials',
|
||||
"Missing S3 credentials. 'access_key_id' and 'secret_access_key' are required.")
|
||||
}
|
||||
for name, value in {
|
||||
'access_key_id': c.access_key_id
|
||||
'secret_access_key': c.secret_access_key
|
||||
'session_token': c.session_token
|
||||
'region': c.region
|
||||
'bucket': c.bucket
|
||||
'endpoint': c.endpoint
|
||||
} {
|
||||
if contains_crlf(value) {
|
||||
return new_error('InvalidCredentials',
|
||||
'Credential field "${name}" contains CR or LF — refused (header-injection guard)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// host_only returns the bare host[:port] from `c.endpoint`, stripping any
|
||||
// scheme and trailing path. Returned value is what gets signed in the
|
||||
// `host` header for SigV4.
|
||||
pub fn (c Credentials) host_only() string {
|
||||
mut ep := c.endpoint
|
||||
if i := ep.index('://') {
|
||||
ep = ep[i + 3..]
|
||||
}
|
||||
if j := ep.index('/') {
|
||||
ep = ep[..j]
|
||||
}
|
||||
return ep
|
||||
}
|
||||
|
||||
// extra_path returns the path component of `c.endpoint`, including any
|
||||
// leading '/'. Useful for proxies that mount S3 under a sub-path.
|
||||
pub fn (c Credentials) extra_path() string {
|
||||
mut ep := c.endpoint
|
||||
if i := ep.index('://') {
|
||||
ep = ep[i + 3..]
|
||||
}
|
||||
if j := ep.index('/') {
|
||||
return ep[j..]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// scheme returns 'http' or 'https' based on the endpoint's explicit scheme
|
||||
// when present, falling back to `insecure_http`. So all three of these work:
|
||||
// endpoint: 'https://s3.example.com' → https
|
||||
// endpoint: 's3.example.com' → https (default)
|
||||
// endpoint: 'http://localhost:9000' → http (auto-detected)
|
||||
// endpoint: 'localhost:9000', insecure_http: true → http
|
||||
pub fn (c Credentials) scheme() string {
|
||||
if c.endpoint.starts_with('http://') {
|
||||
return 'http'
|
||||
}
|
||||
if c.endpoint.starts_with('https://') {
|
||||
return 'https'
|
||||
}
|
||||
return if c.insecure_http { 'http' } else { 'https' }
|
||||
}
|
||||
|
||||
// guess_region derives the SigV4 region from an endpoint URL. Public so it
|
||||
// can be reused by the higher-level Client for log / inspect output.
|
||||
pub fn guess_region(endpoint string) string {
|
||||
if endpoint == '' {
|
||||
return 'us-east-1'
|
||||
}
|
||||
if endpoint.ends_with('.r2.cloudflarestorage.com') {
|
||||
return 'auto'
|
||||
}
|
||||
if amz_end := endpoint.index('.amazonaws.com') {
|
||||
if s3_pos := endpoint.index('s3.') {
|
||||
start := s3_pos + 3
|
||||
if start < amz_end {
|
||||
return endpoint[start..amz_end]
|
||||
}
|
||||
}
|
||||
// AWS global endpoint (`s3.amazonaws.com`) and legacy forms
|
||||
// (`s3-external-1.amazonaws.com`) sign with `us-east-1`. SigV4
|
||||
// rejects `auto` against AWS, so fall back here instead.
|
||||
return 'us-east-1'
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
// env_first returns the first non-empty env var among the names provided.
|
||||
fn env_first(names ...string) string {
|
||||
for n in names {
|
||||
v := os.getenv(n)
|
||||
if v != '' {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
Reference in new issue
Block a user