Skip to main content

tsafe_core/
pushconfig.rs

1//! Push configuration — parsing `.tsafe.yml` / `.tsafe.json` repo manifests.
2//!
3//! A push config file declares one or more [`PushSource`]s (Azure Key Vault,
4//! AWS Secrets Manager, AWS SSM Parameter Store, GCP Secret Manager) that
5//! `tsafe push` writes secrets to.  The file is searched upward from the
6//! current directory via [`find_config`] (shared with pull config).
7//!
8//! # ADR-030 fields
9//!
10//! Every source entry may declare:
11//!
12//! - `name`: a label used by `--source <label>` filtering.  Sources without a
13//!   `name` field are always included in unfiltered runs but cannot be selected
14//!   with `--source`.
15//! - `delete_missing`: opt-in flag (default `false`) to delete remote keys
16//!   that are absent from the local vault within the filtered scope.  Off by
17//!   default to avoid accidental mass deletion (ADR-030).
18
19use std::path::{Path, PathBuf};
20
21use serde::Deserialize;
22
23use crate::errors::{SafeError, SafeResult};
24
25/// Top-level push configuration parsed from `.tsafe.yml` or `.tsafe.json`.
26///
27/// When `pushes:` is absent from the config file, serde returns a deserialisation
28/// error — the caller (`cmd_push`) converts this into an actionable message:
29/// "no `pushes:` key found in config — add a `pushes:` section to .tsafe.yml".
30#[derive(Debug, Deserialize)]
31pub struct PushConfig {
32    pub pushes: Vec<PushSource>,
33}
34
35/// A single push destination definition.
36///
37/// Every variant includes two ADR-030 fields:
38/// - `name`: optional label for `--source <label>` filtering
39/// - `delete_missing`: opt-in flag for remote-key deletion (default `false`)
40#[derive(Debug, Deserialize)]
41#[serde(tag = "source")]
42pub enum PushSource {
43    /// Azure Key Vault (AKV).
44    #[serde(rename = "akv")]
45    Kv {
46        /// Optional label for `--source <label>` filtering.
47        #[serde(default)]
48        name: Option<String>,
49        /// AKV vault URL, e.g. `https://myvault.vault.azure.net`.
50        vault_url: String,
51        /// Only push secrets whose local key names start with this prefix.
52        #[serde(default)]
53        prefix: Option<String>,
54        /// Delete remote secrets not present locally within the filtered scope.
55        /// Off by default (ADR-030).
56        #[serde(default)]
57        delete_missing: bool,
58    },
59    /// AWS Secrets Manager.
60    #[serde(rename = "aws")]
61    Aws {
62        /// Optional label for `--source <label>` filtering.
63        #[serde(default)]
64        name: Option<String>,
65        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
66        #[serde(default)]
67        region: Option<String>,
68        /// Only push secrets whose local key names start with this prefix.
69        #[serde(default)]
70        prefix: Option<String>,
71        /// Delete remote secrets not present locally within the filtered scope.
72        #[serde(default)]
73        delete_missing: bool,
74    },
75    /// AWS SSM Parameter Store.
76    #[serde(rename = "ssm")]
77    Ssm {
78        /// Optional label for `--source <label>` filtering.
79        #[serde(default)]
80        name: Option<String>,
81        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
82        #[serde(default)]
83        region: Option<String>,
84        /// Parameter path prefix (e.g. `/myapp/prod/`). Defaults to `/`.
85        #[serde(default)]
86        path: Option<String>,
87        /// Delete remote parameters not present locally within the path scope.
88        #[serde(default)]
89        delete_missing: bool,
90    },
91    /// GCP Secret Manager.
92    #[serde(rename = "gcp")]
93    Gcp {
94        /// Optional label for `--source <label>` filtering.
95        #[serde(default)]
96        name: Option<String>,
97        /// GCP project ID. Overrides `GOOGLE_CLOUD_PROJECT`/`GCLOUD_PROJECT`.
98        #[serde(default)]
99        project: Option<String>,
100        /// Only push secrets whose local key names start with this prefix.
101        #[serde(default)]
102        prefix: Option<String>,
103        /// Delete remote secrets not present locally within the filtered scope.
104        #[serde(default)]
105        delete_missing: bool,
106    },
107}
108
109impl PushSource {
110    /// Return the `name` label for this source, if declared.
111    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    /// Return a human-readable provider type label for display purposes.
121    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    /// Return the `delete_missing` flag for this source.
131    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
141/// Parse a push configuration file (YAML or JSON).
142///
143/// Returns `SafeError::InvalidVault` when the file is malformed or when the
144/// `pushes:` top-level key is absent.  The caller should convert the latter
145/// into an actionable operator message.
146pub 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
164/// Search upward from `start` for `.tsafe.yml` / `.tsafe.json`.
165///
166/// Re-exports the same logic as `pullconfig::find_config` — both use the same
167/// manifest file; the caller chooses which top-level key to parse.
168pub 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        // Absence of `pushes:` must produce a deserialisation error.
249        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    /// Source filtering mirrors pull: --source selects named sources only;
311    /// unnamed sources are excluded when any filter is active.
312    #[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}