1use std::path::{Path, PathBuf};
20
21use serde::Deserialize;
22
23use crate::errors::{SafeError, SafeResult};
24
25#[derive(Debug, Deserialize)]
31pub struct PushConfig {
32 pub pushes: Vec<PushSource>,
33}
34
35#[derive(Debug, Deserialize)]
41#[serde(tag = "source")]
42pub enum PushSource {
43 #[serde(rename = "akv")]
45 Kv {
46 #[serde(default)]
48 name: Option<String>,
49 vault_url: String,
51 #[serde(default)]
53 prefix: Option<String>,
54 #[serde(default)]
57 delete_missing: bool,
58 },
59 #[serde(rename = "aws")]
61 Aws {
62 #[serde(default)]
64 name: Option<String>,
65 #[serde(default)]
67 region: Option<String>,
68 #[serde(default)]
70 prefix: Option<String>,
71 #[serde(default)]
73 delete_missing: bool,
74 },
75 #[serde(rename = "ssm")]
77 Ssm {
78 #[serde(default)]
80 name: Option<String>,
81 #[serde(default)]
83 region: Option<String>,
84 #[serde(default)]
86 path: Option<String>,
87 #[serde(default)]
89 delete_missing: bool,
90 },
91 #[serde(rename = "gcp")]
93 Gcp {
94 #[serde(default)]
96 name: Option<String>,
97 #[serde(default)]
99 project: Option<String>,
100 #[serde(default)]
102 prefix: Option<String>,
103 #[serde(default)]
105 delete_missing: bool,
106 },
107}
108
109impl PushSource {
110 pub fn name(&self) -> Option<&str> {
112 match self {
113 PushSource::Kv { name, .. }
114 | PushSource::Aws { name, .. }
115 | PushSource::Ssm { name, .. }
116 | PushSource::Gcp { name, .. } => name.as_deref(),
117 }
118 }
119
120 pub fn provider_type(&self) -> &'static str {
122 match self {
123 PushSource::Kv { .. } => "akv",
124 PushSource::Aws { .. } => "aws",
125 PushSource::Ssm { .. } => "ssm",
126 PushSource::Gcp { .. } => "gcp",
127 }
128 }
129
130 pub fn delete_missing(&self) -> bool {
132 match self {
133 PushSource::Kv { delete_missing, .. }
134 | PushSource::Aws { delete_missing, .. }
135 | PushSource::Ssm { delete_missing, .. }
136 | PushSource::Gcp { delete_missing, .. } => *delete_missing,
137 }
138 }
139}
140
141pub fn load(path: &Path) -> SafeResult<PushConfig> {
147 let content = std::fs::read_to_string(path)?;
148 let is_json = path
149 .extension()
150 .and_then(|e| e.to_str())
151 .map(|e| e == "json")
152 .unwrap_or(false);
153 if is_json {
154 serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
155 reason: format!("invalid push config JSON: {e}"),
156 })
157 } else {
158 serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
159 reason: format!("invalid push config YAML: {e}"),
160 })
161 }
162}
163
164pub fn find_config(start: &Path) -> Option<PathBuf> {
169 let mut dir = start.to_path_buf();
170 loop {
171 let yml = dir.join(".tsafe.yml");
172 if yml.exists() {
173 return Some(yml);
174 }
175 let json = dir.join(".tsafe.json");
176 if json.exists() {
177 return Some(json);
178 }
179 if !dir.pop() {
180 return None;
181 }
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use tempfile::tempdir;
189
190 #[test]
191 fn parse_yaml_push_config_all_providers() {
192 let yaml = r#"
193pushes:
194 - source: akv
195 vault_url: https://myvault.vault.azure.net
196 prefix: MYAPP_
197 delete_missing: false
198 - source: aws
199 region: us-east-1
200 prefix: myapp/
201 delete_missing: true
202 - source: ssm
203 region: us-east-1
204 path: /myapp/prod/
205 - source: gcp
206 project: my-gcp-project
207 prefix: myapp-
208"#;
209 let cfg: PushConfig = serde_yaml::from_str(yaml).unwrap();
210 assert_eq!(cfg.pushes.len(), 4);
211
212 match &cfg.pushes[0] {
213 PushSource::Kv {
214 vault_url,
215 prefix,
216 delete_missing,
217 ..
218 } => {
219 assert_eq!(vault_url, "https://myvault.vault.azure.net");
220 assert_eq!(prefix.as_deref(), Some("MYAPP_"));
221 assert!(!delete_missing);
222 }
223 other => panic!("expected Kv, got {other:?}"),
224 }
225
226 match &cfg.pushes[1] {
227 PushSource::Aws { delete_missing, .. } => {
228 assert!(delete_missing);
229 }
230 other => panic!("expected Aws, got {other:?}"),
231 }
232 }
233
234 #[test]
235 fn parse_json_push_config() {
236 let json = r#"{"pushes": [{"source": "akv", "vault_url": "https://v.vault.azure.net"}]}"#;
237 let cfg: PushConfig = serde_json::from_str(json).unwrap();
238 assert_eq!(cfg.pushes.len(), 1);
239 }
240
241 #[test]
242 fn missing_pushes_key_returns_error() {
243 let yaml = r#"
244pulls:
245 - source: akv
246 vault_url: https://myvault.vault.azure.net
247"#;
248 let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
250 assert!(result.is_err(), "expected error when pushes: key is absent");
251 }
252
253 #[test]
254 fn push_source_name_accessor() {
255 let named = PushSource::Kv {
256 name: Some("prod-akv".into()),
257 vault_url: "https://prod.vault.azure.net".into(),
258 prefix: None,
259 delete_missing: false,
260 };
261 assert_eq!(named.name(), Some("prod-akv"));
262 assert_eq!(named.provider_type(), "akv");
263
264 let unnamed = PushSource::Aws {
265 name: None,
266 region: Some("us-east-1".into()),
267 prefix: None,
268 delete_missing: false,
269 };
270 assert_eq!(unnamed.name(), None);
271 assert_eq!(unnamed.provider_type(), "aws");
272 }
273
274 #[test]
275 fn delete_missing_defaults_to_false() {
276 let yaml = r#"
277pushes:
278 - source: akv
279 vault_url: https://myvault.vault.azure.net
280 - source: ssm
281 region: us-east-1
282"#;
283 let cfg: PushConfig = serde_yaml::from_str(yaml).unwrap();
284 for source in &cfg.pushes {
285 assert!(
286 !source.delete_missing(),
287 "expected delete_missing=false for {:?}",
288 source.provider_type()
289 );
290 }
291 }
292
293 #[test]
294 fn find_config_walks_up() {
295 let dir = tempdir().unwrap();
296 let child = dir.path().join("a/b/c");
297 std::fs::create_dir_all(&child).unwrap();
298 let cfg_path = dir.path().join(".tsafe.yml");
299 std::fs::write(&cfg_path, "pushes: []").unwrap();
300 let found = find_config(&child).unwrap();
301 assert_eq!(found, cfg_path);
302 }
303
304 #[test]
305 fn find_config_returns_none_when_absent() {
306 let dir = tempdir().unwrap();
307 assert!(find_config(dir.path()).is_none());
308 }
309
310 #[test]
313 fn source_filter_selects_named_sources_only() {
314 let sources = vec![
315 PushSource::Kv {
316 name: Some("prod-akv".into()),
317 vault_url: "https://prod.vault.azure.net".into(),
318 prefix: None,
319 delete_missing: false,
320 },
321 PushSource::Kv {
322 name: Some("staging-akv".into()),
323 vault_url: "https://staging.vault.azure.net".into(),
324 prefix: None,
325 delete_missing: false,
326 },
327 PushSource::Aws {
328 name: None,
329 region: Some("us-east-1".into()),
330 prefix: None,
331 delete_missing: false,
332 },
333 ];
334
335 let filter = ["prod-akv".to_string()];
336 let filtered: Vec<&PushSource> = sources
337 .iter()
338 .filter(|s| {
339 s.name()
340 .map(|n| filter.iter().any(|f| f == n))
341 .unwrap_or(false)
342 })
343 .collect();
344
345 assert_eq!(filtered.len(), 1);
346 assert_eq!(filtered[0].name(), Some("prod-akv"));
347 }
348}