Skip to main content

envvault/config/
settings.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::errors::{EnvVaultError, Result};
6
7/// Project-level configuration, loaded from `.envvault.toml`.
8///
9/// Every field has a sensible default so EnvVault works out-of-the-box
10/// without any config file at all.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Settings {
13    /// Which environment to use when none is specified (e.g. "dev").
14    #[serde(default = "default_environment")]
15    pub default_environment: String,
16
17    /// Directory (relative to project root) where vault files are stored.
18    #[serde(default = "default_vault_dir")]
19    pub vault_dir: String,
20
21    /// Argon2 memory cost in KiB (default: 64 MB).
22    #[serde(default = "default_argon2_memory_kib")]
23    pub argon2_memory_kib: u32,
24
25    /// Argon2 iteration count (default: 3).
26    #[serde(default = "default_argon2_iterations")]
27    pub argon2_iterations: u32,
28
29    /// Argon2 parallelism degree (default: 4).
30    #[serde(default = "default_argon2_parallelism")]
31    pub argon2_parallelism: u32,
32
33    /// Default keyfile path (used when `--keyfile` is not passed on the CLI).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub keyfile_path: Option<String>,
36
37    /// Restrict which environment names are allowed (typo protection).
38    /// If set, any env name not in this list is rejected.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub allowed_environments: Option<Vec<String>>,
41
42    /// Preferred editor for `envvault edit` (overrides $VISUAL / $EDITOR).
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub editor: Option<String>,
45
46    /// Audit log settings.
47    #[serde(default)]
48    pub audit: AuditSettings,
49
50    /// Secret scanning settings (for future use).
51    #[serde(default)]
52    pub secret_scanning: SecretScanningSettings,
53}
54
55/// Audit log configuration.
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct AuditSettings {
58    /// Whether to log read operations (get, list, run). Default: false.
59    #[serde(default)]
60    pub log_reads: bool,
61}
62
63/// Secret scanning configuration.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct SecretScanningSettings {
66    /// Custom regex patterns to scan for leaked secrets.
67    #[serde(default)]
68    pub custom_patterns: Vec<CustomPattern>,
69
70    /// Path to a gitleaks-format TOML config file for additional rules.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub gitleaks_config: Option<String>,
73}
74
75/// A custom secret scanning pattern.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CustomPattern {
78    pub name: String,
79    pub regex: String,
80}
81
82// ── Serde default helpers ────────────────────────────────────────────
83
84fn default_environment() -> String {
85    "dev".to_string()
86}
87
88fn default_vault_dir() -> String {
89    ".envvault".to_string()
90}
91
92fn default_argon2_memory_kib() -> u32 {
93    65_536 // 64 MB
94}
95
96fn default_argon2_iterations() -> u32 {
97    3
98}
99
100fn default_argon2_parallelism() -> u32 {
101    4
102}
103
104// ── Implementation ───────────────────────────────────────────────────
105
106impl Default for Settings {
107    fn default() -> Self {
108        Self {
109            default_environment: default_environment(),
110            vault_dir: default_vault_dir(),
111            argon2_memory_kib: default_argon2_memory_kib(),
112            argon2_iterations: default_argon2_iterations(),
113            argon2_parallelism: default_argon2_parallelism(),
114            keyfile_path: None,
115            allowed_environments: None,
116            editor: None,
117            audit: AuditSettings::default(),
118            secret_scanning: SecretScanningSettings::default(),
119        }
120    }
121}
122
123impl Settings {
124    /// Name of the config file we look for in the project root.
125    const FILE_NAME: &'static str = ".envvault.toml";
126
127    /// Load settings from `<project_dir>/.envvault.toml`.
128    ///
129    /// If the file does not exist, sensible defaults are returned.
130    /// If the file exists but cannot be parsed, an error is returned.
131    pub fn load(project_dir: &Path) -> Result<Self> {
132        let config_path = project_dir.join(Self::FILE_NAME);
133
134        if !config_path.exists() {
135            return Ok(Self::default());
136        }
137
138        let contents = std::fs::read_to_string(&config_path)?;
139
140        let settings: Settings = toml::from_str(&contents).map_err(|e| {
141            EnvVaultError::ConfigError(format!("Failed to parse {}: {e}", config_path.display()))
142        })?;
143
144        Ok(settings)
145    }
146
147    /// Build the full path to a vault file for a given environment.
148    ///
149    /// Example: `project_dir/.envvault/dev.vault`
150    pub fn vault_path(&self, project_dir: &Path, env_name: &str) -> PathBuf {
151        project_dir
152            .join(&self.vault_dir)
153            .join(format!("{env_name}.vault"))
154    }
155
156    /// Convert the Argon2 settings into crypto-layer params.
157    pub fn argon2_params(&self) -> crate::crypto::kdf::Argon2Params {
158        crate::crypto::kdf::Argon2Params {
159            memory_kib: self.argon2_memory_kib,
160            iterations: self.argon2_iterations,
161            parallelism: self.argon2_parallelism,
162        }
163    }
164}
165
166/// Validate that an environment name is in the allowed list (if configured).
167///
168/// Returns `Ok(())` if no `allowed_environments` is set, or if the name is in the list.
169pub fn validate_env_against_config(env_name: &str, settings: &Settings) -> Result<()> {
170    if let Some(ref allowed) = settings.allowed_environments {
171        if !allowed.iter().any(|a| a == env_name) {
172            return Err(EnvVaultError::ConfigError(format!(
173                "environment '{}' is not in allowed list: {:?}",
174                env_name, allowed
175            )));
176        }
177    }
178    Ok(())
179}
180
181// ── Tests ────────────────────────────────────────────────────────────
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::fs;
187    use tempfile::TempDir;
188
189    #[test]
190    fn default_settings_are_sensible() {
191        let s = Settings::default();
192        assert_eq!(s.default_environment, "dev");
193        assert_eq!(s.vault_dir, ".envvault");
194        assert_eq!(s.argon2_memory_kib, 65_536);
195        assert_eq!(s.argon2_iterations, 3);
196        assert_eq!(s.argon2_parallelism, 4);
197        assert!(s.keyfile_path.is_none());
198        assert!(s.allowed_environments.is_none());
199        assert!(s.editor.is_none());
200        assert!(!s.audit.log_reads);
201        assert!(s.secret_scanning.custom_patterns.is_empty());
202    }
203
204    #[test]
205    fn load_returns_defaults_when_no_config_file() {
206        let tmp = TempDir::new().unwrap();
207        let settings = Settings::load(tmp.path()).unwrap();
208        assert_eq!(settings.default_environment, "dev");
209    }
210
211    #[test]
212    fn load_parses_toml_file() {
213        let tmp = TempDir::new().unwrap();
214        let config = r#"
215default_environment = "staging"
216vault_dir = "secrets"
217argon2_memory_kib = 131072
218argon2_iterations = 5
219argon2_parallelism = 8
220"#;
221        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
222
223        let settings = Settings::load(tmp.path()).unwrap();
224        assert_eq!(settings.default_environment, "staging");
225        assert_eq!(settings.vault_dir, "secrets");
226        assert_eq!(settings.argon2_memory_kib, 131_072);
227        assert_eq!(settings.argon2_iterations, 5);
228        assert_eq!(settings.argon2_parallelism, 8);
229    }
230
231    #[test]
232    fn load_uses_defaults_for_missing_fields() {
233        let tmp = TempDir::new().unwrap();
234        let config = "default_environment = \"prod\"\n";
235        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
236
237        let settings = Settings::load(tmp.path()).unwrap();
238        assert_eq!(settings.default_environment, "prod");
239        // Rest should be defaults
240        assert_eq!(settings.vault_dir, ".envvault");
241        assert_eq!(settings.argon2_iterations, 3);
242    }
243
244    #[test]
245    fn load_errors_on_invalid_toml() {
246        let tmp = TempDir::new().unwrap();
247        fs::write(tmp.path().join(".envvault.toml"), "not valid {{toml").unwrap();
248
249        let result = Settings::load(tmp.path());
250        assert!(result.is_err());
251    }
252
253    #[test]
254    fn vault_path_builds_correct_path() {
255        let s = Settings::default();
256        let project = Path::new("/home/user/myproject");
257        let path = s.vault_path(project, "dev");
258        assert_eq!(
259            path,
260            PathBuf::from("/home/user/myproject/.envvault/dev.vault")
261        );
262    }
263
264    #[test]
265    fn vault_path_respects_custom_vault_dir() {
266        let s = Settings {
267            vault_dir: "secrets".to_string(),
268            ..Settings::default()
269        };
270        let project = Path::new("/home/user/myproject");
271        let path = s.vault_path(project, "staging");
272        assert_eq!(
273            path,
274            PathBuf::from("/home/user/myproject/secrets/staging.vault")
275        );
276    }
277
278    #[test]
279    fn load_parses_keyfile_path() {
280        let tmp = TempDir::new().unwrap();
281        let config = "keyfile_path = \"/home/user/.envvault/keyfile\"\n";
282        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
283
284        let settings = Settings::load(tmp.path()).unwrap();
285        assert_eq!(
286            settings.keyfile_path.as_deref(),
287            Some("/home/user/.envvault/keyfile")
288        );
289    }
290
291    #[test]
292    fn load_parses_allowed_environments() {
293        let tmp = TempDir::new().unwrap();
294        let config = "allowed_environments = [\"dev\", \"staging\", \"prod\"]\n";
295        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
296
297        let settings = Settings::load(tmp.path()).unwrap();
298        assert_eq!(
299            settings.allowed_environments,
300            Some(vec![
301                "dev".to_string(),
302                "staging".to_string(),
303                "prod".to_string()
304            ])
305        );
306    }
307
308    #[test]
309    fn load_parses_editor() {
310        let tmp = TempDir::new().unwrap();
311        let config = "editor = \"nano\"\n";
312        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
313
314        let settings = Settings::load(tmp.path()).unwrap();
315        assert_eq!(settings.editor.as_deref(), Some("nano"));
316    }
317
318    #[test]
319    fn load_parses_audit_section() {
320        let tmp = TempDir::new().unwrap();
321        let config = "[audit]\nlog_reads = true\n";
322        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
323
324        let settings = Settings::load(tmp.path()).unwrap();
325        assert!(settings.audit.log_reads);
326    }
327
328    #[test]
329    fn load_parses_secret_scanning_custom_patterns() {
330        let tmp = TempDir::new().unwrap();
331        let config = r#"
332[[secret_scanning.custom_patterns]]
333name = "Slack Token"
334regex = "xoxb-[0-9A-Za-z-]+"
335"#;
336        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
337
338        let settings = Settings::load(tmp.path()).unwrap();
339        assert_eq!(settings.secret_scanning.custom_patterns.len(), 1);
340        assert_eq!(
341            settings.secret_scanning.custom_patterns[0].name,
342            "Slack Token"
343        );
344    }
345
346    #[test]
347    fn allowed_environments_rejects_unlisted_env() {
348        let settings = Settings {
349            allowed_environments: Some(vec![
350                "dev".to_string(),
351                "staging".to_string(),
352                "prod".to_string(),
353            ]),
354            ..Settings::default()
355        };
356
357        assert!(validate_env_against_config("dev", &settings).is_ok());
358        assert!(validate_env_against_config("staging", &settings).is_ok());
359        assert!(validate_env_against_config("prod", &settings).is_ok());
360
361        let err = validate_env_against_config("pdro", &settings).unwrap_err();
362        assert!(err.to_string().contains("not in allowed list"));
363    }
364
365    #[test]
366    fn unknown_toml_fields_are_ignored() {
367        let tmp = TempDir::new().unwrap();
368        let config = "default_environment = \"dev\"\nunknown_field = \"should be fine\"\n";
369        fs::write(tmp.path().join(".envvault.toml"), config).unwrap();
370
371        // Should NOT error — we don't deny unknown fields for forward-compat.
372        let settings = Settings::load(tmp.path()).unwrap();
373        assert_eq!(settings.default_environment, "dev");
374    }
375
376    #[test]
377    fn allowed_environments_allows_all_when_not_configured() {
378        let settings = Settings::default();
379        assert!(validate_env_against_config("anything", &settings).is_ok());
380    }
381}