use std::collections::HashMap;
use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use crate::crypto;
use crate::envfile::{self, EnvMap};
use crate::schema::Schema;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Default,
Base,
Profile,
Local,
Secret,
Process,
}
impl Source {
pub fn label(&self) -> &'static str {
match self {
Source::Default => "default",
Source::Base => ".env",
Source::Profile => "profile",
Source::Local => ".env.local",
Source::Secret => "secret",
Source::Process => "process-env",
}
}
}
#[derive(Debug, Default)]
pub struct Resolved {
pub values: EnvMap,
pub provenance: HashMap<String, Source>,
pub warnings: Vec<String>,
}
impl Resolved {
pub fn source_of(&self, key: &str) -> Option<Source> {
self.provenance.get(key).copied()
}
}
pub struct ResolveOptions<'a> {
pub dir: &'a Path,
pub profile: Option<&'a str>,
pub include_process_env: bool,
pub decrypt_secrets: bool,
pub key_file: Option<&'a Path>,
}
pub fn resolve(schema: &Schema, opts: &ResolveOptions) -> Result<Resolved> {
let mut out = Resolved::default();
for spec in &schema.vars {
if let Some(def) = &spec.default {
out.values.set(spec.name.clone(), def.clone());
out.provenance.insert(spec.name.clone(), Source::Default);
}
}
apply_file(&opts.dir.join(".env"), Source::Base, &mut out)?;
if let Some(profile) = opts.profile {
apply_file(
&opts.dir.join(format!(".env.{profile}")),
Source::Profile,
&mut out,
)?;
}
apply_file(&opts.dir.join(".env.local"), Source::Local, &mut out)?;
if opts.decrypt_secrets {
let mut secret_bases = vec![".env.secrets".to_string()];
if let Some(profile) = opts.profile {
secret_bases.push(format!(".env.{profile}.secrets"));
}
secret_bases.push(".env.local.secrets".to_string());
for base in secret_bases {
apply_secret_file(opts.dir, &base, opts.key_file, &mut out)?;
}
}
if opts.include_process_env {
for spec in &schema.vars {
if let Ok(val) = std::env::var(&spec.name) {
out.values.set(spec.name.clone(), val);
out.provenance.insert(spec.name.clone(), Source::Process);
}
}
}
Ok(out)
}
fn apply_file(path: &Path, src: Source, out: &mut Resolved) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let map = envfile::parse(&text).with_context(|| format!("parsing {}", path.display()))?;
merge(&map, src, out);
Ok(true)
}
fn apply_secret_file(
dir: &Path,
base: &str,
key_file: Option<&Path>,
out: &mut Resolved,
) -> Result<()> {
let plain = dir.join(base);
let enc = dir.join(format!("{base}.enc"));
if plain.exists() {
let text =
fs::read_to_string(&plain).with_context(|| format!("reading {}", plain.display()))?;
let map = envfile::parse(&text).with_context(|| format!("parsing {}", plain.display()))?;
merge(&map, Source::Secret, out);
return Ok(());
}
if enc.exists() {
let envelope =
fs::read_to_string(&enc).with_context(|| format!("reading {}", enc.display()))?;
match crypto::resolve_passphrase(dir, key_file) {
Ok(pass) => match crypto::decrypt(&envelope, pass.as_bytes()) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext);
let map = envfile::parse(&text)
.with_context(|| format!("parsing decrypted {}", enc.display()))?;
merge(&map, Source::Secret, out);
}
Err(e) => out
.warnings
.push(format!("could not decrypt {}: {e}", enc.display())),
},
Err(e) => out
.warnings
.push(format!("{} present but {e}", enc.display())),
}
}
Ok(())
}
fn merge(map: &EnvMap, src: Source, out: &mut Resolved) {
for (k, v) in map.iter() {
out.values.set(k.to_string(), v.to_string());
out.provenance.insert(k.to_string(), src);
}
}