1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::errors::{EnvVaultError, Result};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Settings {
13 #[serde(default = "default_environment")]
15 pub default_environment: String,
16
17 #[serde(default = "default_vault_dir")]
19 pub vault_dir: String,
20
21 #[serde(default = "default_argon2_memory_kib")]
23 pub argon2_memory_kib: u32,
24
25 #[serde(default = "default_argon2_iterations")]
27 pub argon2_iterations: u32,
28
29 #[serde(default = "default_argon2_parallelism")]
31 pub argon2_parallelism: u32,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub keyfile_path: Option<String>,
36
37 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub allowed_environments: Option<Vec<String>>,
41
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub editor: Option<String>,
45
46 #[serde(default)]
48 pub audit: AuditSettings,
49
50 #[serde(default)]
52 pub secret_scanning: SecretScanningSettings,
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct AuditSettings {
58 #[serde(default)]
60 pub log_reads: bool,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct SecretScanningSettings {
66 #[serde(default)]
68 pub custom_patterns: Vec<CustomPattern>,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub gitleaks_config: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CustomPattern {
78 pub name: String,
79 pub regex: String,
80}
81
82fn 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 }
95
96fn default_argon2_iterations() -> u32 {
97 3
98}
99
100fn default_argon2_parallelism() -> u32 {
101 4
102}
103
104impl 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 const FILE_NAME: &'static str = ".envvault.toml";
126
127 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 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 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
166pub 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#[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 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 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}