OXYGENHUB

Coverage / crates/oxy-strata/src/aws.rs 63.4%

118 of 186 lines run, 16 of 61 functions · run #427 on main · run missed

1
//! Amazon S3: `s3://BUCKET/PREFIX`.
2
//!
3
//! Credentials, in the order the AWS tools use them:
4
//! 1. `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` (and
5
//!    `AWS_SESSION_TOKEN`) in the environment, unless the URL names a
6
//!    profile;
7
//! 2. the profile (`?profile=NAME`, else `AWS_PROFILE`, else `default`):
8
//!    its keys from `~/.aws/credentials`;
9
//! 3. what the AWS CLI can produce for that profile
10
//!    (`aws configure export-credentials`): SSO, login sessions, assumed
11
//!    roles and credential processes, so anything that works for `aws`
12
//!    works for `oxy`;
13
//! 4. the instance's or container's role, through the provider's own
14
//!    lookup.
15
//!
16
//! The region comes from `?region=`, the environment, the profile, or is
17
//! asked of S3 itself; the endpoint (`?endpoint=`, for MinIO and other
18
//! S3-alikes) from the URL, the environment or the profile.
19
20
use crate::ini;
21
use crate::StoreUrl;
22
use object_store::aws::{AmazonS3, AmazonS3Builder, S3ConditionalPut};
23
use oxy_core::strata::{Error, Result};
24
use std::path::PathBuf;
25
26
pub const HINT: &str = "set AWS_PROFILE (or add ?profile=NAME to the remote URL) to a profile in ~/.aws/credentials, run `aws sso login` or `aws login` for it, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY";
27
28
struct Found {
29
    key_id: String,
30
    secret: String,
31
    token: Option<String>,
32
}
33
347×
fn env(name: &str) -> Option<String> {
357×
    std::env::var(name).ok().filter(|v| !v.is_empty())
367×
}
37
382×
fn home() -> Option<PathBuf> {
392×
    env("HOME")
402×
        .or_else(|| env("USERPROFILE"))
412×
        .map(PathBuf::from)
422×
}
43
442×
fn read_ini(env_override: &str, default_rel: &str) -> ini::Sections {
452×
    let path = env(env_override)
462×
        .map(PathBuf::from)
472×
        .or_else(|| home().map(|h| h.join(default_rel)));
482×
    path.and_then(|p| std::fs::read_to_string(p).ok())
492×
        .map(|t| ini::parse(&t))
502×
        .unwrap_or_default()
512×
}
52
53
/// A profile's section in `~/.aws/config`: `[profile NAME]`, or
54
/// `[default]`; `~/.aws/credentials` uses `[NAME]` for both.
555×
fn config_section<'a>(
565×
    config: &'a ini::Sections,
575×
    profile: &str,
585×
) -> Option<&'a std::collections::BTreeMap<String, String>> {
595×
    if profile == "default" {
602×
        config.get("default")
61
    } else {
623×
        config
633×
            .get(&format!("profile {profile}"))
643×
            .or_else(|| config.get(profile))
65
    }
665×
}
67
68
/// Ask the AWS CLI for the profile's credentials, if it is installed and
69
/// knows the profile.
700×
fn from_cli(profile: &str) -> Option<Found> {
710×
    let out = std::process::Command::new("aws")
720×
        .args([
730×
            "configure",
740×
            "export-credentials",
750×
            "--profile",
760×
            profile,
770×
            "--format",
780×
            "process",
790×
        ])
800×
        .stdin(std::process::Stdio::null())
810×
        .stderr(std::process::Stdio::null())
820×
        .output()
830×
        .ok()?;
840×
    if !out.status.success() {
850×
        return None;
860×
    }
870×
    let v: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
88
    Some(Found {
890×
        key_id: v.get("AccessKeyId")?.as_str()?.to_string(),
900×
        secret: v.get("SecretAccessKey")?.as_str()?.to_string(),
910×
        token: v
920×
            .get("SessionToken")
930×
            .and_then(|t| t.as_str())
940×
            .map(str::to_string),
95
    })
960×
}
97
98
/// With `keys` given, only they are used: nothing from the environment
99
/// or a profile, and the region and endpoint come from the URL alone.
1003×
pub fn open(url: &StoreUrl, keys: Option<(&str, &str, Option<&str>)>) -> Result<AmazonS3> {
1013×
    if let Some((id, secret, token)) = keys {
1022×
        let mut b = AmazonS3Builder::new()
1032×
            .with_bucket_name(&url.host)
1042×
            .with_http_connector(crate::http::UreqConnector)
1052×
            .with_conditional_put(S3ConditionalPut::ETagMatch)
1062×
            .with_access_key_id(id)
1072×
            .with_secret_access_key(secret);
1082×
        if let Some(t) = token {
1090×
            b = b.with_token(t);
1102×
        }
1112×
        let endpoint = url.param("endpoint").map(str::to_string);
1122×
        return finish(b, url, endpoint, url.param("region").map(str::to_string));
1131×
    }
1141×
    let bucket = url.host.clone();
1151×
    let explicit_profile = url.param("profile").map(str::to_string);
1161×
    let profile = explicit_profile
1171×
        .clone()
1181×
        .or_else(|| env("AWS_PROFILE"))
1191×
        .unwrap_or_else(|| "default".into());
1201×
    let config = read_ini("AWS_CONFIG_FILE", ".aws/config");
1211×
    let creds = read_ini("AWS_SHARED_CREDENTIALS_FILE", ".aws/credentials");
1221×
    let section = config_section(&config, &profile);
1231×
    let known_profile = section.is_some() || creds.contains_key(&profile);
124
125
    // from_env picks up the environment's keys, region, endpoint, and the
126
    // container and instance roles; what follows fills in or overrides.
1271×
    let mut b = AmazonS3Builder::from_env()
1281×
        .with_bucket_name(&bucket)
1291×
        .with_http_connector(crate::http::UreqConnector)
1301×
        .with_conditional_put(S3ConditionalPut::ETagMatch);
131
1321×
    let env_keys = match (env("AWS_ACCESS_KEY_ID"), env("AWS_SECRET_ACCESS_KEY")) {
1330×
        (Some(k), Some(s)) if explicit_profile.is_none() => Some(Found {
1340×
            key_id: k,
1350×
            secret: s,
1360×
            token: env("AWS_SESSION_TOKEN"),
1370×
        }),
1381×
        _ => None,
139
    };
1401×
    let found = env_keys
1411×
        .or_else(|| {
1421×
            let c = creds.get(&profile)?;
143
            Some(Found {
1440×
                key_id: c.get("aws_access_key_id")?.clone(),
1450×
                secret: c.get("aws_secret_access_key")?.clone(),
1460×
                token: c.get("aws_session_token").cloned(),
147
            })
1481×
        })
1491×
        .or_else(|| {
1501×
            if known_profile {
1510×
                from_cli(&profile)
152
            } else {
1531×
                None
154
            }
1551×
        });
1561×
    match found {
1570×
        Some(f) => {
1580×
            b = b
1590×
                .with_access_key_id(f.key_id)
1600×
                .with_secret_access_key(f.secret);
1610×
            if let Some(t) = f.token {
1620×
                b = b.with_token(t);
1630×
            }
164
        }
1651×
        None if explicit_profile.is_some() && !known_profile => {
1660×
            return Err(Error::Backend(format!(
1670×
                "{}: profile {profile:?} is not in ~/.aws/config or ~/.aws/credentials: {HINT}",
1680×
                url.plain()
1690×
            )));
170
        }
1711×
        None => {}
172
    }
173
1741×
    let endpoint = url
1751×
        .param("endpoint")
1761×
        .map(str::to_string)
1771×
        .or_else(|| env("AWS_ENDPOINT_URL_S3"))
1781×
        .or_else(|| env("AWS_ENDPOINT_URL"))
1791×
        .or_else(|| env("AWS_ENDPOINT"))
1801×
        .or_else(|| section.and_then(|s| s.get("endpoint_url").cloned()));
1811×
    let region = url
1821×
        .param("region")
1831×
        .map(str::to_string)
1841×
        .or_else(|| env("AWS_REGION"))
1851×
        .or_else(|| env("AWS_DEFAULT_REGION"))
1861×
        .or_else(|| section.and_then(|s| s.get("region").cloned()));
1871×
    finish(b, url, endpoint, region)
1883×
}
189
190
/// The endpoint and region, then the store.
1913×
fn finish(
1923×
    mut b: AmazonS3Builder,
1933×
    url: &StoreUrl,
1943×
    endpoint: Option<String>,
1953×
    region: Option<String>,
1963×
) -> Result<AmazonS3> {
1973×
    let bucket = url.host.clone();
1983×
    if let Some(e) = &endpoint {
1991×
        b = b
2001×
            .with_endpoint(e.clone())
2011×
            .with_allow_http(e.starts_with("http://"));
2021×
        // S3-alikes serve buckets by path, not by host name.
2031×
        b = b.with_virtual_hosted_style_request(false);
2042×
    }
205
2063×
    let region = match region {
2073×
        Some(r) => r,
2080×
        None if endpoint.is_some() => "us-east-1".into(),
2090×
        None => crate::http::bucket_region(&bucket).ok_or_else(|| {
2100×
            Error::Backend(format!(
2110×
                "{}: no region was given and S3 did not say which region holds the bucket: add ?region=NAME to the remote URL, set AWS_REGION, or put region in the profile",
2120×
                url.plain()
2130×
            ))
2140×
        })?,
215
    };
2163×
    b = b.with_region(region);
217
2183×
    b.build()
2193×
        .map_err(|e| Error::Backend(format!("{}: {e}: {HINT}", url.plain())))
2203×
}
221
222
#[cfg(test)]
223
mod tests {
224
    use super::*;
225
226
    #[test]
2271×
    fn profile_sections_are_found_under_either_heading() {
2281×
        let config =
2291×
            ini::parse("[default]\nregion = a\n[profile work]\nregion = b\n[plain]\nregion = c\n");
2301×
        assert_eq!(config_section(&config, "default").unwrap()["region"], "a");
2311×
        assert_eq!(config_section(&config, "work").unwrap()["region"], "b");
2321×
        assert_eq!(config_section(&config, "plain").unwrap()["region"], "c");
2331×
        assert!(config_section(&config, "none").is_none());
2341×
    }
235
}