tsafe-core 1.0.11

Encrypted local secret vault library — AES-256 via age, audit log, RBAC, biometric keyring, CloudEvents
Documentation
//! Push configuration — parsing `.tsafe.yml` / `.tsafe.json` repo manifests.
//!
//! A push config file declares one or more [`PushSource`]s (Azure Key Vault,
//! AWS Secrets Manager, AWS SSM Parameter Store, GCP Secret Manager) that
//! `tsafe push` writes secrets to.  The file is searched upward from the
//! current directory via [`find_config`] (shared with pull config).
//!
//! # ADR-030 fields
//!
//! Every source entry may declare:
//!
//! - `name`: a label used by `--source <label>` filtering.  Sources without a
//!   `name` field are always included in unfiltered runs but cannot be selected
//!   with `--source`.
//! - `delete_missing`: opt-in flag (default `false`) to delete remote keys
//!   that are absent from the local vault within the filtered scope.  Off by
//!   default to avoid accidental mass deletion (ADR-030).

use std::path::{Path, PathBuf};

use serde::Deserialize;

use crate::errors::{SafeError, SafeResult};

/// Top-level push configuration parsed from `.tsafe.yml` or `.tsafe.json`.
///
/// When `pushes:` is absent from the config file, serde returns a deserialisation
/// error — the caller (`cmd_push`) converts this into an actionable message:
/// "no `pushes:` key found in config — add a `pushes:` section to .tsafe.yml".
#[derive(Debug, Deserialize)]
pub struct PushConfig {
    pub pushes: Vec<PushSource>,
}

/// A single push destination definition.
///
/// Every variant includes two ADR-030 fields:
/// - `name`: optional label for `--source <label>` filtering
/// - `delete_missing`: opt-in flag for remote-key deletion (default `false`)
#[derive(Debug, Deserialize)]
#[serde(tag = "source")]
pub enum PushSource {
    /// Azure Key Vault (AKV).
    #[serde(rename = "akv")]
    Kv {
        /// Optional label for `--source <label>` filtering.
        #[serde(default)]
        name: Option<String>,
        /// AKV vault URL, e.g. `https://myvault.vault.azure.net`.
        vault_url: String,
        /// Only push secrets whose local key names start with this prefix.
        #[serde(default)]
        prefix: Option<String>,
        /// Delete remote secrets not present locally within the filtered scope.
        /// Off by default (ADR-030).
        #[serde(default)]
        delete_missing: bool,
    },
    /// AWS Secrets Manager.
    #[serde(rename = "aws")]
    Aws {
        /// Optional label for `--source <label>` filtering.
        #[serde(default)]
        name: Option<String>,
        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
        #[serde(default)]
        region: Option<String>,
        /// Only push secrets whose local key names start with this prefix.
        #[serde(default)]
        prefix: Option<String>,
        /// Delete remote secrets not present locally within the filtered scope.
        #[serde(default)]
        delete_missing: bool,
    },
    /// AWS SSM Parameter Store.
    #[serde(rename = "ssm")]
    Ssm {
        /// Optional label for `--source <label>` filtering.
        #[serde(default)]
        name: Option<String>,
        /// AWS region, e.g. `us-east-1`. Overrides `AWS_DEFAULT_REGION`/`AWS_REGION`.
        #[serde(default)]
        region: Option<String>,
        /// Parameter path prefix (e.g. `/myapp/prod/`). Defaults to `/`.
        #[serde(default)]
        path: Option<String>,
        /// Delete remote parameters not present locally within the path scope.
        #[serde(default)]
        delete_missing: bool,
    },
    /// GCP Secret Manager.
    #[serde(rename = "gcp")]
    Gcp {
        /// Optional label for `--source <label>` filtering.
        #[serde(default)]
        name: Option<String>,
        /// GCP project ID. Overrides `GOOGLE_CLOUD_PROJECT`/`GCLOUD_PROJECT`.
        #[serde(default)]
        project: Option<String>,
        /// Only push secrets whose local key names start with this prefix.
        #[serde(default)]
        prefix: Option<String>,
        /// Delete remote secrets not present locally within the filtered scope.
        #[serde(default)]
        delete_missing: bool,
    },
}

impl PushSource {
    /// Return the `name` label for this source, if declared.
    pub fn name(&self) -> Option<&str> {
        match self {
            PushSource::Kv { name, .. }
            | PushSource::Aws { name, .. }
            | PushSource::Ssm { name, .. }
            | PushSource::Gcp { name, .. } => name.as_deref(),
        }
    }

    /// Return a human-readable provider type label for display purposes.
    pub fn provider_type(&self) -> &'static str {
        match self {
            PushSource::Kv { .. } => "akv",
            PushSource::Aws { .. } => "aws",
            PushSource::Ssm { .. } => "ssm",
            PushSource::Gcp { .. } => "gcp",
        }
    }

    /// Return the `delete_missing` flag for this source.
    pub fn delete_missing(&self) -> bool {
        match self {
            PushSource::Kv { delete_missing, .. }
            | PushSource::Aws { delete_missing, .. }
            | PushSource::Ssm { delete_missing, .. }
            | PushSource::Gcp { delete_missing, .. } => *delete_missing,
        }
    }
}

/// Parse a push configuration file (YAML or JSON).
///
/// Returns `SafeError::InvalidVault` when the file is malformed or when the
/// `pushes:` top-level key is absent.  The caller should convert the latter
/// into an actionable operator message.
pub fn load(path: &Path) -> SafeResult<PushConfig> {
    let content = std::fs::read_to_string(path)?;
    let is_json = path
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e == "json")
        .unwrap_or(false);
    if is_json {
        serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
            reason: format!("invalid push config JSON: {e}"),
        })
    } else {
        serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
            reason: format!("invalid push config YAML: {e}"),
        })
    }
}

/// Search upward from `start` for `.tsafe.yml` / `.tsafe.json`.
///
/// Re-exports the same logic as `pullconfig::find_config` — both use the same
/// manifest file; the caller chooses which top-level key to parse.
pub fn find_config(start: &Path) -> Option<PathBuf> {
    let mut dir = start.to_path_buf();
    loop {
        let yml = dir.join(".tsafe.yml");
        if yml.exists() {
            return Some(yml);
        }
        let json = dir.join(".tsafe.json");
        if json.exists() {
            return Some(json);
        }
        if !dir.pop() {
            return None;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn parse_yaml_push_config_all_providers() {
        let yaml = r#"
pushes:
  - source: akv
    vault_url: https://myvault.vault.azure.net
    prefix: MYAPP_
    delete_missing: false
  - source: aws
    region: us-east-1
    prefix: myapp/
    delete_missing: true
  - source: ssm
    region: us-east-1
    path: /myapp/prod/
  - source: gcp
    project: my-gcp-project
    prefix: myapp-
"#;
        let cfg: PushConfig = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(cfg.pushes.len(), 4);

        match &cfg.pushes[0] {
            PushSource::Kv {
                vault_url,
                prefix,
                delete_missing,
                ..
            } => {
                assert_eq!(vault_url, "https://myvault.vault.azure.net");
                assert_eq!(prefix.as_deref(), Some("MYAPP_"));
                assert!(!delete_missing);
            }
            other => panic!("expected Kv, got {other:?}"),
        }

        match &cfg.pushes[1] {
            PushSource::Aws { delete_missing, .. } => {
                assert!(delete_missing);
            }
            other => panic!("expected Aws, got {other:?}"),
        }
    }

    #[test]
    fn parse_json_push_config() {
        let json = r#"{"pushes": [{"source": "akv", "vault_url": "https://v.vault.azure.net"}]}"#;
        let cfg: PushConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.pushes.len(), 1);
    }

    #[test]
    fn missing_pushes_key_returns_error() {
        let yaml = r#"
pulls:
  - source: akv
    vault_url: https://myvault.vault.azure.net
"#;
        // Absence of `pushes:` must produce a deserialisation error.
        let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
        assert!(result.is_err(), "expected error when pushes: key is absent");
    }

    #[test]
    fn push_source_name_accessor() {
        let named = PushSource::Kv {
            name: Some("prod-akv".into()),
            vault_url: "https://prod.vault.azure.net".into(),
            prefix: None,
            delete_missing: false,
        };
        assert_eq!(named.name(), Some("prod-akv"));
        assert_eq!(named.provider_type(), "akv");

        let unnamed = PushSource::Aws {
            name: None,
            region: Some("us-east-1".into()),
            prefix: None,
            delete_missing: false,
        };
        assert_eq!(unnamed.name(), None);
        assert_eq!(unnamed.provider_type(), "aws");
    }

    #[test]
    fn delete_missing_defaults_to_false() {
        let yaml = r#"
pushes:
  - source: akv
    vault_url: https://myvault.vault.azure.net
  - source: ssm
    region: us-east-1
"#;
        let cfg: PushConfig = serde_yaml::from_str(yaml).unwrap();
        for source in &cfg.pushes {
            assert!(
                !source.delete_missing(),
                "expected delete_missing=false for {:?}",
                source.provider_type()
            );
        }
    }

    #[test]
    fn find_config_walks_up() {
        let dir = tempdir().unwrap();
        let child = dir.path().join("a/b/c");
        std::fs::create_dir_all(&child).unwrap();
        let cfg_path = dir.path().join(".tsafe.yml");
        std::fs::write(&cfg_path, "pushes: []").unwrap();
        let found = find_config(&child).unwrap();
        assert_eq!(found, cfg_path);
    }

    #[test]
    fn find_config_returns_none_when_absent() {
        let dir = tempdir().unwrap();
        assert!(find_config(dir.path()).is_none());
    }

    /// Source filtering mirrors pull: --source selects named sources only;
    /// unnamed sources are excluded when any filter is active.
    #[test]
    fn source_filter_selects_named_sources_only() {
        let sources = vec![
            PushSource::Kv {
                name: Some("prod-akv".into()),
                vault_url: "https://prod.vault.azure.net".into(),
                prefix: None,
                delete_missing: false,
            },
            PushSource::Kv {
                name: Some("staging-akv".into()),
                vault_url: "https://staging.vault.azure.net".into(),
                prefix: None,
                delete_missing: false,
            },
            PushSource::Aws {
                name: None,
                region: Some("us-east-1".into()),
                prefix: None,
                delete_missing: false,
            },
        ];

        let filter = ["prod-akv".to_string()];
        let filtered: Vec<&PushSource> = sources
            .iter()
            .filter(|s| {
                s.name()
                    .map(|n| filter.iter().any(|f| f == n))
                    .unwrap_or(false)
            })
            .collect();

        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].name(), Some("prod-akv"));
    }
}