Skip to main content

datui_lib/cloud/
cloud_env.rs

1//! Cloud variables from `[cloud] env_files`, beside the real environment.
2//!
3//! A project's `.env` often holds the keys to its bucket. Reading one is opt-in: a
4//! `.env` picked up from whatever directory datui starts in, unasked, would be picking
5//! up secrets. Only cloud variable names are taken, the real environment wins over the
6//! files, and nothing is exported, so no program datui starts ever sees them.
7
8use crate::config::CloudConfig;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::RwLock;
12
13/// Variables datui itself reads. `MC_HOST_<alias>` is matched by prefix, and the names
14/// `[[cloud.connections]]` point at with `*_env` are added to these.
15pub const KNOWN: &[&str] = &[
16    "AWS_ACCESS_KEY_ID",
17    "AWS_SECRET_ACCESS_KEY",
18    "AWS_SESSION_TOKEN",
19    "AWS_REGION",
20    "AWS_DEFAULT_REGION",
21    "AWS_PROFILE",
22    "AWS_ENDPOINT_URL",
23    "AWS_ENDPOINT_URL_S3",
24    "AWS_ENDPOINT",
25    "AWS_CONFIG_FILE",
26    "AWS_SHARED_CREDENTIALS_FILE",
27    "GOOGLE_APPLICATION_CREDENTIALS",
28    "GOOGLE_CLOUD_PROJECT",
29    "GCLOUD_PROJECT",
30    "CLOUDSDK_CORE_PROJECT",
31    "DATUI_GCP_PROJECT",
32    "AZURE_STORAGE_CONNECTION_STRING",
33    "AZURE_STORAGE_ACCOUNT_NAME",
34    "AZURE_STORAGE_ACCOUNT_KEY",
35    "AZURE_STORAGE_SAS_TOKEN",
36    "AZURE_TENANT_ID",
37    "AZURE_CLIENT_ID",
38    "AZURE_CLIENT_SECRET",
39    "AZURE_FEDERATED_TOKEN_FILE",
40];
41
42fn store() -> &'static RwLock<HashMap<String, String>> {
43    static VARS: std::sync::OnceLock<RwLock<HashMap<String, String>>> = std::sync::OnceLock::new();
44    VARS.get_or_init(Default::default)
45}
46
47/// Read the files `cloud.env_files` names, relative to `dir`, and keep their cloud
48/// variables for [`var`]. Returns a note for each file that could not be read.
49pub fn load(cloud: &CloudConfig, dir: &Path) -> Vec<String> {
50    let named: Vec<&str> = cloud
51        .connections
52        .iter()
53        .flat_map(|s| {
54            [
55                s.access_key_id_env.as_deref(),
56                s.secret_access_key_env.as_deref(),
57                s.session_token_env.as_deref(),
58                s.account_key_env.as_deref(),
59                s.sas_env.as_deref(),
60                s.connection_string_env.as_deref(),
61            ]
62        })
63        .flatten()
64        .collect();
65    let allowed =
66        |key: &str| KNOWN.contains(&key) || key.starts_with("MC_HOST_") || named.contains(&key);
67    let mut vars = HashMap::new();
68    let mut notes = Vec::new();
69    for file in &cloud.env_files {
70        let path = resolve_path(file, dir);
71        match std::fs::read_to_string(&path) {
72            Ok(text) => vars.extend(parse(&text, &allowed)),
73            Err(e) => notes.push(format!("env_files: {}: {e}", path.display())),
74        }
75    }
76    if let Ok(mut store) = store().write() {
77        *store = vars;
78    }
79    notes
80}
81
82/// `~/x` under the home directory, a relative path under `dir`.
83fn resolve_path(file: &str, dir: &Path) -> PathBuf {
84    if let Some(rest) = file.strip_prefix("~/").or_else(|| file.strip_prefix("~\\"))
85        && let Some(home) = dirs::home_dir()
86    {
87        return home.join(rest);
88    }
89    let path = PathBuf::from(file);
90    if path.is_absolute() {
91        path
92    } else {
93        dir.join(path)
94    }
95}
96
97/// The `KEY=value` lines of a dotenv file whose key `allowed` accepts. Handles
98/// `export `, `#` comments, and single or double quotes around a value.
99pub fn parse(text: &str, allowed: &dyn Fn(&str) -> bool) -> Vec<(String, String)> {
100    text.lines()
101        .filter_map(|line| {
102            let line = line.trim();
103            if line.is_empty() || line.starts_with('#') {
104                return None;
105            }
106            let line = line.strip_prefix("export ").unwrap_or(line);
107            let (key, value) = line.split_once('=')?;
108            let key = key.trim();
109            if !allowed(key) {
110                return None;
111            }
112            let value = value.trim();
113            let value = match value.chars().next() {
114                Some(quote @ ('"' | '\'')) => {
115                    let inner = &value[1..];
116                    inner.split(quote).next().unwrap_or(inner)
117                }
118                // An unquoted value ends at a comment.
119                _ => value.split(" #").next().unwrap_or(value).trim(),
120            };
121            Some((key.to_string(), value.to_string()))
122        })
123        .collect()
124}
125
126/// A variable: the real environment's, else one from the env files.
127pub fn var(key: &str) -> Option<String> {
128    std::env::var(key)
129        .ok()
130        .or_else(|| store().read().ok()?.get(key).cloned())
131}
132
133/// Every variable: the real environment, and the env files' where it has none.
134pub fn vars() -> Vec<(String, String)> {
135    let mut all: Vec<(String, String)> = std::env::vars().collect();
136    if let Ok(store) = store().read() {
137        for (key, value) in store.iter() {
138            if std::env::var_os(key).is_none() {
139                all.push((key.clone(), value.clone()));
140            }
141        }
142    }
143    all
144}
145
146#[cfg(test)]
147mod tests;