use std::env;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub casdir: Option<PathBuf>,
pub lang: Option<String>,
pub policy: Option<String>,
pub defaults: Option<String>,
pub fips: Option<bool>,
pub max_depth: Option<usize>,
pub left_separator: Option<String>,
pub right_separator: Option<String>,
#[serde(default)]
pub encrypt: EncryptConfig,
#[serde(default)]
pub chain: ChainConfig,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct EncryptConfig {
pub cipher: Option<String>,
pub pbkdf: Option<String>,
pub pbkdf_msec: Option<u32>,
pub pbkdf_salt_len: Option<usize>,
}
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct ChainConfig {
pub signer: Option<String>,
pub auto_anchor: Option<bool>,
}
impl Config {
pub fn from_toml_str(s: &str) -> Result<Self> {
let cfg: Self = toml::from_str(s).map_err(|e| Error::msg(format!("config parse: {e}")))?;
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<()> {
if let Some(ref lang) = self.lang {
let valid: Vec<&str> = crate::consts::LANG_SEPARATORS
.iter()
.map(|(n, _, _)| *n)
.collect();
if !valid.contains(&lang.as_str()) {
return Err(Error::msg(format!(
"config: unknown lang '{}' (valid: {})",
lang,
valid.join(", ")
)));
}
}
if let Some(ref policy) = self.policy {
if !crate::consts::VALID_POLICIES.contains(&policy.as_str()) {
return Err(Error::msg(format!(
"config: unknown policy '{}' (valid: {})",
policy,
crate::consts::VALID_POLICIES.join(", ")
)));
}
}
Ok(())
}
pub fn load_file(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(s) => Self::from_toml_str(&s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(Error::Io(e)),
}
}
pub fn discover_project(start: &Path) -> Result<Self> {
for dir in walk_up(start) {
let candidate = dir.join(".enprot.toml");
if candidate.is_file() {
return Self::load_file(&candidate);
}
}
Ok(Self::default())
}
pub fn load_user() -> Result<Self> {
let Some(path) = user_config_path() else {
return Ok(Self::default());
};
Self::load_file(&path)
}
pub fn apply_env(mut self) -> Self {
if let Some(v) = env_first("ENPROT_CASDIR") {
self.casdir = Some(PathBuf::from(v));
}
if let Some(v) = env_first("ENPROT_LANG") {
self.lang = Some(v);
}
if let Some(v) = env_first("ENPROT_POLICY") {
self.policy = Some(v);
}
if let Some(v) = env_first("ENPROT_DEFAULTS") {
self.defaults = Some(v);
}
if let Some(v) = env_first("ENPROPT_FIPS") {
self.fips = parse_bool(&v);
}
if let Some(v) = env_first("ENPROPT_MAX_DEPTH") {
if let Ok(n) = v.parse() {
self.max_depth = Some(n);
}
}
if let Some(v) = env_first("ENPROPT_CIPHER") {
self.encrypt.cipher = Some(v);
}
if let Some(v) = env_first("ENPROPT_PBKDF") {
self.encrypt.pbkdf = Some(v);
}
if let Some(v) = env_first("ENPROPT_PBKDF_MSEC") {
if let Ok(n) = v.parse() {
self.encrypt.pbkdf_msec = Some(n);
}
}
if let Some(v) = env_first("ENPROPT_CHAIN_SIGNER") {
self.chain.signer = Some(v);
}
if let Some(v) = env_first("ENPROPT_CHAIN_AUTO_ANCHOR") {
self.chain.auto_anchor = parse_bool(&v);
}
self
}
pub fn load(start: &Path) -> Result<Self> {
let user = Self::load_user()?;
let project = Self::discover_project(start)?;
let merged = user.merged(project);
Ok(merged.apply_env())
}
pub fn merged(mut self, other: Config) -> Self {
if other.casdir.is_some() {
self.casdir = other.casdir;
}
if other.lang.is_some() {
self.lang = other.lang;
}
if other.policy.is_some() {
self.policy = other.policy;
}
if other.defaults.is_some() {
self.defaults = other.defaults;
}
if other.fips.is_some() {
self.fips = other.fips;
}
if other.max_depth.is_some() {
self.max_depth = other.max_depth;
}
if other.left_separator.is_some() {
self.left_separator = other.left_separator;
}
if other.right_separator.is_some() {
self.right_separator = other.right_separator;
}
if other.encrypt.cipher.is_some() {
self.encrypt.cipher = other.encrypt.cipher;
}
if other.encrypt.pbkdf.is_some() {
self.encrypt.pbkdf = other.encrypt.pbkdf;
}
if other.encrypt.pbkdf_msec.is_some() {
self.encrypt.pbkdf_msec = other.encrypt.pbkdf_msec;
}
if other.encrypt.pbkdf_salt_len.is_some() {
self.encrypt.pbkdf_salt_len = other.encrypt.pbkdf_salt_len;
}
if other.chain.signer.is_some() {
self.chain.signer = other.chain.signer;
}
if other.chain.auto_anchor.is_some() {
self.chain.auto_anchor = other.chain.auto_anchor;
}
self
}
pub fn template() -> &'static str {
r#"# enprot configuration. See TODO.roadmap/40 for the full field list.
# Every line here is commented out — uncomment to override the built-in
# default. CLI flags always win over values set in this file.
# casdir = "cas"
# lang = "c" # one of: c, shell, python, rust, ...
# policy = "default" # "default" or "nist"
# fips = false
# max_depth = 0 # 0 = infinite
[encrypt]
# cipher = "aes-256-siv"
# pbkdf = "argon2"
# pbkdf_msec = 100
[chain]
# signer = "confium://session-id"
# auto_anchor = false
"#
}
}
fn env_first(var: &str) -> Option<String> {
match env::var(var) {
Ok(v) if !v.is_empty() => Some(v),
_ => None,
}
}
fn parse_bool(s: &str) -> Option<bool> {
match s.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn walk_up(start: &Path) -> impl Iterator<Item = PathBuf> {
let mut current = Some(start.to_path_buf());
std::iter::from_fn(move || {
let path = current.take()?;
if let Some(parent) = path.parent() {
current = Some(parent.to_path_buf());
}
Some(path)
})
}
pub fn user_config_path() -> Option<PathBuf> {
if let Some(xdg) = env::var_os("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("enprot").join("config.toml"));
}
}
let home = env::var_os("HOME")?;
Some(
PathBuf::from(home)
.join(".config")
.join("enprot")
.join("config.toml"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_yields_default_config() {
let cfg = Config::from_toml_str("").unwrap();
assert!(cfg.casdir.is_none());
assert!(cfg.policy.is_none());
assert!(cfg.encrypt.cipher.is_none());
}
#[test]
fn parses_top_level_and_section_fields() {
let s = r#"
casdir = "cas"
policy = "nist"
[encrypt]
cipher = "aes-256-scm-siv"
pbkdf = "argon2"
pbkdf_msec = 250
[chain]
signer = "/home/me/priv.pem"
auto_anchor = true
"#;
let cfg = Config::from_toml_str(s).unwrap();
assert_eq!(cfg.casdir.as_deref(), Some(Path::new("cas")));
assert_eq!(cfg.policy.as_deref(), Some("nist"));
assert_eq!(cfg.encrypt.cipher.as_deref(), Some("aes-256-scm-siv"));
assert_eq!(cfg.encrypt.pbkdf_msec, Some(250));
assert_eq!(cfg.chain.signer.as_deref(), Some("/home/me/priv.pem"));
assert_eq!(cfg.chain.auto_anchor, Some(true));
}
#[test]
fn unknown_field_rejected_so_typos_surface() {
let s = "caspdir = \"cas\"\n";
assert!(Config::from_toml_str(s).is_err());
}
#[test]
fn project_overrides_user_when_field_set() {
let user = Config {
policy: Some("default".into()),
casdir: Some(PathBuf::from("/user/cas")),
..Config::default()
};
let project = Config {
policy: Some("nist".into()),
..Config::default()
};
let merged = user.merged(project);
assert_eq!(merged.policy.as_deref(), Some("nist"));
assert_eq!(merged.casdir.as_deref(), Some(Path::new("/user/cas")));
}
#[test]
fn env_overrides_config() {
let cfg = Config {
policy: Some("default".into()),
..Config::default()
};
let old = env::var("ENPROT_POLICY").ok();
unsafe {
env::set_var("ENPROT_POLICY", "nist");
}
let env_cfg = cfg.apply_env();
unsafe {
match &old {
Some(v) => env::set_var("ENPROT_POLICY", v),
None => env::remove_var("ENPROT_POLICY"),
}
}
assert_eq!(env_cfg.policy.as_deref(), Some("nist"));
}
#[test]
fn template_is_valid_toml_when_uncommented() {
let uncommented: String = Config::template()
.lines()
.map(|l| l.strip_prefix("# ").unwrap_or(l))
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
let _ = Config::from_toml_str(&uncommented);
}
#[test]
fn walk_up_yields_inclusive_ancestors() {
let dirs: Vec<PathBuf> = walk_up(Path::new("/a/b/c")).take(4).collect();
assert_eq!(dirs[0], PathBuf::from("/a/b/c"));
assert_eq!(dirs[1], PathBuf::from("/a/b"));
assert_eq!(dirs[2], PathBuf::from("/a"));
assert_eq!(dirs[3], PathBuf::from("/"));
}
}