Skip to main content

envo/
resolve.rs

1//! Layered resolution of environment values.
2//!
3//! Precedence, lowest to highest (later layers win):
4//!   1. schema defaults
5//!   2. `.env`                (committed base values)
6//!   3. `.env.<profile>`      (per-profile overrides)
7//!   4. `.env.local`          (gitignored local overrides)
8//!   5. secret files (`.env.secrets`, `.env.<profile>.secrets`, plaintext/.enc)
9//!   6. process environment   (optional; for `check`/`run`)
10
11use std::collections::HashMap;
12use std::fs;
13use std::path::Path;
14
15use anyhow::{Context, Result};
16
17use crate::crypto;
18use crate::envfile::{self, EnvMap};
19use crate::schema::Schema;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Source {
23    Default,
24    Base,
25    Profile,
26    Local,
27    Secret,
28    Process,
29}
30
31impl Source {
32    pub fn label(&self) -> &'static str {
33        match self {
34            Source::Default => "default",
35            Source::Base => ".env",
36            Source::Profile => "profile",
37            Source::Local => ".env.local",
38            Source::Secret => "secret",
39            Source::Process => "process-env",
40        }
41    }
42}
43
44#[derive(Debug, Default)]
45pub struct Resolved {
46    pub values: EnvMap,
47    pub provenance: HashMap<String, Source>,
48    /// Non-fatal issues encountered while resolving (e.g. an encrypted secrets
49    /// file present but no key available).
50    pub warnings: Vec<String>,
51}
52
53impl Resolved {
54    pub fn source_of(&self, key: &str) -> Option<Source> {
55        self.provenance.get(key).copied()
56    }
57}
58
59pub struct ResolveOptions<'a> {
60    pub dir: &'a Path,
61    pub profile: Option<&'a str>,
62    pub include_process_env: bool,
63    pub decrypt_secrets: bool,
64    pub key_file: Option<&'a Path>,
65}
66
67/// Resolve all layers into a single ordered map plus provenance.
68pub fn resolve(schema: &Schema, opts: &ResolveOptions) -> Result<Resolved> {
69    let mut out = Resolved::default();
70
71    // 1. schema defaults
72    for spec in &schema.vars {
73        if let Some(def) = &spec.default {
74            out.values.set(spec.name.clone(), def.clone());
75            out.provenance.insert(spec.name.clone(), Source::Default);
76        }
77    }
78
79    // 2-4. plaintext layer files
80    apply_file(&opts.dir.join(".env"), Source::Base, &mut out)?;
81    if let Some(profile) = opts.profile {
82        apply_file(
83            &opts.dir.join(format!(".env.{profile}")),
84            Source::Profile,
85            &mut out,
86        )?;
87    }
88    apply_file(&opts.dir.join(".env.local"), Source::Local, &mut out)?;
89
90    // 5. secret files (plaintext or encrypted)
91    if opts.decrypt_secrets {
92        let mut secret_bases = vec![".env.secrets".to_string()];
93        if let Some(profile) = opts.profile {
94            secret_bases.push(format!(".env.{profile}.secrets"));
95        }
96        secret_bases.push(".env.local.secrets".to_string());
97        for base in secret_bases {
98            apply_secret_file(opts.dir, &base, opts.key_file, &mut out)?;
99        }
100    }
101
102    // 6. process environment (declared keys only, to avoid polluting output)
103    if opts.include_process_env {
104        for spec in &schema.vars {
105            if let Ok(val) = std::env::var(&spec.name) {
106                out.values.set(spec.name.clone(), val);
107                out.provenance.insert(spec.name.clone(), Source::Process);
108            }
109        }
110    }
111
112    Ok(out)
113}
114
115fn apply_file(path: &Path, src: Source, out: &mut Resolved) -> Result<bool> {
116    if !path.exists() {
117        return Ok(false);
118    }
119    let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
120    let map = envfile::parse(&text).with_context(|| format!("parsing {}", path.display()))?;
121    merge(&map, src, out);
122    Ok(true)
123}
124
125fn apply_secret_file(
126    dir: &Path,
127    base: &str,
128    key_file: Option<&Path>,
129    out: &mut Resolved,
130) -> Result<()> {
131    let plain = dir.join(base);
132    let enc = dir.join(format!("{base}.enc"));
133
134    if plain.exists() {
135        let text =
136            fs::read_to_string(&plain).with_context(|| format!("reading {}", plain.display()))?;
137        let map = envfile::parse(&text).with_context(|| format!("parsing {}", plain.display()))?;
138        merge(&map, Source::Secret, out);
139        return Ok(());
140    }
141
142    if enc.exists() {
143        let envelope =
144            fs::read_to_string(&enc).with_context(|| format!("reading {}", enc.display()))?;
145        match crypto::resolve_passphrase(dir, key_file) {
146            Ok(pass) => match crypto::decrypt(&envelope, pass.as_bytes()) {
147                Ok(plaintext) => {
148                    let text = String::from_utf8_lossy(&plaintext);
149                    let map = envfile::parse(&text)
150                        .with_context(|| format!("parsing decrypted {}", enc.display()))?;
151                    merge(&map, Source::Secret, out);
152                }
153                Err(e) => out
154                    .warnings
155                    .push(format!("could not decrypt {}: {e}", enc.display())),
156            },
157            Err(e) => out
158                .warnings
159                .push(format!("{} present but {e}", enc.display())),
160        }
161    }
162    Ok(())
163}
164
165fn merge(map: &EnvMap, src: Source, out: &mut Resolved) {
166    for (k, v) in map.iter() {
167        out.values.set(k.to_string(), v.to_string());
168        out.provenance.insert(k.to_string(), src);
169    }
170}