diff --git a/.gitignore b/.gitignore index bcdbfc9..3046268 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .pre-commit* .direnv target + +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 3b82ce7..d459032 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "async-trait" version = "0.1.89" @@ -1605,6 +1611,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2969,6 +2984,15 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + [[package]] name = "outref" version = "0.5.2" @@ -3700,6 +3724,29 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "saphyr" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3767dfe8889ebb55a21409df2b6f36e66abfbe1eb92d64ff76ae799d3f91016" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", + "ordered-float", + "saphyr-parser", +] + +[[package]] +name = "saphyr-parser" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb771b59f6b1985d1406325ec28f97cfb14256abcec4fdfb37b36a1766d6af7" +dependencies = [ + "arraydeque", + "hashlink", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3803,6 +3850,7 @@ dependencies = [ "reqwest 0.12.28", "rsa", "rustls 0.23.37", + "saphyr", "secrecy", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 55b2cd5..b93fecf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ colored = "3.0" dotenvy = "0.15" inquire = { version = "0.9.4", features = ["experimental-multiline-input"] } miette = { version = "7.6", features = ["fancy"] } +saphyr = "0.0.6" serde_json = "1.0" tempfile = "3.0" http = "1.0" diff --git a/devenv.nix b/devenv.nix index ae55b1d..d5c050e 100644 --- a/devenv.nix +++ b/devenv.nix @@ -5,6 +5,7 @@ channel = "stable"; version = "1.92.0"; }; + languages.javascript = { enable = true; npm = { @@ -20,6 +21,8 @@ pkgs.cargo-tarpaulin # installers pkgs.cargo-dist + # For development of the SOPS provider + pkgs.sops ]; git-hooks.hooks = { @@ -37,7 +40,7 @@ # Build the CLI for integration tests cargo build --release export PATH="$PWD/target/release:$PATH" - + # Run CLI integration tests bash tests/cli-integration.sh ''; diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index b2b8eaf..899bd5e 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -46,6 +46,7 @@ rsa.workspace = true uuid.workspace = true data-encoding.workspace = true detect-coding-agent.workspace = true +saphyr.workspace = true [features] default = ["cli", "keyring", "gcsm", "awssm", "vault", "bws"] diff --git a/secretspec/src/provider/mod.rs b/secretspec/src/provider/mod.rs index 65a0663..effb55c 100644 --- a/secretspec/src/provider/mod.rs +++ b/secretspec/src/provider/mod.rs @@ -160,8 +160,10 @@ pub mod lastpass; pub mod onepassword; pub mod pass; pub mod protonpass; +pub mod sops; #[cfg(feature = "vault")] pub mod vault; + #[macro_use] pub mod macros; diff --git a/secretspec/src/provider/sops/config.rs b/secretspec/src/provider/sops/config.rs new file mode 100644 index 0000000..ea2b7c3 --- /dev/null +++ b/secretspec/src/provider/sops/config.rs @@ -0,0 +1,284 @@ +use crate::{ + Result, SecretSpecError, + provider::{ + ProviderUrl, + sops::{ + SopsFormat, SopsMode, + fields::{PATHBUF_FIELDS, STRING_FIELDS}, + }, + }, +}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +struct EnvString(String); + +impl From<&PathBuf> for EnvString { + fn from(p: &PathBuf) -> Self { + EnvString(p.to_string_lossy().into_owned()) + } +} + +impl From<&String> for EnvString { + fn from(s: &String) -> Self { + EnvString(s.clone()) + } +} + +impl AsRef for EnvString { + fn as_ref(&self) -> &OsStr { + OsStr::new(&self.0) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SopsConfig { + pub mode: SopsMode, + pub format: Option, + + // Age configuration + pub age_key_file: Option, + pub age_key: Option, + pub age_key_cmd: Option, + pub age_recipients: Option, + pub age_ssh_private_key_file: Option, + pub age_ssh_private_key_cmd: Option, + + // AWS KMS configuration + pub kms_arn: Option, + pub aws_profile: Option, + pub aws_access_key_id: Option, + pub aws_secret_access_key: Option, + pub aws_secret_key: Option, // alias + pub aws_region: Option, + + // Azure configuration + pub azure_client_id: Option, + pub azure_client_secret: Option, + pub azure_tenant_id: Option, + pub azure_keyvault_urls: Option, + + // GCP KMS configuration + pub gcp_kms: Option, + pub gcp_kms_client_type: Option, + pub gcp_kms_endpoint: Option, + pub gcp_kms_ids: Option, + pub gcp_kms_universe_domain: Option, + + // PGP configuration + pub pgp_fp: Option, + pub gpg_exec: Option, + + // HashiCorp Vault configuration + pub hc_vault_addr: Option, + pub hc_vault_token: Option, + pub hc_vault_allowlist: Option, + + // Huawei Cloud KMS + pub huawei_sdk_ak: Option, + pub huawei_sdk_sk: Option, + pub huawei_sdk_project_id: Option, + pub huawei_kms_ids: Option, + + // Generic SOPS settings + pub sops_config: Option, + pub sops_decryption_order: Option, + pub sops_editor: Option, + pub sops_enable_local_keyservice: Option, + pub sops_keyservice: Option, + + // AES_GCM + pub aes_gcm: Option, + + // ENCRYPT_DECRYPT toggle + pub encrypt_decrypt: Option, + + // Google OAuth token + pub google_oauth_access_token: Option, +} + +impl Default for SopsConfig { + fn default() -> Self { + Self { + aes_gcm: None, + age_key_cmd: None, + age_key_file: None, + age_key: None, + age_recipients: None, + age_ssh_private_key_cmd: None, + age_ssh_private_key_file: None, + aws_access_key_id: None, + aws_profile: None, + aws_region: None, + aws_secret_access_key: None, + aws_secret_key: None, + azure_client_id: None, + azure_client_secret: None, + azure_keyvault_urls: None, + azure_tenant_id: None, + encrypt_decrypt: None, + format: None, + gcp_kms_client_type: None, + gcp_kms_endpoint: None, + gcp_kms_ids: None, + gcp_kms_universe_domain: None, + gcp_kms: None, + google_oauth_access_token: None, + gpg_exec: None, + hc_vault_addr: None, + hc_vault_allowlist: None, + hc_vault_token: None, + huawei_kms_ids: None, + huawei_sdk_ak: None, + huawei_sdk_project_id: None, + huawei_sdk_sk: None, + kms_arn: None, + mode: SopsMode::SingleFile(PathBuf::from(".enc.yaml")), + pgp_fp: None, + sops_config: None, + sops_decryption_order: None, + sops_editor: None, + sops_enable_local_keyservice: None, + sops_keyservice: None, + } + } +} + +impl TryFrom<&ProviderUrl> for SopsConfig { + type Error = SecretSpecError; + + fn try_from(url: &ProviderUrl) -> std::result::Result { + if url.scheme() != "sops" { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "Invalid scheme '{}' for SOPS provider", + url.scheme() + ))); + } + + let mut config = SopsConfig::default(); + + // Build path from host and path + let mut target_path = PathBuf::new(); + + if let Some(host) = url.host() + && host != "localhost" + && !host.is_empty() + { + target_path.push(host); + } + + let url_path = url.path(); + + if !url_path.is_empty() && url_path != "/" { + let path_part = url_path.trim_start_matches('/'); + + if !path_part.is_empty() { + target_path.push(path_part); + } + } + + // If no path specified, use default + if target_path.as_os_str().is_empty() { + target_path = PathBuf::from(".enc.yaml"); + } + + // Parse query parameters first to get pattern and format + let pattern: Option = None; + let default_format = SopsFormat::Yaml; + + for (key, value) in url.query_pairs() { + match config.apply_url_field(key.as_ref(), value.as_ref()) { + Ok(_) => (), + Err(e) => return Err(e), + }; + } + + // Determine mode based on path and pattern + config.mode = if let Some(pattern_str) = pattern { + // Explicit directory mode with custom pattern + SopsMode::Directory { + path: target_path, + pattern: pattern_str, + default_format, + } + } else if target_path.is_dir() + || (!target_path.exists() && !Self::looks_like_file(&target_path)) + { + // Auto-detect directory mode + let auto_pattern = Self::build_default_pattern(&default_format); + SopsMode::Directory { + path: target_path, + pattern: auto_pattern, + default_format, + } + } else { + // Single file mode + SopsMode::SingleFile(target_path) + }; + + Ok(config) + } +} + +impl SopsConfig { + pub fn apply_env(&self, cmd: &mut std::process::Command) { + for spec in STRING_FIELDS { + if let Some(v) = (spec.field)(self) { + cmd.env(spec.env_key, EnvString::from(v)); + } + } + + for spec in PATHBUF_FIELDS { + if let Some(v) = (spec.field)(self) { + cmd.env(spec.env_key, EnvString::from(v)); + } + } + } + + pub fn apply_url_field(&mut self, key: &str, value: &str) -> Result<()> { + if key == "format" { + let fmt = SopsFormat::from_str(value).map_err(|e| { + SecretSpecError::ProviderOperationFailed(format!("Invalid format parameter: {}", e)) + })?; + + self.format = Some(fmt.clone()); + + return Ok(()); + } + + for spec in STRING_FIELDS { + if spec.url_key == key { + *(spec.field_mut)(self) = Some(String::from(value)); + return Ok(()); + } + } + + for spec in PATHBUF_FIELDS { + if spec.url_key == key { + *(spec.field_mut)(self) = Some(PathBuf::from(value)); + return Ok(()); + } + } + + Ok(()) + } + + fn looks_like_file(path: &Path) -> bool { + path.extension().is_some() + || path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.contains('.')) + .unwrap_or(false) + } + + fn build_default_pattern(format: &SopsFormat) -> String { + // Default to hierarchical structure + let extensions = format.extensions(); + format!("{{project}}/{{profile}}.enc.{}", extensions[0]) + } +} diff --git a/secretspec/src/provider/sops/fields.rs b/secretspec/src/provider/sops/fields.rs new file mode 100644 index 0000000..8caab2c --- /dev/null +++ b/secretspec/src/provider/sops/fields.rs @@ -0,0 +1,242 @@ +use std::path::PathBuf; + +pub struct FieldSpec { + pub env_key: &'static str, + pub field: fn(&super::SopsConfig) -> &Option, + pub field_mut: fn(&mut super::SopsConfig) -> &mut Option, + pub url_key: &'static str, +} + +pub static STRING_FIELDS: &[FieldSpec] = &[ + FieldSpec { + field: |c| &c.age_key, + field_mut: |c| &mut c.age_key, + url_key: "age_key", + env_key: "SOPS_AGE_KEY", + }, + FieldSpec { + field: |c| &c.age_key_cmd, + field_mut: |c| &mut c.age_key_cmd, + url_key: "age_key_cmd", + env_key: "SOPS_AGE_KEY_CMD", + }, + FieldSpec { + field: |c| &c.age_recipients, + field_mut: |c| &mut c.age_recipients, + url_key: "age_recipients", + env_key: "SOPS_AGE_RECIPIENTS", + }, + FieldSpec { + field: |c| &c.age_ssh_private_key_cmd, + field_mut: |c| &mut c.age_ssh_private_key_cmd, + url_key: "age_ssh_private_key_cmd", + env_key: "SOPS_AGE_SSH_PRIVATE_KEY_CMD", + }, + FieldSpec { + field: |c| &c.kms_arn, + field_mut: |c| &mut c.kms_arn, + url_key: "kms_arn", + env_key: "SOPS_KMS_ARN", + }, + FieldSpec { + field: |c| &c.aws_profile, + field_mut: |c| &mut c.aws_profile, + url_key: "aws_profile", + env_key: "AWS_PROFILE", + }, + FieldSpec { + field: |c| &c.aws_access_key_id, + field_mut: |c| &mut c.aws_access_key_id, + url_key: "aws_access_key_id", + env_key: "AWS_ACCESS_KEY_ID", + }, + FieldSpec { + field: |c| &c.aws_secret_access_key, + field_mut: |c| &mut c.aws_secret_access_key, + url_key: "aws_secret_access_key", + env_key: "AWS_SECRET_ACCESS_KEY", + }, + FieldSpec { + field: |c| &c.aws_secret_key, + field_mut: |c| &mut c.aws_secret_key, + url_key: "aws_secret_key", + env_key: "AWS_SECRET_KEY", + }, + FieldSpec { + field: |c| &c.aws_region, + field_mut: |c| &mut c.aws_region, + url_key: "aws_region", + env_key: "AWS_REGION", + }, + FieldSpec { + field: |c| &c.azure_client_id, + field_mut: |c| &mut c.azure_client_id, + url_key: "azure_client_id", + env_key: "AZURE_CLIENT_ID", + }, + FieldSpec { + field: |c| &c.azure_client_secret, + field_mut: |c| &mut c.azure_client_secret, + url_key: "azure_client_secret", + env_key: "AZURE_CLIENT_SECRET", + }, + FieldSpec { + field: |c| &c.azure_tenant_id, + field_mut: |c| &mut c.azure_tenant_id, + url_key: "azure_tenant_id", + env_key: "AZURE_TENANT_ID", + }, + FieldSpec { + field: |c| &c.azure_keyvault_urls, + field_mut: |c| &mut c.azure_keyvault_urls, + url_key: "azure_keyvault_urls", + env_key: "SOPS_AZURE_KEYVAULT_URLS", + }, + FieldSpec { + field: |c| &c.gcp_kms, + field_mut: |c| &mut c.gcp_kms, + url_key: "gcp_kms", + env_key: "SOPS_GCP_KMS", + }, + FieldSpec { + field: |c| &c.gcp_kms_client_type, + field_mut: |c| &mut c.gcp_kms_client_type, + url_key: "gcp_kms_client_type", + env_key: "SOPS_GCP_KMS_CLIENT_TYPE", + }, + FieldSpec { + field: |c| &c.gcp_kms_endpoint, + field_mut: |c| &mut c.gcp_kms_endpoint, + url_key: "gcp_kms_endpoint", + env_key: "SOPS_GCP_KMS_ENDPOINT", + }, + FieldSpec { + field: |c| &c.gcp_kms_ids, + field_mut: |c| &mut c.gcp_kms_ids, + url_key: "gcp_kms_ids", + env_key: "SOPS_GCP_KMS_IDS", + }, + FieldSpec { + field: |c| &c.gcp_kms_universe_domain, + field_mut: |c| &mut c.gcp_kms_universe_domain, + url_key: "gcp_kms_universe_domain", + env_key: "SOPS_GCP_KMS_UNIVERSE_DOMAIN", + }, + FieldSpec { + field: |c| &c.pgp_fp, + field_mut: |c| &mut c.pgp_fp, + url_key: "pgp_fp", + env_key: "SOPS_PGP_FP", + }, + FieldSpec { + field: |c| &c.gpg_exec, + field_mut: |c| &mut c.gpg_exec, + url_key: "gpg_exec", + env_key: "SOPS_GPG_EXEC", + }, + FieldSpec { + field: |c| &c.hc_vault_addr, + field_mut: |c| &mut c.hc_vault_addr, + url_key: "hc_vault_addr", + env_key: "VAULT_ADDR", + }, + FieldSpec { + field: |c| &c.hc_vault_token, + field_mut: |c| &mut c.hc_vault_token, + url_key: "hc_vault_token", + env_key: "VAULT_TOKEN", + }, + FieldSpec { + field: |c| &c.hc_vault_allowlist, + field_mut: |c| &mut c.hc_vault_allowlist, + url_key: "hc_vault_allowlist", + env_key: "SOPS_HC_VAULT_ALLOWLIST", + }, + FieldSpec { + field: |c| &c.huawei_sdk_ak, + field_mut: |c| &mut c.huawei_sdk_ak, + url_key: "huawei_sdk_ak", + env_key: "HUAWEICLOUD_SDK_AK", + }, + FieldSpec { + field: |c| &c.huawei_sdk_sk, + field_mut: |c| &mut c.huawei_sdk_sk, + url_key: "huawei_sdk_sk", + env_key: "HUAWEICLOUD_SDK_SK", + }, + FieldSpec { + field: |c| &c.huawei_sdk_project_id, + field_mut: |c| &mut c.huawei_sdk_project_id, + url_key: "huawei_sdk_project_id", + env_key: "HUAWEICLOUD_SDK_PROJECT_ID", + }, + FieldSpec { + field: |c| &c.huawei_kms_ids, + field_mut: |c| &mut c.huawei_kms_ids, + url_key: "huawei_kms_ids", + env_key: "SOPS_HUAWEICLOUD_KMS_IDS", + }, + FieldSpec { + field: |c| &c.sops_config, + field_mut: |c| &mut c.sops_config, + url_key: "sops_config", + env_key: "SOPS_CONFIG", + }, + FieldSpec { + field: |c| &c.sops_decryption_order, + field_mut: |c| &mut c.sops_decryption_order, + url_key: "sops_decryption_order", + env_key: "SOPS_DECRYPTION_ORDER", + }, + FieldSpec { + field: |c| &c.sops_editor, + field_mut: |c| &mut c.sops_editor, + url_key: "sops_editor", + env_key: "SOPS_EDITOR", + }, + FieldSpec { + field: |c| &c.sops_enable_local_keyservice, + field_mut: |c| &mut c.sops_enable_local_keyservice, + url_key: "sops_enable_local_keyservice", + env_key: "SOPS_ENABLE_LOCAL_KEYSERVICE", + }, + FieldSpec { + field: |c| &c.sops_keyservice, + field_mut: |c| &mut c.sops_keyservice, + url_key: "sops_keyservice", + env_key: "SOPS_KEYSERVICE", + }, + FieldSpec { + field: |c| &c.aes_gcm, + field_mut: |c| &mut c.aes_gcm, + url_key: "aes_gcm", + env_key: "AES_GCM", + }, + FieldSpec { + field: |c| &c.encrypt_decrypt, + field_mut: |c| &mut c.encrypt_decrypt, + url_key: "encrypt_decrypt", + env_key: "ENCRYPT_DECRYPT", + }, + FieldSpec { + field: |c| &c.google_oauth_access_token, + field_mut: |c| &mut c.google_oauth_access_token, + url_key: "google_oauth_access_token", + env_key: "GOOGLE_OAUTH_ACCESS_TOKEN", + }, +]; + +pub static PATHBUF_FIELDS: &[FieldSpec] = &[ + FieldSpec { + field: |c| &c.age_key_file, + field_mut: |c| &mut c.age_key_file, + url_key: "age_key_file", + env_key: "SOPS_AGE_KEY_FILE", + }, + FieldSpec { + field: |c| &c.age_ssh_private_key_file, + field_mut: |c| &mut c.age_ssh_private_key_file, + url_key: "age_ssh_private_key_file", + env_key: "SOPS_AGE_SSH_PRIVATE_KEY_FILE", + }, +]; diff --git a/secretspec/src/provider/sops/format.rs b/secretspec/src/provider/sops/format.rs new file mode 100644 index 0000000..e3c1037 --- /dev/null +++ b/secretspec/src/provider/sops/format.rs @@ -0,0 +1,91 @@ +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +use crate::SecretSpecError; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum SopsFormat { + /// YAML configuration files (.yaml, .yml) + #[default] + Yaml, + /// JSON configuration files (.json) + Json, + /// Environment variable files (.env) + Env, + /// INI configuration files (.ini) + Ini, + /// Binary files (encrypted as base64 under tree['data'] in JSON format) + Binary, +} + +impl fmt::Display for SopsFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Yaml => write!(f, "yaml"), + Self::Json => write!(f, "json"), + Self::Env => write!(f, "env"), + Self::Ini => write!(f, "ini"), + Self::Binary => write!(f, "binary"), + } + } +} + +impl FromStr for SopsFormat { + type Err = SecretSpecError; + + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "yaml" | "yml" => Ok(Self::Yaml), + "json" => Ok(Self::Json), + "env" | "dotenv" => Ok(Self::Env), + "ini" => Ok(Self::Ini), + "binary" | "bin" => Ok(Self::Binary), + _ => Err(SecretSpecError::ProviderOperationFailed(format!( + "Unsupported SOPS format: {}. Supported formats: yaml, json, env, ini, binary", + s + ))), + } + } +} + +impl SopsFormat { + /// Detect format from file extension + pub fn from_extension(ext: &str) -> Self { + match ext.to_lowercase().as_str() { + "yml" | "yaml" => Self::Yaml, + "json" => Self::Json, + "env" => Self::Env, + "ini" => Self::Ini, + // Any other extension is treated as binary + _ => Self::Binary, + } + } + + /// Get the canonical string representation for SOPS CLI + pub fn as_str(&self) -> &'static str { + match self { + Self::Yaml => "yaml", + Self::Json => "json", + Self::Env => "env", + Self::Ini => "ini", + Self::Binary => "binary", + } + } + + /// Get common file extensions for this format + pub fn extensions(&self) -> &'static [&'static str] { + match self { + Self::Yaml => &["yaml", "yml"], + Self::Json => &["json"], + Self::Env => &["env"], + Self::Ini => &["ini"], + Self::Binary => &["bin", "dat", "key", "cert", "p12", "pfx"], // Common binary file extensions + } + } + + /// Check if this format supports structured data (key-value lookup) + pub fn is_structured(&self) -> bool { + matches!(self, Self::Yaml | Self::Json | Self::Env | Self::Ini) + } +} diff --git a/secretspec/src/provider/sops/mod.rs b/secretspec/src/provider/sops/mod.rs new file mode 100644 index 0000000..4e4fc90 --- /dev/null +++ b/secretspec/src/provider/sops/mod.rs @@ -0,0 +1,596 @@ +use super::Provider; +use crate::provider::sops::config::SopsConfig; +use crate::provider::sops::format::SopsFormat; +use crate::{Result, SecretSpecError}; +use saphyr::{LoadableYamlNode, Yaml}; +use secrecy::ExposeSecret; +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +mod config; +mod fields; +mod format; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SopsMode { + SingleFile(PathBuf), + Directory { + path: PathBuf, + pattern: String, + default_format: SopsFormat, + }, +} + +pub struct SopsProvider { + config: SopsConfig, +} + +crate::register_provider! { + struct: SopsProvider, + config: SopsConfig, + name: "sops", + description: "SOPS encrypted file provider supporting YAML, JSON, ENV, INI, and binary files", + schemes: ["sops"], + examples: [ + "sops://.enc.yaml", + "sops://config/secrets.enc.json", + "sops://secrets-dir", + "sops://secrets-dir?pattern={project}.{profile}.enc.json", + "sops://secrets-dir?pattern={project}/{profile}.enc.env", + "sops://binary-secrets.enc.bin?format=binary", + "sops://.enc.yaml?age_key_file=/home/user/.config/sops/age/keys.txt", + "sops://secrets-dir?format=json&kms_arn=arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" + ], +} + +impl SopsProvider { + pub fn new(config: SopsConfig) -> Self { + Self { config } + } + + fn resolve_file_path(&self, project: &str, profile: &str) -> Result> { + match &self.config.mode { + SopsMode::SingleFile(path) => { + if path.exists() { + Ok(Some(path.clone())) + } else { + Ok(None) + } + } + SopsMode::Directory { + path, + pattern, + default_format, + } => self.find_matching_file(path, pattern, project, profile, default_format), + } + } + + fn find_matching_file( + &self, + dir_path: &Path, + pattern: &str, + project: &str, + profile: &str, + default_format: &SopsFormat, + ) -> Result> { + // Try multiple organizational patterns in order of preference + + // 1. Try hierarchical directory structure first (/.enc.) + if !project.is_empty() + && let Some(path) = + self.try_hierarchical_structure(dir_path, project, profile, default_format)? + { + return Ok(Some(path)); + } + + // 2. Try explicit pattern matching + if let Some(path) = + self.try_pattern_matching(dir_path, pattern, project, profile, default_format)? + { + return Ok(Some(path)); + } + + // 3. Try flat directory fallback patterns + self.try_flat_fallback_patterns(dir_path, project, profile, default_format) + } + + fn try_hierarchical_structure( + &self, + base_path: &Path, + project: &str, + profile: &str, + default_format: &SopsFormat, + ) -> Result> { + let project_dir = base_path.join(project); + + if !project_dir.exists() || !project_dir.is_dir() { + return Ok(None); + } + + // Try various profile file naming patterns within the project directory + let profile_patterns = [ + format!("{}.enc.{}", profile, default_format.as_str()), + format!("{}.sops.{}", profile, default_format.as_str()), + format!("{}.{}", profile, default_format.as_str()), + format!("sops.{}.{}", profile, default_format.as_str()), + format!("{}.sops", profile), + ]; + + // Also try all extensions for the format + let mut all_patterns = profile_patterns.to_vec(); + for ext in default_format.extensions() { + all_patterns.extend([ + format!("{}.enc.{}", profile, ext), + format!("{}.sops.{}", profile, ext), + format!("{}.{}", profile, ext), + format!("sops.{}.{}", profile, ext), + ]); + } + + for pattern in all_patterns { + let file_path = project_dir.join(&pattern); + if file_path.exists() && file_path.is_file() { + return Ok(Some(file_path)); + } + } + + Ok(None) + } + + fn try_pattern_matching( + &self, + dir_path: &Path, + pattern: &str, + project: &str, + profile: &str, + default_format: &SopsFormat, + ) -> Result> { + // Replace placeholders in pattern + let mut resolved_pattern = pattern.replace("{project}", project); + resolved_pattern = resolved_pattern.replace("{profile}", profile); + resolved_pattern = resolved_pattern.replace("{format}", default_format.as_str()); + + let full_path = dir_path.join(&resolved_pattern); + if full_path.exists() && full_path.is_file() { + return Ok(Some(full_path)); + } + + // Try with different extensions if no explicit format in pattern + if !pattern.contains("{format}") + && !default_format + .extensions() + .iter() + .any(|ext| pattern.contains(ext)) + { + for ext in default_format.extensions() { + let pattern_with_ext = if pattern.ends_with(".sops") { + format!("{}.{}", pattern, ext) + } else if pattern.contains(".sops.") { + pattern.to_string() + } else { + format!("{}.sops.{}", pattern, ext) + }; + + let mut resolved = pattern_with_ext.replace("{project}", project); + resolved = resolved.replace("{profile}", profile); + resolved = resolved.replace("{format}", ext); + + let full_path = dir_path.join(&resolved); + if full_path.exists() && full_path.is_file() { + return Ok(Some(full_path)); + } + } + } + + Ok(None) + } + + fn try_flat_fallback_patterns( + &self, + dir_path: &Path, + project: &str, + profile: &str, + default_format: &SopsFormat, + ) -> Result> { + let fallback_patterns = [ + // Standard flat patterns + format!("{}.{}.sops.{}", project, profile, default_format.as_str()), + format!("{}-{}.sops.{}", project, profile, default_format.as_str()), + format!("{}_{}.sops.{}", project, profile, default_format.as_str()), + format!("{}.sops.{}", project, default_format.as_str()), + format!("{}.{}", project, default_format.as_str()), + ]; + + // Try all extensions for the format + let mut all_patterns = fallback_patterns.to_vec(); + for ext in default_format.extensions() { + all_patterns.extend([ + format!("{}.{}.sops.{}", project, profile, ext), + format!("{}-{}.sops.{}", project, profile, ext), + format!("{}_{}.sops.{}", project, profile, ext), + format!("{}.sops.{}", project, ext), + format!("{}.{}", project, ext), + ]); + } + + for pattern in &all_patterns { + let file_path = dir_path.join(pattern); + if file_path.exists() { + return Ok(Some(file_path)); + } + } + + Ok(None) + } + + fn detect_format(&self, path: &Path) -> SopsFormat { + // Use explicit format if provided + if let Some(format) = &self.config.format { + return format.clone(); + } + + // Otherwise detect from file extension + path.extension() + .and_then(|ext| ext.to_str()) + .map(SopsFormat::from_extension) + .unwrap_or(SopsFormat::Yaml) + } + + fn execute_sops_command(&self, args: &[&str]) -> Result> { + let mut cmd = Command::new("sops"); + + cmd.args(args); + + self.config.apply_env(&mut cmd); + + let output = match cmd.output() { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(SecretSpecError::ProviderOperationFailed( + "The 'sops' CLI is not installed.\n\ + Install it from https://github.com/getsops/sops or via your package manager." + .to_string(), + )); + } + Err(e) => return Err(e.into()), + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + return Err(SecretSpecError::ProviderOperationFailed(stderr.to_string())); + } + + Ok(output.stdout) + } + + fn navigate_nested_value(&self, data: &serde_json::Value, path: &[&str]) -> Option { + let mut current = data; + + for segment in path { + match current { + serde_json::Value::Object(map) => { + current = map.get(*segment)?; + } + _ => return None, + } + } + + match current { + serde_json::Value::String(s) => Some(s.clone()), + other => Some(other.to_string()), + } + } + + fn navigate_yaml_value(&self, yaml: &Yaml, path: &[&str]) -> Option { + let mut current = yaml; + + for segment in path { + if current.is_mapping() { + current = current.as_mapping_get(segment)?; + } else { + return None; + } + } + + Some(self.yaml_to_string(current)) + } + + fn yaml_to_string(&self, yaml: &Yaml) -> String { + if yaml.is_null() { + String::new() + } else if let Some(s) = yaml.as_str() { + s.to_string() + } else if let Some(i) = yaml.as_integer() { + i.to_string() + } else if let Some(f) = yaml.as_floating_point() { + f.to_string() + } else if let Some(b) = yaml.as_bool() { + b.to_string() + } else { + format!("{:?}", yaml) + } + } + + fn build_lookup_paths<'a>( + &self, + project: &'a str, + key: &'a str, + profile: &'a str, + ) -> Vec> { + match &self.config.mode { + SopsMode::SingleFile(_) => { + // For single file mode, use hierarchical lookup within the file + let mut paths = Vec::new(); + if !project.is_empty() { + if !profile.is_empty() && profile != "default" { + paths.push(vec![project, profile, key]); + } + paths.push(vec![project, key]); + } + paths.push(vec![key]); + paths + } + SopsMode::Directory { .. } => { + let mut paths = Vec::new(); + paths.push(vec![profile, key]); + paths + + // For directory mode, the file already contains the right data + // so we just look for the key directly + // vec![vec![key]] + } + } + } + + fn parse_decrypted_content( + &self, + content: &[u8], + format: &SopsFormat, + project: &str, + key: &str, + profile: &str, + ) -> Result> { + match format { + SopsFormat::Yaml => { + let content_str = String::from_utf8_lossy(content); + let docs = Yaml::load_from_str(&content_str).map_err(|e| { + SecretSpecError::ProviderOperationFailed(format!("Failed to parse YAML: {}", e)) + })?; + + let lookup_paths = self.build_lookup_paths(project, key, profile); + + for doc in &docs { + for path in &lookup_paths { + if let Some(value) = self.navigate_yaml_value(doc, path) { + return Ok(Some(value)); + } + } + } + Ok(None) + } + SopsFormat::Json => { + let data: serde_json::Value = serde_json::from_slice(content).map_err(|e| { + SecretSpecError::ProviderOperationFailed(format!("Failed to parse JSON: {}", e)) + })?; + + let lookup_paths = self.build_lookup_paths(project, key, profile); + + for path in lookup_paths { + if let Some(value) = self.navigate_nested_value(&data, &path) { + return Ok(Some(value)); + } + } + + Ok(None) + } + SopsFormat::Env => { + let content_str = String::from_utf8_lossy(content); + + for line in content_str.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((env_key, value)) = line.split_once('=') + && env_key.trim() == key + { + let value = value.trim(); + let value = if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + &value[1..value.len() - 1] + } else { + value + }; + return Ok(Some(value.to_string())); + } + } + Ok(None) + } + SopsFormat::Ini => { + // For INI files, we could implement proper INI parsing + // For now, treat similar to ENV files but could be enhanced + let content_str = String::from_utf8_lossy(content); + for line in content_str.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with(';') || line.starts_with('#') { + continue; + } + + if let Some((ini_key, value)) = line.split_once('=') + && ini_key.trim() == key + { + return Ok(Some(value.trim().to_string())); + } + } + Ok(None) + } + SopsFormat::Binary => { + // For binary files, SOPS stores the encrypted data as base64 under tree['data'] + // The decrypted content should be the raw binary data + // Since we're looking for a specific key, this doesn't make much sense for binary files + // Return the entire content as a hex string for consistency + let hex_string = content + .iter() + .map(|b| format!("{:02x}", b)) + .collect::(); + Ok(Some(hex_string)) + } + } + } + + // Add a method to inspect the SOPS file metadata + fn inspect_sops_file(&self, file_path: &Path) -> Result<()> { + match std::fs::metadata(file_path) { + Err(e) => { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "Cannot access SOPS file {}: {}", + file_path.display(), + e + ))); + } + _ => {} + } + + // Try to read the file and inspect its structure + match std::fs::read_to_string(file_path) { + Ok(content) => { + eprintln!("DEBUG: File content length: {} characters", content.len()); + + // Try to parse as JSON to inspect SOPS metadata + if let Ok(json_value) = serde_json::from_str::(&content) { + if let Some(sops_obj) = json_value.get("sops") { + eprintln!("DEBUG: Found SOPS metadata section"); + + // Check for age recipients + if let Some(age_section) = sops_obj.get("age") + && let Some(recipients) = age_section.as_array() + { + eprintln!("DEBUG: Found {} age recipients", recipients.len()); + for (i, recipient) in recipients.iter().enumerate() { + if let Some(recipient_str) = + recipient.get("recipient").and_then(|r| r.as_str()) + { + eprintln!("DEBUG: Age recipient {}: {}", i, recipient_str); + } + } + } + + // Check for KMS keys + if let Some(kms_section) = sops_obj.get("kms") + && let Some(kms_keys) = kms_section.as_array() + { + eprintln!("DEBUG: Found {} KMS keys", kms_keys.len()); + for (i, key) in kms_keys.iter().enumerate() { + if let Some(arn) = key.get("arn").and_then(|a| a.as_str()) { + eprintln!("DEBUG: KMS key {}: {}", i, arn); + } + } + } + + // Check for PGP keys + if let Some(pgp_section) = sops_obj.get("pgp") + && let Some(pgp_keys) = pgp_section.as_array() + { + eprintln!("DEBUG: Found {} PGP keys", pgp_keys.len()); + for (i, key) in pgp_keys.iter().enumerate() { + if let Some(fp) = key.get("fp").and_then(|f| f.as_str()) { + eprintln!("DEBUG: PGP key {}: {}", i, fp); + } + } + } + } else { + eprintln!("WARNING: No SOPS metadata section found in file"); + } + } else { + eprintln!("DEBUG: File is not valid JSON, might be YAML or other format"); + } + } + Err(e) => { + eprintln!("WARNING: Cannot read SOPS file content: {}", e); + } + } + + Ok(()) + } +} + +impl Provider for SopsProvider { + fn name(&self) -> &'static str { + Self::PROVIDER_NAME + } + + fn get(&self, project: &str, key: &str, profile: &str) -> Result> { + let file_path = match self.resolve_file_path(project, profile)? { + Some(p) => p, + None => return Ok(None), + }; + + let format = self.detect_format(&file_path); + let path_str = file_path.to_string_lossy().to_string(); + + // Decrypt using CLI + let decrypted = match self.execute_sops_command(&["-d", &path_str]) { + Ok(out) => out, + Err(e) => return Err(e), + }; + + // Parse decrypted content using your existing logic + match self.parse_decrypted_content(&decrypted, &format, project, key, profile)? { + Some(v) => Ok(Some(SecretString::from(v))), + None => Ok(None), + } + } + + fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> { + let file_path = match self.resolve_file_path(project, profile)? { + Some(p) => p, + None => { + return Err(SecretSpecError::ProviderOperationFailed( + "SOPS file not found; cannot set key".to_string(), + )); + } + }; + + let format = self.detect_format(&file_path); + + if let Err(e) = self.inspect_sops_file(&file_path) { + eprintln!("WARNING: File inspection failed: {}", e); + } + + if !format.is_structured() { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "Cannot set key '{}' in non-structured SOPS file format {}", + key, format + ))); + } + + let path_str = file_path.to_string_lossy().to_string(); + + let escaped = value.expose_secret().replace('"', "\\\""); + + // SOPS path syntax: ["foo"]["bar"] + let sops_path = format!(r#"["{}"]"#, key.replace('.', r#""][""#)); + + let set_arg = format!(r#"{}="{}""#, sops_path, escaped); + + self.execute_sops_command(&["--set", &set_arg, &path_str])?; + + Ok(()) + } + + fn allows_set(&self) -> bool { + true + } + + fn uri(&self) -> String { + "".to_string() + } +} diff --git a/secretspec/src/provider/sops/notes.md b/secretspec/src/provider/sops/notes.md new file mode 100644 index 0000000..7f135b2 --- /dev/null +++ b/secretspec/src/provider/sops/notes.md @@ -0,0 +1,42 @@ +# SOPS Provider Notes + +## Environment Variables mentioned in SOPS documentation + +| Name | Addressed | Comments | +| ----------------------------- | --------- | -------- | +| AES_GCM | [ ] | | +| AWS_ACCESS_KEY_ID | [ ] | | +| AWS_SECRET_ACCESS_KEY | [ ] | | +| AWS_SECRET_KEY | [ ] | | +| AZURE_CLIENT_ID | [ ] | | +| AZURE_CLIENT_SECRET | [ ] | | +| AZURE_TENANT_ID | [ ] | | +| ENCRYPT_DECRYPT | [ ] | | +| GOOGLE_OAUTH_ACCESS_TOKEN | [ ] | | +| HUAWEICLOUD_SDK_AK | [ ] | | +| HUAWEICLOUD_SDK_PROJECT_ID | [ ] | | +| HUAWEICLOUD_SDK_SK | [ ] | | +| SOPS_AGE_KEY | [ ] | | +| SOPS_AGE_KEY_CMD | [ ] | | +| SOPS_AGE_KEY_FILE | [ ] | | +| SOPS_AGE_RECIPIENT | [ ] | | +| SOPS_AGE_RECIPIENTS | [ ] | | +| SOPS_AGE_SSH_PRIVATE_KEY_CMD | [ ] | | +| SOPS_AGE_SSH_PRIVATE_KEY_FILE | [ ] | | +| SOPS_AZURE_KEYVAULT_URLS | [ ] | | +| SOPS_CONFIG | [ ] | | +| SOPS_DECRYPTION_ORDER | [ ] | | +| SOPS_EDITOR | [ ] | | +| SOPS_ENABLE_LOCAL_KEYSERVICE | [ ] | | +| SOPS_GCP_KMS_CLIENT_TYPE | [ ] | | +| SOPS_GCP_KMS_ENDPOINT | [ ] | | +| SOPS_GCP_KMS_IDS | [ ] | | +| SOPS_GCP_KMS_UNIVERSE_DOMAIN | [ ] | | +| SOPS_GPG_EXEC | [ ] | | +| SOPS_HC_VAULT_ALLOWLIST | [ ] | | +| SOPS_HUAWEICLOUD_KMS_IDS | [ ] | | +| SOPS_KEYSERVICE | [ ] | | +| SOPS_KMS_ARN | [ ] | | +| SOPS_PGP_FP | [ ] | | +| VAULT_ADDR | [ ] | | +| VAULT_TOKEN | [ ] | | diff --git a/secretspec/src/provider/sops/test_fixtures/key.txt b/secretspec/src/provider/sops/test_fixtures/key.txt new file mode 100644 index 0000000..8781d8c --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/key.txt @@ -0,0 +1,3 @@ +# created: 2026-01-29T16:46:30-08:00 +# public key: age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34 +AGE-SECRET-KEY-1JA63S0F306F9Q3AQPD5QQWV03H858UUCFPL9NN5E0F00R5G3Q8QSHCEU2M diff --git a/secretspec/src/provider/sops/test_fixtures/test_secrets.enc.json b/secretspec/src/provider/sops/test_fixtures/test_secrets.enc.json new file mode 100644 index 0000000..e178f3d --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/test_secrets.enc.json @@ -0,0 +1,22 @@ +{ + "some-project-name": { + "development": { + "foobar": "ENC[AES256_GCM,data:kdh0,iv:mwU8S4YP1VFavv8PA0nqVp9lLDIUSrqsezuOhQN+nk8=,tag:R0mkYYD9tiHOWpDSBnQPKg==,type:str]" + }, + "production": { + "foobar": "ENC[AES256_GCM,data:RFlU,iv:qcE+CZ5TlHBrhEzIslefhR/VyJGObb/fc6eftXGsQCw=,tag:B/DCszfVQ1b/gBadoePiYg==,type:str]" + } + }, + "sops": { + "age": [ + { + "recipient": "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkRDAvb0VyRHhUL3B2UU1Y\ncjlZWlFwaGkzK1Z2aEEzd1kreDZmMVZMMDNBCnlpSnVGQVRkaDdMcjRBVi9nNk54\nUlB6Zi9PcjgwNzNEQWVzNklreUFia28KLS0tIGVJVUgyaEpzQS9aVHFGQmVuZGlC\nNmRWY3lGblZzTHFHQ0RJais5WmFKbXMK0w6VRhlzWoIrRTJSybM5qfKBzxcBscDE\nc+0uIdRbM7sp2QBMorzi+I2GqgNYE6xPOp64UzL2pkU3KR9qPpxm6A==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-01-30T01:17:42Z", + "mac": "ENC[AES256_GCM,data:mMkHzsHcdGlQiiiwxSnnRHPil4XYjk/crSxstRsbz1LbczF1wn3Rxov/Ucb09mQ2DPfHUUnsOpkRj1rAAZzLKvlclI663zOOHkERZBY9IJZWM+7Nk1fM+JtNYTJDBxYxS0Fby9xxfTLrtSIg6NtzmxD/U7F4WI3M+hHPNIq04E0=,iv:qvCiKqX1tcK5WB5YmJsexrUCehYc1s6d15pN2Sg0N4w=,tag:kC4limAg6yHxO+sHUOFN7g==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.11.0" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/test_secrets.json b/secretspec/src/provider/sops/test_fixtures/test_secrets.json new file mode 100644 index 0000000..f26d43b --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/test_secrets.json @@ -0,0 +1,10 @@ +{ + "some-project-name": { + "development": { + "foobar": "bar" + }, + "production": { + "foobar": "baz" + } + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/test_secrets/some-project-name.enc.json b/secretspec/src/provider/sops/test_fixtures/test_secrets/some-project-name.enc.json new file mode 100644 index 0000000..4891501 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/test_secrets/some-project-name.enc.json @@ -0,0 +1,20 @@ +{ + "development": { + "foobar": "ENC[AES256_GCM,data:xy9W,iv:62tR2rT0iNmAC3g9mmebRyKvSIZn8hxHx/uTQe0koog=,tag:YmveudtDgTi3M2OMZSaZ5A==,type:str]" + }, + "production": { + "foobar": "ENC[AES256_GCM,data:Gguu,iv:CAj1ZKkmaFosInq1ajVa2xy0j+Hgpz4btMqwh1RVLYo=,tag:e2iwU94tpNiWhQwVgztegw==,type:str]" + }, + "sops": { + "age": [ + { + "recipient": "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWTjVkQmtBTUQrYzhTWjlP\nZnhlUGNpY25Bb2kwcmpKd0hSWHJEQlhTd0VNCmlmdTVKQ0lWNEM2L0UvOVp3UUVI\nVGFoQk9FcmRIOHFtbkpJcGNreWF3R1UKLS0tIGZDL2lIam52Q1kvNjFjaC90Znkz\nbm1TWnBuQTQrOE1sRDAxVUh0bFdRZm8KLgM9T09k9shr550/iT/M5f/AJuDLGTFU\noYsc1tXqlx1I4mox+PI+su9Lrh7dN4vz7GwHdhb3Nac+3iBiGJEvAw==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-02-27T21:01:40Z", + "mac": "ENC[AES256_GCM,data:ZrlB3sFXzybBme0cI8vek9FSRxiFKcB/A46xF+tg/iIkZfffwg2NEzg0gqZfIKQmeZl7/XQ5EPIA6ZH9Eprzh//NVNyn2RiTDJrVdHA0dCIruqN5/+emmkMWw9RUCbkg8bliFOMd+wE/5Q6fPoKeMQVnlQXFg4xmu3ZfvTnqgfc=,iv:Uqe2cI/YjVY5ERZKThF/GBNrJzQOJQL8+xhYJ0Q0+jQ=,tag:3nh9yGjp8gWtuEGh+ZE1lw==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.11.0" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/test_secrets/some-project-name.json b/secretspec/src/provider/sops/test_fixtures/test_secrets/some-project-name.json new file mode 100644 index 0000000..3fa197e --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/test_secrets/some-project-name.json @@ -0,0 +1,8 @@ +{ + "development": { + "foobar": "foo" + }, + "production": { + "foobar": "baz" + } +} diff --git a/secretspec/src/provider/sops/tests.rs b/secretspec/src/provider/sops/tests.rs new file mode 100644 index 0000000..9b2b841 --- /dev/null +++ b/secretspec/src/provider/sops/tests.rs @@ -0,0 +1,318 @@ +use crate::provider::sops::config::SopsConfig; + +use super::*; +use std::{fs, str::FromStr}; +use tempfile::TempDir; + +#[test] +fn test_sops_format_from_str() { + assert_eq!(SopsFormat::from_str("yaml").unwrap(), SopsFormat::Yaml); + assert_eq!(SopsFormat::from_str("yml").unwrap(), SopsFormat::Yaml); + assert_eq!(SopsFormat::from_str("json").unwrap(), SopsFormat::Json); + assert_eq!(SopsFormat::from_str("env").unwrap(), SopsFormat::Env); + assert_eq!(SopsFormat::from_str("dotenv").unwrap(), SopsFormat::Env); + assert_eq!(SopsFormat::from_str("ini").unwrap(), SopsFormat::Ini); + assert_eq!(SopsFormat::from_str("binary").unwrap(), SopsFormat::Binary); + assert_eq!(SopsFormat::from_str("bin").unwrap(), SopsFormat::Binary); + + assert!(SopsFormat::from_str("invalid").is_err()); + let err = SopsFormat::from_str("invalid").unwrap_err(); + assert!( + err.to_string() + .contains("Supported formats: yaml, json, env, ini, binary") + ); +} + +#[test] +fn test_sops_format_extensions() { + assert_eq!(SopsFormat::Yaml.extensions(), &["yaml", "yml"]); + assert_eq!(SopsFormat::Json.extensions(), &["json"]); + assert_eq!(SopsFormat::Env.extensions(), &["env"]); + assert_eq!(SopsFormat::Ini.extensions(), &["ini"]); + assert_eq!( + SopsFormat::Binary.extensions(), + &["bin", "dat", "key", "cert", "p12", "pfx"] + ); +} + +#[test] +fn test_sops_format_is_structured() { + assert!(SopsFormat::Yaml.is_structured()); + assert!(SopsFormat::Json.is_structured()); + assert!(SopsFormat::Env.is_structured()); + assert!(SopsFormat::Ini.is_structured()); + assert!(!SopsFormat::Binary.is_structured()); +} + +#[test] +fn test_sops_format_from_extension() { + assert_eq!(SopsFormat::from_extension("yaml"), SopsFormat::Yaml); + assert_eq!(SopsFormat::from_extension("yml"), SopsFormat::Yaml); + assert_eq!(SopsFormat::from_extension("json"), SopsFormat::Json); + assert_eq!(SopsFormat::from_extension("env"), SopsFormat::Env); + assert_eq!(SopsFormat::from_extension("ini"), SopsFormat::Ini); + + // Unknown extensions default to binary + assert_eq!(SopsFormat::from_extension("bin"), SopsFormat::Binary); + assert_eq!(SopsFormat::from_extension("key"), SopsFormat::Binary); + assert_eq!(SopsFormat::from_extension("unknown"), SopsFormat::Binary); +} + +#[test] +fn test_hierarchical_directory_structure() { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create hierarchical structure: base/myapp/production.sops.json + let project_dir = base_path.join("myapp"); + fs::create_dir_all(&project_dir).unwrap(); + fs::write( + project_dir.join("production.sops.json"), + r#"{"database_url": "prod-db"}"#, + ) + .unwrap(); + fs::write( + project_dir.join("development.sops.json"), + r#"{"database_url": "dev-db"}"#, + ) + .unwrap(); + + let config = SopsConfig { + mode: SopsMode::Directory { + path: base_path.to_path_buf(), + pattern: "{project}/{profile}.sops.json".to_string(), + default_format: SopsFormat::Json, + }, + ..Default::default() + }; + let provider = SopsProvider::new(config); + + // Test hierarchical lookup + let result = provider + .try_hierarchical_structure(base_path, "myapp", "production", &SopsFormat::Json) + .unwrap(); + + assert!(result.is_some()); + let path = result.unwrap(); + assert_eq!(path.file_name().unwrap(), "production.sops.json"); + assert_eq!(path.parent().unwrap().file_name().unwrap(), "myapp"); +} + +#[test] +fn test_build_lookup_paths_single_file_vs_directory() { + // Single file mode - hierarchical lookup + let single_file_config = SopsConfig { + mode: SopsMode::SingleFile(PathBuf::from(".sops.yaml")), + ..Default::default() + }; + let provider = SopsProvider::new(single_file_config); + + let paths = provider.build_lookup_paths("myapp", "database_url", "production"); + assert_eq!( + paths, + vec![ + vec!["myapp", "production", "database_url"], + vec!["myapp", "database_url"], + vec!["database_url"] + ] + ); + + // Directory mode - direct lookup + let dir_config = SopsConfig { + mode: SopsMode::Directory { + path: PathBuf::from("secrets"), + pattern: "{project}.{profile}.sops.json".to_string(), + default_format: SopsFormat::Json, + }, + ..Default::default() + }; + let provider = SopsProvider::new(dir_config); + + let paths = provider.build_lookup_paths("myapp", "database_url", "production"); + assert_eq!(paths, vec![vec!["database_url"]]); +} + +#[test] +fn test_parse_decrypted_content_binary() { + let provider = SopsProvider::new(SopsConfig::default()); + let binary_content = b"\x00\x01\x02\x03\xFF"; + + let result = provider + .parse_decrypted_content(binary_content, &SopsFormat::Binary, "", "any_key", "") + .unwrap(); + + // Should return hex encoded content + assert!(result.is_some()); + let value = result.unwrap(); + assert_eq!(value, "00010203ff"); +} + +#[test] +fn test_integration_with_real_sops_file() { + use secrecy::ExposeSecret; + use std::env; + + // use std::path::PathBuf; + + // Attempt to get the current working directory + let encrypted_file = match env::current_dir() { + Ok(current_dir) => { + // Create a PathBuf from the current directory + let path_buf = PathBuf::from(¤t_dir) + .join("src/provider/sops/test_fixtures/test_secrets.enc.json"); + + // Print the current directory and the PathBuf + println!("Current working directory: {}", current_dir.display()); + println!("PathBuf representation: {:?}", path_buf); + + path_buf + } + Err(e) => { + panic!("Error retrieving current directory: {}", e); + } + }; + + let age_key_file = + PathBuf::from(env::current_dir().unwrap()).join("src/provider/sops/test_fixtures/key.txt"); + + eprintln!( + "DEBUG: Testing with encrypted file: {}", + encrypted_file.display() + ); + eprintln!( + "DEBUG: Testing with age key file: {}", + age_key_file.display() + ); + + if !encrypted_file.exists() { + eprintln!( + "SKIP: Encrypted file not found: {}", + encrypted_file.display() + ); + return; + } + + if !age_key_file.exists() { + eprintln!("SKIP: Age key file not found: {}", age_key_file.display()); + return; + } + + // Try to read the first few lines of the age key file to verify it's valid + match std::fs::read_to_string(&age_key_file) { + Ok(content) => { + let lines: Vec<&str> = content.lines().take(3).collect(); + eprintln!("DEBUG: Age key file first few lines:"); + for (i, line) in lines.iter().enumerate() { + if line.starts_with("AGE-SECRET-KEY-") { + eprintln!(" {}: AGE-SECRET-KEY-*** (truncated)", i); + } else { + eprintln!(" {}: {}", i, line); + } + } + } + Err(e) => { + eprintln!("WARNING: Cannot read age key file: {}", e); + } + } + + // Configure for single file mode with the specific encrypted file + let config = SopsConfig { + mode: SopsMode::SingleFile(encrypted_file.clone()), + format: Some(SopsFormat::Json), + age_key_file: Some(age_key_file), + ..Default::default() + }; + + let provider = SopsProvider::new(config); + + // Test decryption by trying to read a key from the file + eprintln!("DEBUG: Attempting to decrypt and read key 'foobar'"); + let result = provider.get("some-project-name", "foobar", "development"); + + match result { + Ok(Some(value)) => { + assert!(value.expose_secret().eq("bar")) + } + Ok(None) => { + panic!("Specified key not found") + } + Err(e) => { + panic!("Decryption failed: {:?}", e) + } + } +} + +#[test] +fn test_integration_with_directory_structure() { + use secrecy::ExposeSecret; + use std::env; + + println!("here"); + + // Attempt to get the current working directory + let secrets_dir = match env::current_dir() { + Ok(current_dir) => { + // Create a PathBuf from the current directory + let path_buf = + PathBuf::from(¤t_dir).join("src/provider/sops/test_fixtures/test_secrets"); + + // Print the current directory and the PathBuf + println!("Current working directory: {}", current_dir.display()); + println!("PathBuf representation: {:?}", path_buf); + + path_buf + } + Err(e) => { + panic!("Error retrieving current directory: {}", e); + } + }; + + let age_key_file = + PathBuf::from(env::current_dir().unwrap()).join("src/provider/sops/test_fixtures/key.txt"); + + println!("age key file path: {:?}", age_key_file); + + if !age_key_file.exists() { + panic!("age key file not found"); + } + + // Configure for directory mode with pattern matching + let config = SopsConfig { + mode: SopsMode::Directory { + path: secrets_dir, + pattern: "{project}.enc.json".to_string(), + default_format: SopsFormat::Json, + }, + format: Some(SopsFormat::Json), + age_key_file: Some(age_key_file), + ..Default::default() + }; + + println!("config: {:?}", config); + + let provider = SopsProvider::new(config); + + // Test decryption using project name from the filename + let result = provider.get("some-project-name", "foobar", "development"); + + println!( + "result: {:?}", + result.as_ref().unwrap().as_ref().unwrap().expose_secret() + ); + + match result { + Ok(Some(value)) => { + assert!(!value.expose_secret().is_empty()); + + assert!(value.expose_secret().eq("foo")); + } + Ok(None) => { + eprintln!( + "Key not found in directory mode - this might be expected if the file structure doesn't match the pattern" + ); + } + Err(e) => { + eprintln!("Directory decryption failed (might be expected): {:?}", e); + } + } +} diff --git a/secretspec/src/tests.rs b/secretspec/src/tests.rs index ed68839..71421c7 100644 --- a/secretspec/src/tests.rs +++ b/secretspec/src/tests.rs @@ -1,3 +1,4 @@ +use crate::Provider; use crate::config::{ Config, GlobalConfig, GlobalDefaults, ParseError, Profile, Project, Resolved, Secret, }; @@ -10,6 +11,7 @@ use std::convert::TryFrom; use std::path::Path; use std::{fs, io}; use tempfile::TempDir; +use url::Url; // Helper function for tests that need to parse from string fn parse_spec_from_str(content: &str, _base_path: Option<&Path>) -> Result { @@ -4763,3 +4765,74 @@ fn test_resolve_profile_unknown_returns_invalid_profile() { other => panic!("expected InvalidProfile, got {other:?}"), } } + +#[test] +fn test_sops_provider_registration() { + let url = Url::parse("sops://./secrets").unwrap(); + + let provider_result: std::result::Result, _> = (&url).try_into(); + + assert!(provider_result.is_ok()); + + let provider = provider_result.unwrap(); + + assert_eq!(provider.name(), "sops"); + + assert!(provider.allows_set()); +} + +#[test] +fn test_sops_provider_with_age_config() { + let url = Url::parse("sops://./secrets?age_key_file=/tmp/age_key&format=json").unwrap(); + let provider_result: std::result::Result, _> = (&url).try_into(); + assert!(provider_result.is_ok()); + + let provider = provider_result.unwrap(); + assert_eq!(provider.name(), "sops"); +} + +#[test] +fn test_sops_provider_with_real_age_config() { + let url = Url::parse("sops://./provider/sops/test-fixtures/test-secrets.enc.json?age_key_file=./provider/sops/test-fixtures/key.txt&format=json").unwrap(); + let provider_result: std::result::Result, _> = (&url).try_into(); + assert!(provider_result.is_ok()); + + let provider = provider_result.unwrap(); + assert_eq!(provider.name(), "sops"); +} + +#[test] +fn test_sops_provider_with_aws_config() { + let url = Url::parse( + "sops://./secrets?kms_arn=arn:aws:kms:us-east-1:123456789012:key/test&aws_profile=prod", + ) + .unwrap(); + let provider_result: std::result::Result, _> = (&url).try_into(); + assert!(provider_result.is_ok()); + + let provider = provider_result.unwrap(); + assert_eq!(provider.name(), "sops"); +} + +#[test] +fn test_sops_format_parsing() { + let url = Url::parse("sops://./secrets?format=json").unwrap(); + let provider_result: std::result::Result, _> = (&url).try_into(); + assert!(provider_result.is_ok()); +} + +#[test] +fn test_sops_invalid_format() { + let url = Url::parse("sops://./secrets?format=invalid").unwrap(); + + let provider_result: std::result::Result, _> = (&url).try_into(); + + assert!(provider_result.is_err()); +} + +#[test] +fn test_sops_provider_invalid_scheme() { + let url = Url::parse("invalid://./secrets").unwrap(); + let provider_result: std::result::Result, _> = (&url).try_into(); + assert!(provider_result.is_err()); +}