diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b86f52e..4b8ca99d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,7 +61,7 @@ jobs: - name: Check each provider feature standalone run: | set -euo pipefail - for feature in cli keyring gcsm awssm vault openbao infisical bws akv; do + for feature in cli keyring gcsm awssm vault openbao infisical bws akv sops; do echo "::group::$feature" cargo check --package secretspec --no-default-features --features "$feature" echo "::endgroup::" @@ -75,6 +75,20 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v5 + - name: Download SOPS 3.13.1 + uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f + with: + repository: getsops/sops + tag: v3.13.1 + fileName: sops-v3.13.1.amd64.exe + out-file-path: sops-bin + + - name: Add SOPS to PATH + shell: pwsh + run: | + Rename-Item -Path "sops-bin/sops-v3.13.1.amd64.exe" -NewName "sops.exe" + (Resolve-Path "sops-bin").Path | + Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Install Rust run: rustup toolchain install stable --profile minimal - name: cargo test diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb6eb45..4528d5a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- SOPS provider (`sops://`, `sops` build feature) for reading and writing + YAML, JSON, dotenv, and INI files through the SOPS CLI, including templated + per-project/profile paths and provider-credential injection for encryption + keys and cloud authentication. - `secretspec config init --provider --profile ` can save global defaults without interactive prompts, including `--profile none` to clear the default profile. diff --git a/devenv.nix b/devenv.nix index 706ab1e7..b7251cf9 100644 --- a/devenv.nix +++ b/devenv.nix @@ -7,7 +7,9 @@ # rust-toolchain.toml (read automatically via toolchainFile). toolchainFile = ./rust-toolchain.toml; }; + languages.javascript = { + directory = "./docs"; enable = true; # Node 22 (the plain nixpkgs default) bundles npm 10.x, which mishandles # npm Trusted Publishing's OIDC handshake and can even misreport a brand @@ -70,6 +72,8 @@ # ext-php-rs's bindgen step can parse php.h. pkgs.php.unwrapped.dev pkgs.rustPlatform.bindgenHook + # For development of the SOPS provider + pkgs.sops ]; # Fully-static musl build of the Go SDK (-tags static + -extldflags -static). @@ -110,7 +114,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/docs/astro.config.ts b/docs/astro.config.ts index 37bb9864..84249950 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -119,7 +119,7 @@ $ secretspec import dotenv://.env.production ## Providers -Secrets can be stored in: keyring (default), KeePass KDBX (0.17+), dotenv files, environment variables, systemd service credentials (0.17+), 1Password, Gopass (0.15+), LastPass, Pass, Proton Pass, Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault, OpenBao (0.17+), Bitwarden Secrets Manager, Azure Key Vault, Infisical (0.16+), or age (0.17+).`, +Secrets can be stored in: keyring (default), KeePass KDBX (0.17+), dotenv files, environment variables, systemd service credentials (0.17+), 1Password, Gopass (0.15+), LastPass, Pass, Proton Pass, Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault, OpenBao (0.17+), Bitwarden Secrets Manager, Azure Key Vault, Infisical (0.16+), age (0.17+), or SOPS (0.17+).`, }), ], title: "SecretSpec", @@ -252,6 +252,11 @@ Secrets can be stored in: keyring (default), KeePass KDBX (0.17+), dotenv files, slug: "providers/age", badge: { text: "0.17+", variant: "note" }, }, + { + label: "SOPS", + slug: "providers/sops", + badge: { text: "0.17+", variant: "note" }, + }, ], }, { diff --git a/docs/src/assets/sops-logo.svg b/docs/src/assets/sops-logo.svg new file mode 100644 index 00000000..eccd740a --- /dev/null +++ b/docs/src/assets/sops-logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/src/content/docs/concepts/providers.md b/docs/src/content/docs/concepts/providers.md index 82480aea..3eee0c31 100644 --- a/docs/src/content/docs/concepts/providers.md +++ b/docs/src/content/docs/concepts/providers.md @@ -57,6 +57,7 @@ DATABASE_URL = { description = "Production database", providers = ["prod_vault"] | [akv](/providers/akv/) | Azure Key Vault (requires the `akv` build feature) | ✓ | ✓ | ✓ | — | | [infisical](/providers/infisical/) (0.16+) | Infisical (requires the `infisical` build feature) | ✓ | ✓ | ✓ | — | | [age](/providers/age/) (0.17+) | An age-encrypted file (requires the `age` build feature) | ✓ | ✓ | ✓ | — | +| [sops](/providers/sops/) (0.17+) | SOPS-encrypted files (requires the `sops` build feature and SOPS CLI) | ✓ | ✓ | ✓ | Depends on the configured SOPS key service | “TPM-backed keys” means the local key used by the provider can be protected by a [TPM 2.0](https://trustedcomputinggroup.org/resource/tpm-library-specification/) @@ -295,7 +296,10 @@ Provider credentials follow these rules: - **Names are provider-specific.** Bitwarden accepts `access_token`; Vault accepts `token`, `role_id`, and `secret_id`; 1Password accepts `service_account_token`; Azure Key Vault accepts `tenant_id`, `client_id`, and - `client_secret`. Unsupported names are rejected before any source is read. + `client_secret`; SOPS (0.17+) accepts `age_key`, `aws_secret_access_key`, + `azure_client_secret`, `google_oauth_access_token`, `hc_vault_token`, + `huawei_sdk_ak`, and `huawei_sdk_sk`. Unsupported names are rejected before + any source is read. ## Next steps diff --git a/docs/src/content/docs/providers/sops.md b/docs/src/content/docs/providers/sops.md new file mode 100644 index 00000000..94ce483a --- /dev/null +++ b/docs/src/content/docs/providers/sops.md @@ -0,0 +1,207 @@ +--- +title: "SOPS: Secrets OPerationS" +description: SOPS integration +tableOfContents: + maxHeadingLevel: 4 +--- + +The SOPS provider integrates with SOPS to enable reading and writing of secrets encrypted at rest in local files. + +:::caution[Version compatibility] +The `sops` provider is added in SecretSpec 0.17. +::: + +## Prerequisites + +- The SOPS CLI available within the environment: + - Manually download a release binary + - Use the SOPS Nix package + - Install with a package manager: + + ```bash + # Homebrew + $ brew install sops + + # Arch + $ sudo pacman -S sops + ``` + +## Configuration + +### URI Format + +``` +sops://path/to/secret[?key=value[&key=value]...] +``` + +- `path/to/secret` — required path to the encrypted file (absolute or relative) + + - :::tip[Secrets in multiple files?] + In order to represent more than one file the path must be templated. i.e. separate SOPS-encrypted files per profile, or a hierarchical directory structure. + + A templated path must include both `{project}` and `{profile}` placeholders. Examples: + + - `sops://secrets-dir/{project}.{profile}.enc.json` + - `sops://secrets-dir/{project}/{profile}.enc.yaml` + ::: + +- `?key=value` -- optional query parameter, refer to the [Query Parameters section](#query-parameters) for available parameters. +- `&key=value` — additional parameters + +### Query Parameters + +Excepting the SecretSpec-specific `format` parameter, refer to the SOPS documentation for the purpose and usage of each parameter. + +#### SecretSpec + +| Provider URL Query Parameter Name | Purpose | +| --------------------------------- | ----------------------------------------------------------------------------------------------------- | +| format | Overrides the extension-based file format detection. Valid values: `dotenv` `env` `ini` `json` `yaml` | + +#### SOPS + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| sops_config | SOPS_CONFIG | +| sops_decryption_order | SOPS_DECRYPTION_ORDER | +| sops_editor | SOPS_EDITOR | +| sops_enable_local_keyservice | SOPS_ENABLE_LOCAL_KEYSERVICE | +| sops_keyservice | SOPS_KEYSERVICE | + +#### Age + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| age_key_cmd | SOPS_AGE_KEY_CMD | +| age_key_file | SOPS_AGE_KEY_FILE | +| age_recipients | SOPS_AGE_RECIPIENTS | +| age_ssh_private_key_cmd | SOPS_AGE_SSH_PRIVATE_KEY_CMD | +| age_ssh_private_key_file | SOPS_AGE_SSH_PRIVATE_KEY_FILE | + +#### AWS + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| aws_access_key_id | AWS_ACCESS_KEY_ID | +| aws_profile | AWS_PROFILE | +| aws_region | AWS_REGION | +| kms_arn | SOPS_KMS_ARN | + +#### GCP + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| gcp_kms_client_type | SOPS_GCP_KMS_CLIENT_TYPE | +| gcp_kms_endpoint | SOPS_GCP_KMS_ENDPOINT | +| gcp_kms_ids | SOPS_GCP_KMS_IDS | +| gcp_kms_universe_domain | SOPS_GCP_KMS_UNIVERSE_DOMAIN | + +#### Azure + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| azure_client_id | AZURE_CLIENT_ID | +| azure_keyvault_urls | SOPS_AZURE_KEYVAULT_URLS | +| azure_tenant_id | AZURE_TENANT_ID | + +#### PGP + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| pgp_fp | SOPS_PGP_FP | + +#### GPG + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| gpg_exec | SOPS_GPG_EXEC | + +#### Hashicorp Vault/OpenBao + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| hc_vault_addr | VAULT_ADDR | +| hc_vault_allowlist | SOPS_HC_VAULT_ALLOWLIST | + +#### Huawei Cloud + +| Provider URL Query Parameter Name | Corresponding Environment Variable | +| --------------------------------- | ---------------------------------- | +| huawei_kms_ids | SOPS_HUAWEICLOUD_KMS_IDS | +| huawei_sdk_project_id | HUAWEICLOUD_SDK_PROJECT_ID | + +### Provider credentials + +Secret values used to authenticate SOPS belong in a provider alias's +`credentials` map, not in the SOPS URI. The SOPS provider accepts these +semantic credential names: + +| Credential | SOPS environment fallback | +| ---------- | ------------------------- | +| `age_key` | `SOPS_AGE_KEY` | +| `aws_secret_access_key` | `AWS_SECRET_ACCESS_KEY` | +| `azure_client_secret` | `AZURE_CLIENT_SECRET` | +| `google_oauth_access_token` | `GOOGLE_OAUTH_ACCESS_TOKEN` | +| `hc_vault_token` | `VAULT_TOKEN` | +| `huawei_sdk_ak` | `HUAWEICLOUD_SDK_AK` | +| `huawei_sdk_sk` | `HUAWEICLOUD_SDK_SK` | + +For example, this alias loads an age identity from the system keyring and +passes it only to the SOPS child process: + +```toml title="secretspec.toml" +[providers] +sops_age = { + uri = "sops://secrets.enc.yaml?age_recipients=age1example", + credentials = { age_key = "keyring" } +} + +[profiles.production.defaults] +providers = ["sops_age"] +``` + +When a credential is not declared on the alias, SOPS can still use its normal +environment variable. See [Provider credentials](/concepts/providers/#provider-credentials) +for convention and explicit credential-source addresses. + +### Format handling + +SecretSpec asks SOPS to emit JSON when decrypting, then uses one lookup path +for YAML, JSON, dotenv, and INI files. When `?format=` is present, SecretSpec +also passes the corresponding SOPS input type so filenames such as +`.env.production.enc?format=dotenv` work correctly. + +SOPS does not support `--input-type ini`, so `?format=ini` is accepted only +when the filename itself ends in `.ini`. Without `?format=`, the filename must +end in `.yaml`, `.yml`, `.json`, `.env`, `.dotenv`, or `.ini`; an unrecognized +extension is reported as a configuration error. + +YAML and JSON single-file layouts can nest values by project and profile. INI +uses profile sections, while dotenv is always flat; in a single dotenv file, +the same key therefore cannot hold different values for different profiles. +Use a templated path such as `.env.{profile}.enc?format=dotenv` when profiles +need separate dotenv values. + +## Usage + +### Set a secret with age + +```bash +$ secretspec set DATABASE_URL --provider sops://secrets.enc.json?age_key_file=key.txt&age_recipients=age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34 + +``` + +Or, if `keys.txt` exists in a place discoverable by SOPS, and `.sops.yaml` exists in the project and has been configured e.g. + +```yaml sops.yaml +creation_rules: + - path_regex: secrets.enc.json$ + age: "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34" +``` + +then: + +```bash +$ secretspec set DATABASE_URL --provider sops://secrets.enc.json + +``` diff --git a/docs/src/content/docs/quick-start.mdx b/docs/src/content/docs/quick-start.mdx index 96108548..c2587b63 100644 --- a/docs/src/content/docs/quick-start.mdx +++ b/docs/src/content/docs/quick-start.mdx @@ -117,6 +117,7 @@ $ secretspec config init akv: Azure Key Vault infisical: Infisical secret management (0.16+) age: age-encrypted file (0.17+) + sops: SOPS encrypted files (0.17+) ? Select your default profile: > development default diff --git a/docs/src/content/docs/reference/providers.md b/docs/src/content/docs/reference/providers.md index 11bfae9b..f7c03a3e 100644 --- a/docs/src/content/docs/reference/providers.md +++ b/docs/src/content/docs/reference/providers.md @@ -315,6 +315,31 @@ age://secrets.age?recipients-file=secrets.age.recipients # Share with a roster **Authentication**: The `identity` credential, `AGE_IDENTITY`, or `?identity=`; recipients from `?recipients-file=` or derived from the identity **Storage**: One `KEY=value` entry per secret inside the encrypted blob at PATH +## SOPS Provider (0.17+) + +:::caution[Version compatibility] +The `sops` provider is added in SecretSpec 0.17. +::: + +**URI**: `sops://PATH[?format=yaml|json|dotenv|ini]` - Stores secrets in a +SOPS-encrypted file or a templated set of files + +```bash +sops://secrets.enc.yaml +sops://secrets/{project}/{profile}.enc.json +sops://secrets/{project}/.env.{profile}.enc?format=dotenv +``` + +**Features**: Read/write, YAML, JSON, dotenv, and INI files, SOPS key-service +support, and profile-aware templated paths +**Prerequisites**: The `sops` CLI and the required SOPS key configuration; +build with `--features sops` (0.17+) +**Authentication**: SOPS environment variables or provider credentials such as +`age_key`, `aws_secret_access_key`, `azure_client_secret`, and `hc_vault_token` +**Storage**: Single-file YAML and JSON can namespace convention secrets by +project and profile; INI uses profile sections and dotenv is flat. Templated +paths contain flat keys in one file per project/profile. + ## Provider Selection ### Command Line @@ -359,3 +384,4 @@ export SECRETSPEC_PROVIDER="dotenv:///config/.env" | AKV | ✅ Azure-managed | Cloud (Azure) | ✅ Yes | | Infisical | ✅ Infisical-managed | Cloud (Infisical) or self-hosted | ✅ Yes | | age (0.17+) | ✅ age encryption | Local filesystem | ❌ No | +| SOPS (0.17+) | ✅ Configured SOPS encryption | Local filesystem | Depends on configured key service | diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index e43dfbec..bbe35ab1 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -1,6 +1,8 @@ --- import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; +import { Image } from 'astro:assets'; import { getCollection } from 'astro:content'; +import sopsLogo from '../assets/sops-logo.svg'; const docs = await getCollection('docs'); @@ -28,8 +30,12 @@ const latestPostDate = latestPost?.data.date : null; type ProviderMetadata = { - /** simpleicons CDN icon name, if not provided renders the generic lock icon as fallback */ - icon?: string; + /** One of: + * - simpleicons CDN icon name + * - object specifying image source and type + * - if not provided renders the generic lock icon as fallback + */ + icon?: string | { src: typeof sopsLogo; type: 'bundled'}; label: string; slug: string }; @@ -55,6 +61,7 @@ const providerMetadata: ProviderMetadata[] = [ { icon: 'systemd', slug: 'systemd-credential', label: 'systemd credentials (0.17+)' }, { icon: 'linux', slug: 'keyring', label: 'Secret Service' }, { slug: 'keyring', label: 'Credential Manager' }, + { icon: { src: sopsLogo, type: 'bundled' }, slug: 'sops', label: 'SOPS (0.17+)' }, ]; @@ -148,6 +155,7 @@ $ secretspec config init infisical: Infisical secret management openbao: OpenBao secret management (0.17+) age: age-encrypted file (0.17+) + sops: SOPS encrypted files (0.17+) systemd-credential: Read-only systemd service credentials (0.17+) Saved to ~/.config/secretspec/config.toml @@ -173,8 +181,18 @@ $ secretspec run --profile production -- npm start diff --git a/docs/src/styles/landing.css b/docs/src/styles/landing.css index 6d37a6e8..fb1635a8 100644 --- a/docs/src/styles/landing.css +++ b/docs/src/styles/landing.css @@ -438,6 +438,7 @@ align-items: center; gap: 0.6rem; padding: 0.6rem 1.1rem; + margin: 1px 0px; border: 1px solid var(--color-gray-200); border-radius: 999px; background: var(--sl-color-bg-nav); diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index c364f58d..ea299465 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -54,7 +54,7 @@ detect-coding-agent.workspace = true age = { workspace = true, optional = true } [features] -default = ["cli", "keyring", "kdbx", "gcsm", "awssm", "vault", "openbao", "bws", "akv", "infisical", "age"] +default = ["cli", "keyring", "kdbx", "gcsm", "awssm", "vault", "openbao", "bws", "akv", "infisical", "age", "sops"] cli = ["dep:toml_edit"] keyring = ["dep:keyring", "dep:whoami"] kdbx = ["dep:keepass"] @@ -73,3 +73,4 @@ infisical = ["dep:reqwest", "tokio/sync"] bws = ["dep:bitwarden", "dep:rustls"] akv = ["dep:azure_core", "dep:azure_identity", "dep:azure_security_keyvault_secrets"] age = ["dep:age"] +sops = [] diff --git a/secretspec/README.md b/secretspec/README.md index c68bde6d..7890c6a2 100644 --- a/secretspec/README.md +++ b/secretspec/README.md @@ -76,6 +76,7 @@ $ secretspec config init akv: Azure Key Vault infisical: Infisical secret management (0.16+) age: age-encrypted file (0.17+) + sops: SOPS encrypted files (0.17+) ? Select your default profile: > development default @@ -168,6 +169,7 @@ SecretSpec supports multiple storage backends for secrets: - **[Azure Key Vault](https://secretspec.dev/providers/akv)** - Azure secret management - **[Infisical](https://secretspec.dev/providers/infisical)** (0.16+) - Infisical secret management - **[age](https://secretspec.dev/providers/age)** (0.17+) - age-encrypted file +- **[SOPS](https://secretspec.dev/providers/sops)** (0.17+) - SOPS-encrypted files ```bash $ secretspec run --provider keyring -- npm start diff --git a/secretspec/src/provider/mod.rs b/secretspec/src/provider/mod.rs index 041c8057..e58a8f0d 100644 --- a/secretspec/src/provider/mod.rs +++ b/secretspec/src/provider/mod.rs @@ -32,6 +32,7 @@ //! - [`bws::BwsProvider`]: Bitwarden Secrets Manager integration //! - [`akv::AkvProvider`]: Azure Key Vault integration //! - [`infisical::InfisicalProvider`]: Infisical integration (0.16+) +//! - [`sops::SopsProvider`]: SOPS-encrypted file integration (0.17+) //! //! ## URI-Based Configuration //! @@ -209,6 +210,7 @@ impl ProviderUrl { feature = "infisical", feature = "kdbx", feature = "openbao", + feature = "sops", feature = "vault", test ))] @@ -249,6 +251,12 @@ impl ProviderUrl { } } +impl std::fmt::Display for ProviderUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + /// Executes an async future in a blocking context. /// /// If already inside a tokio runtime, uses `block_in_place` with the @@ -290,6 +298,8 @@ pub mod onepassword; pub mod openbao; pub mod pass; pub mod protonpass; +#[cfg(feature = "sops")] +pub mod sops; pub mod systemd_credential; #[cfg(feature = "vault")] pub mod vault; diff --git a/secretspec/src/provider/sops/config.rs b/secretspec/src/provider/sops/config.rs new file mode 100644 index 00000000..a33cd49f --- /dev/null +++ b/secretspec/src/provider/sops/config.rs @@ -0,0 +1,367 @@ +use crate::{ + Result, SecretSpecError, + provider::{ + ProviderUrl, + sops::{ + SopsFormat, + SopsMode::{self}, + fields::{PATHBUF_FIELDS, STRING_FIELDS}, + pattern::SopsPathPattern, + }, + }, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SopsConfig { + // Set by Provider::with_base_dir + pub base_dir: Option, + + pub format: SopsFormat, + pub mode: SopsMode, + + // 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, + + // Age configuration + pub age_key_file: 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 aws_access_key_id: Option, + pub aws_profile: Option, + pub aws_region: Option, + pub kms_arn: Option, + + // Azure configuration + pub azure_client_id: Option, + pub azure_tenant_id: Option, + pub azure_keyvault_urls: Option, + + // GCP configuration + 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, + + // GPG configuration + pub gpg_exec: Option, + + // HashiCorp Vault/OpenBao configuration + pub hc_vault_addr: Option, + pub hc_vault_allowlist: Option, + + // Huawei Cloud KMS + pub huawei_sdk_project_id: Option, + pub huawei_kms_ids: Option, +} + +impl Default for SopsConfig { + fn default() -> Self { + Self { + age_key_cmd: None, + age_key_file: 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, + azure_client_id: None, + azure_keyvault_urls: None, + azure_tenant_id: None, + base_dir: None, + format: SopsFormat::default(), + gcp_kms_client_type: None, + gcp_kms_endpoint: None, + gcp_kms_ids: None, + gcp_kms_universe_domain: None, + gpg_exec: None, + hc_vault_addr: None, + hc_vault_allowlist: None, + huawei_kms_ids: None, + huawei_sdk_project_id: None, + kms_arn: None, + mode: SopsMode::Uninitialized, + pgp_fp: None, + sops_config: None, + sops_decryption_order: None, + sops_editor: None, + sops_enable_local_keyservice: None, + sops_keyservice: None, + } + } +} + +fn split_template_path(path: &str) -> (PathBuf, String) { + let bytes = path.as_bytes(); + let mut first_placeholder = None; + + for i in 0..bytes.len() { + if bytes[i] == b'{' { + first_placeholder = Some(i); + break; + } + } + + match first_placeholder { + None => (PathBuf::from(path), String::new()), + Some(idx) => { + let (dir, pat) = path.split_at(idx); + (PathBuf::from(dir.trim_end_matches('/')), pat.to_string()) + } + } +} + +fn infer_format(path: &str) -> Result { + let extension = std::path::Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .ok_or_else(|| { + SecretSpecError::ProviderOperationFailed(format!( + "Cannot infer the SOPS format from '{path}'. Add a supported extension \ + (.yaml, .yml, .json, .env, .dotenv, or .ini) or set ?format=." + )) + })?; + + SopsFormat::from_str(extension) +} + +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 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 = if target_path.as_os_str().is_empty() { + url_path.as_str() + } else { + url_path.trim_start_matches('/') + }; + + if !path_part.is_empty() { + target_path.push(path_part); + } + } + + if target_path.as_os_str().is_empty() { + target_path = PathBuf::from("secrets.enc.yaml"); + } + + let raw_path = target_path.to_string_lossy().to_string(); + + let mut config = SopsConfig::default(); + + let mut explicit_format: Option = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "format" => { + explicit_format = Some(SopsFormat::from_str(value.as_ref()).map_err(|e| { + SecretSpecError::ProviderOperationFailed(format!( + "Invalid format parameter: {}", + e + )) + })?); + } + other => { + if let Err(e) = config.apply_query_parameter(other, value.as_ref()) { + return Err(e); + } + } + } + } + + let (dir_path, pattern) = split_template_path(&raw_path); + + let format = match explicit_format { + Some(format) => format, + None => infer_format(&raw_path)?, + }; + + // SOPS does not accept `--input-type ini`. An INI override can only be + // honored when the file itself has an INI extension and SOPS can infer + // the store from that extension. + if explicit_format == Some(SopsFormat::Ini) && infer_format(&raw_path)? != SopsFormat::Ini { + return Err(SecretSpecError::ProviderOperationFailed( + "SOPS cannot override a non-INI filename with ?format=ini; use a .ini filename" + .to_string(), + )); + } + + let mode = if pattern.is_empty() { + SopsMode::SingleFile(PathBuf::from(raw_path)) + } else { + SopsMode::Directory { + path: dir_path, + pattern: SopsPathPattern::try_from(pattern)?, + format, + } + }; + + Ok(SopsConfig { + format, + mode, + ..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, v); + } + } + + for spec in PATHBUF_FIELDS { + if let Some(v) = (spec.field)(self) { + cmd.env(spec.env_key, self.rebase_path(v.to_path_buf())); + } + } + } + + pub fn apply_query_parameter(&mut self, key: &str, value: &str) -> Result<()> { + 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(()); + } + } + + Err(SecretSpecError::ProviderOperationFailed(format!( + "Invalid query parameter: {}", + key + ))) + } + + pub fn with_base_dir(&mut self, base_dir: &std::path::Path) { + self.base_dir = Some(base_dir.to_owned()); + } + + pub fn rebase_path(&self, path: std::path::PathBuf) -> std::path::PathBuf { + if let Some(base) = &self.base_dir { + if path.is_relative() { + return base.join(path); + } + } + + return path; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Provider; + use url::Url; + + #[test] + fn test_sops_config_try_from_succeeds_for_known_query_parameters() { + let mut url_query_parameter_keys: Vec<&str> = + STRING_FIELDS.iter().map(|f| f.url_key).collect(); + + url_query_parameter_keys.extend(PATHBUF_FIELDS.iter().map(|f| f.url_key).into_iter()); + + url_query_parameter_keys.iter().for_each(|key| { + let url: Url = Url::parse(format!("sops://src/provider/sops/test_fixtures/single_file/some-project-name.enc.json?{}=foo", key).as_str()).unwrap(); + + let provider_result: std::result::Result, _> = (&url).try_into(); + + match provider_result { + Err(e) => { + assert!(false, "{}", e.to_string()); + } + Ok(_) => () + } + }); + } + + #[test] + fn test_sops_config_try_from_errors_on_unknown_query_parameter() { + let url = Url::parse("sops://src/provider/sops/test_fixtures/single_file/some-project-name.enc.json?invalid_parameter=foo").unwrap(); + + let provider_result: std::result::Result, _> = (&url).try_into(); + + match provider_result { + Err(e) => { + assert_eq!( + e.to_string(), + "Provider operation failed: Invalid query parameter: invalid_parameter" + ); + } + _ => { + assert!(false) + } + } + } + + #[test] + fn unknown_extension_returns_an_error_instead_of_panicking() { + for spec in [ + "sops://secrets.enc", + "sops://secrets/{project}/{profile}.enc", + ] { + let url = Url::parse(spec).unwrap(); + let result: std::result::Result, _> = (&url).try_into(); + let error = result.err().expect("unknown extension should fail"); + assert!( + error.to_string().contains("Supported formats"), + "unexpected error for {spec}: {error}" + ); + } + } + + #[test] + fn explicit_dotenv_format_allows_an_enc_extension() { + let url = Url::parse("sops://secrets/{project}/.env.{profile}.enc?format=dotenv").unwrap(); + let provider: std::result::Result, _> = (&url).try_into(); + assert!(provider.is_ok()); + } + + #[test] + fn explicit_ini_format_requires_an_ini_extension() { + let url = Url::parse("sops://secrets.enc?format=ini").unwrap(); + let result: std::result::Result, _> = (&url).try_into(); + assert!(result.is_err()); + } +} diff --git a/secretspec/src/provider/sops/fields.rs b/secretspec/src/provider/sops/fields.rs new file mode 100644 index 00000000..1ca267dd --- /dev/null +++ b/secretspec/src/provider/sops/fields.rs @@ -0,0 +1,227 @@ +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 struct CredentialSpec { + pub name: &'static str, + pub env_key: &'static str, +} + +pub const AGE_KEY: &str = "age_key"; +pub const AWS_SECRET_ACCESS_KEY: &str = "aws_secret_access_key"; +pub const AZURE_CLIENT_SECRET: &str = "azure_client_secret"; +pub const HC_VAULT_TOKEN: &str = "hc_vault_token"; +pub const HUAWEI_SDK_AK: &str = "huawei_sdk_ak"; +pub const HUAWEI_SDK_SK: &str = "huawei_sdk_sk"; +pub const GOOGLE_OAUTH_ACCESS_TOKEN: &str = "google_oauth_access_token"; + +/// Secret values accepted through a provider alias's `credentials` map. +/// +/// The SOPS process inherits the matching environment variables when no +/// provider credential is supplied. Injected credentials override that +/// ambient fallback for this child process only. +pub static CREDENTIAL_FIELDS: &[CredentialSpec] = &[ + CredentialSpec { + name: AGE_KEY, + env_key: "SOPS_AGE_KEY", + }, + CredentialSpec { + name: AWS_SECRET_ACCESS_KEY, + env_key: "AWS_SECRET_ACCESS_KEY", + }, + CredentialSpec { + name: AZURE_CLIENT_SECRET, + env_key: "AZURE_CLIENT_SECRET", + }, + CredentialSpec { + name: HC_VAULT_TOKEN, + env_key: "VAULT_TOKEN", + }, + CredentialSpec { + name: HUAWEI_SDK_AK, + env_key: "HUAWEICLOUD_SDK_AK", + }, + CredentialSpec { + name: HUAWEI_SDK_SK, + env_key: "HUAWEICLOUD_SDK_SK", + }, + CredentialSpec { + name: GOOGLE_OAUTH_ACCESS_TOKEN, + env_key: "GOOGLE_OAUTH_ACCESS_TOKEN", + }, +]; + +/// Non-secret SOPS settings which may be carried by the provider URI and are +/// translated to the environment variables understood by the SOPS CLI. +pub static STRING_FIELDS: &[FieldSpec] = &[ + 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_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_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_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_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_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_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", + }, +]; + +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", + }, + FieldSpec { + field: |c| &c.sops_config, + field_mut: |c| &mut c.sops_config, + url_key: "sops_config", + env_key: "SOPS_CONFIG", + }, +]; diff --git a/secretspec/src/provider/sops/format.rs b/secretspec/src/provider/sops/format.rs new file mode 100644 index 00000000..67382a61 --- /dev/null +++ b/secretspec/src/provider/sops/format.rs @@ -0,0 +1,72 @@ +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Serialize}; + +use crate::SecretSpecError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum SopsFormat { + #[default] + Yaml, + Json, + Env, + Ini, +} + +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"), + } + } +} + +impl SopsFormat { + /// The spelling accepted by SOPS' `--input-type` flag. SOPS can infer INI + /// only from a `.ini` filename and does not expose an `ini` input type. + pub fn sops_input_type(self) -> Option<&'static str> { + match self { + Self::Yaml => Some("yaml"), + Self::Json => Some("json"), + Self::Env => Some("dotenv"), + Self::Ini => None, + } + } +} + +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), + _ => Err(SecretSpecError::ProviderOperationFailed(format!( + "Unsupported SOPS format: {}. Supported formats: yaml, json, env, ini", + s + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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!(SopsFormat::from_str("unknown").is_err()); + } +} diff --git a/secretspec/src/provider/sops/mod.rs b/secretspec/src/provider/sops/mod.rs new file mode 100644 index 00000000..df391824 --- /dev/null +++ b/secretspec/src/provider/sops/mod.rs @@ -0,0 +1,444 @@ +use super::{Address, Provider, ProviderCredentials, ProviderUrl}; +use crate::config::NativeAddress; +use crate::provider::sops::config::SopsConfig; +use crate::provider::sops::fields::{ + AGE_KEY, AWS_SECRET_ACCESS_KEY, AZURE_CLIENT_SECRET, CREDENTIAL_FIELDS, + GOOGLE_OAUTH_ACCESS_TOKEN, HC_VAULT_TOKEN, HUAWEI_SDK_AK, HUAWEI_SDK_SK, PATHBUF_FIELDS, + STRING_FIELDS, +}; +use crate::provider::sops::format::SopsFormat; +use crate::provider::sops::pattern::SopsPathPattern; +use crate::{Result, SecretSpecError}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +mod config; +mod fields; +mod format; +mod pattern; + +#[cfg(all(test, feature = "sops"))] +mod tests; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SopsMode { + SingleFile(PathBuf), + Directory { + path: PathBuf, + pattern: SopsPathPattern, + format: SopsFormat, + }, + Uninitialized, +} + +pub struct SopsProvider { + config: SopsConfig, + credentials: ProviderCredentials, +} + +crate::register_provider! { + struct: SopsProvider, + config: SopsConfig, + name: "sops", + description: "SOPS encrypted files (0.17+)", + schemes: ["sops"], + examples: [ + "sops://secrets.enc.yaml", + "sops://secrets-dir/{project}/{profile}.enc.json", + "sops://secrets-dir/{project}/.env.{profile}.enc?format=dotenv", + ], + credential_names: [ + AGE_KEY, + AWS_SECRET_ACCESS_KEY, + AZURE_CLIENT_SECRET, + HC_VAULT_TOKEN, + HUAWEI_SDK_AK, + HUAWEI_SDK_SK, + GOOGLE_OAUTH_ACCESS_TOKEN, + ], +} + +struct AddressParts<'a> { + project: &'a str, + profile: &'a str, + key: &'a str, +} + +impl SopsProvider { + pub fn new(config: SopsConfig) -> Self { + Self { + config, + credentials: ProviderCredentials::new(), + } + } + + fn provider_error(message: impl Into) -> SecretSpecError { + SecretSpecError::ProviderOperationFailed(message.into()) + } + + fn address_parts<'a>(&self, addr: Address<'a>) -> Result> { + match addr { + Address::Convention { + project, + profile, + key, + } => Ok(AddressParts { + project, + profile, + key, + }), + Address::Native(native) => { + // Apply the shared unsupported-coordinate validation before + // interpreting `item` as a root key in a single SOPS file. + self.resolve_coords(addr)?; + if matches!(self.config.mode, SopsMode::Directory { .. }) { + return Err(Self::provider_error( + "SOPS refs require a single-file provider URI; a templated directory \ + URI needs project and profile values to select the file", + )); + } + Ok(AddressParts { + project: "", + profile: "", + key: &native.item, + }) + } + } + } + + fn resolve_file_path(&self, project: &str, profile: &str) -> Result> { + let path = match &self.config.mode { + SopsMode::SingleFile(path) => self.config.rebase_path(path.clone()), + SopsMode::Directory { path, pattern, .. } => self + .config + .rebase_path(path.join(pattern.render(project, profile))), + SopsMode::Uninitialized => { + return Err(Self::provider_error( + "SOPS provider mode must be initialized to a file or directory pattern", + )); + } + }; + + Ok((path.is_file()).then_some(path)) + } + + fn new_file_path(&self, project: &str, profile: &str) -> Result { + match &self.config.mode { + SopsMode::SingleFile(path) => Ok(self.config.rebase_path(path.clone())), + SopsMode::Directory { path, pattern, .. } => Ok(self + .config + .rebase_path(path.join(pattern.render(project, profile)))), + SopsMode::Uninitialized => Err(Self::provider_error( + "SOPS provider mode must be initialized to a file or directory pattern", + )), + } + } + + fn execute_sops_command(&self, args: I) -> Result> + where + I: IntoIterator, + S: AsRef, + { + let mut command = Command::new("sops"); + command.args(args); + self.config.apply_env(&mut command); + + // Provider credentials override inherited environment variables for + // this SOPS child process without exposing the values in the provider + // URI, audit log, or launched application environment. + for spec in CREDENTIAL_FIELDS { + if let Some(value) = self.credentials.get(spec.name) { + command.env(spec.env_key, value.expose_secret()); + } + } + + let output = match command.output() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(Self::provider_error( + "The 'sops' CLI is not installed. Install it from \ + https://github.com/getsops/sops or via your package manager.", + )); + } + Err(error) => return Err(error.into()), + }; + + if !output.status.success() { + return Err(Self::provider_error( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + + Ok(output.stdout) + } + + fn input_type(&self) -> Option<&'static str> { + self.config.format.sops_input_type() + } + + fn command_with_input_type(&self, command: &str) -> Vec { + let mut args = vec![command.to_string()]; + if let Some(input_type) = self.input_type() { + args.push("--input-type".to_string()); + args.push(input_type.to_string()); + } + args + } + + fn command_preserving_file_type(&self, command: &str) -> Vec { + let mut args = self.command_with_input_type(command); + if let Some(output_type) = self.input_type() { + args.push("--output-type".to_string()); + args.push(output_type.to_string()); + } + args + } + + fn decrypt(&self, path: &Path) -> Result> { + let mut args = self.command_with_input_type("decrypt"); + args.extend([ + "--output-type".to_string(), + "json".to_string(), + path.to_string_lossy().into_owned(), + ]); + self.execute_sops_command(args) + } + + fn lookup_paths(&self, parts: &AddressParts<'_>) -> Result>> { + match &self.config.mode { + SopsMode::SingleFile(_) => { + let mut paths = Vec::new(); + if !parts.project.is_empty() { + if !parts.profile.is_empty() && parts.profile != "default" { + paths.push(vec![ + parts.project.to_string(), + parts.profile.to_string(), + parts.key.to_string(), + ]); + } + paths.push(vec![parts.profile.to_string(), parts.key.to_string()]); + } + paths.push(vec![parts.key.to_string()]); + Ok(paths) + } + SopsMode::Directory { .. } => Ok(vec![vec![parts.key.to_string()]]), + SopsMode::Uninitialized => Err(Self::provider_error( + "SOPS provider mode must be initialized to a file or directory pattern", + )), + } + } + + fn parse_decrypted_json( + &self, + content: &[u8], + parts: &AddressParts<'_>, + ) -> Result> { + let data: serde_json::Value = serde_json::from_slice(content).map_err(|error| { + Self::provider_error(format!( + "Failed to parse JSON emitted by the SOPS CLI: {error}" + )) + })?; + + for path in self.lookup_paths(parts)? { + let mut current = &data; + let mut found = true; + for segment in path { + match current { + serde_json::Value::Object(map) => match map.get(&segment) { + Some(value) => current = value, + None => { + found = false; + break; + } + }, + _ => { + found = false; + break; + } + } + } + if found { + return Ok(Some(match current { + serde_json::Value::String(value) => value.clone(), + other => other.to_string(), + })); + } + } + + Ok(None) + } + + fn create_new_sops_file(&self, project: &str, profile: &str) -> Result { + let path = self.new_file_path(project, profile)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + Self::provider_error(format!( + "Failed to create SOPS directory {}: {error}", + parent.display() + )) + })?; + } + + let initial = match self.config.format { + SopsFormat::Json => "{}\n", + SopsFormat::Yaml => "{}\n", + SopsFormat::Env | SopsFormat::Ini => "", + }; + std::fs::write(&path, initial).map_err(|error| { + Self::provider_error(format!( + "Failed to create SOPS file {}: {error}", + path.display() + )) + })?; + + let mut args = self.command_preserving_file_type("encrypt"); + args.extend([ + "--in-place".to_string(), + path.to_string_lossy().into_owned(), + ]); + self.execute_sops_command(args)?; + Ok(path) + } + + fn set_path(&self, parts: &AddressParts<'_>) -> Result { + let mut paths = self.lookup_paths(parts)?; + + match self.config.format { + // Dotenv is always a flat map, even when one encrypted file stores + // several SecretSpec profiles. + SopsFormat::Env => paths = vec![vec![parts.key.to_string()]], + SopsFormat::Ini => { + // INI can represent one section level but not the fully nested + // project/profile/key hierarchy. + paths = if matches!(self.config.mode, SopsMode::Directory { .. }) { + vec![vec!["DEFAULT".to_string(), parts.key.to_string()]] + } else if parts.profile.is_empty() { + vec![vec![parts.key.to_string()]] + } else { + vec![vec![parts.profile.to_string(), parts.key.to_string()]] + }; + } + SopsFormat::Json | SopsFormat::Yaml => {} + } + + let chosen = paths + .first() + .ok_or_else(|| Self::provider_error("No SOPS lookup path is available"))?; + Ok(chosen + .iter() + .map(|segment| { + serde_json::to_string(segment) + .map(|segment| format!("[{segment}]")) + .map_err(|error| { + Self::provider_error(format!("Failed to encode SOPS path: {error}")) + }) + }) + .collect::>>()? + .join("")) + } +} + +impl Provider for SopsProvider { + fn convention_address( + &self, + _project: &str, + _profile: &str, + key: &str, + ) -> Result { + Ok(NativeAddress { + item: key.to_string(), + ..Default::default() + }) + } + + fn get(&self, addr: Address<'_>) -> Result> { + let parts = self.address_parts(addr)?; + let Some(path) = self.resolve_file_path(parts.project, parts.profile)? else { + return Ok(None); + }; + let decrypted = self.decrypt(&path)?; + Ok(self + .parse_decrypted_json(&decrypted, &parts)? + .map(|value| SecretString::new(value.into()))) + } + + fn check_writable(&self, addr: Address<'_>) -> Result<()> { + self.address_parts(addr).map(|_| ()) + } + + fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> { + self.check_writable(addr)?; + let parts = self.address_parts(addr)?; + let path = match self.resolve_file_path(parts.project, parts.profile)? { + Some(path) => path, + None => self.create_new_sops_file(parts.project, parts.profile)?, + }; + let file = path.to_string_lossy().into_owned(); + + // Preserve the original provider behavior of accepting an existing + // plaintext file, but do not silently proceed until it has been + // encrypted successfully. + if self.decrypt(&path).is_err() { + let mut encrypt = self.command_preserving_file_type("encrypt"); + encrypt.extend(["--in-place".to_string(), file.clone()]); + self.execute_sops_command(encrypt)?; + } + + let mut args = self.command_preserving_file_type("set"); + args.extend([ + file, + self.set_path(&parts)?, + serde_json::to_string(value.expose_secret()).map_err(|error| { + Self::provider_error(format!("Failed to encode the secret value: {error}")) + })?, + ]); + self.execute_sops_command(args)?; + Ok(()) + } + + fn name(&self) -> &'static str { + Self::PROVIDER_NAME + } + + fn uri(&self) -> String { + let mut params = BTreeMap::<&str, String>::new(); + params.insert("format", self.config.format.to_string()); + + for spec in PATHBUF_FIELDS { + if let Some(value) = (spec.field)(&self.config) { + params.insert(spec.url_key, value.to_string_lossy().into_owned()); + } + } + for spec in STRING_FIELDS { + if let Some(value) = (spec.field)(&self.config) { + params.insert(spec.url_key, value.clone()); + } + } + + let path = match &self.config.mode { + SopsMode::SingleFile(path) => path.to_string_lossy().into_owned(), + SopsMode::Directory { path, pattern, .. } => { + format!("{}/{}", path.to_string_lossy(), pattern.debug_template()) + } + SopsMode::Uninitialized => String::new(), + }; + let query = params + .into_iter() + .map(|(key, value)| format!("{key}={}", ProviderUrl::encode_query(&value))) + .collect::>() + .join("&"); + format!("sops://{path}?{query}") + } + + fn with_base_dir(&mut self, base_dir: &Path) { + self.config.with_base_dir(base_dir); + } + + fn with_credentials(&mut self, credentials: ProviderCredentials) { + self.credentials = credentials; + } +} diff --git a/secretspec/src/provider/sops/pattern.rs b/secretspec/src/provider/sops/pattern.rs new file mode 100644 index 00000000..ec14f2ce --- /dev/null +++ b/secretspec/src/provider/sops/pattern.rs @@ -0,0 +1,118 @@ +use std::{collections::HashSet, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{Result, SecretSpecError}; + +fn extract_placeholders(s: &str) -> Result> { + let mut placeholders = Vec::new(); + let mut remainder = s; + + while let Some(start) = remainder.find('{') { + let after_start = &remainder[start + 1..]; + let end = after_start.find('}').ok_or_else(|| { + SecretSpecError::ProviderOperationFailed(format!( + "Unclosed placeholder in SOPS path '{s}'" + )) + })?; + placeholders.push(after_start[..end].to_string()); + remainder = &after_start[end + 1..]; + } + + if remainder.contains('}') { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "Unexpected closing brace in SOPS path '{s}'" + ))); + } + + Ok(placeholders) +} + +fn validate_template(template: &str) -> std::result::Result<(), SecretSpecError> { + let placeholders = extract_placeholders(template)?; + + if placeholders.len() > 0 { + let mut expected_placeholders: HashSet<&str> = HashSet::new(); + + expected_placeholders.insert("profile"); + + expected_placeholders.insert("project"); + + for placeholder in &placeholders { + match placeholder.as_str() { + "profile" | "project" => { + expected_placeholders.take(placeholder.as_str()); + } + other => { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "Unknown placeholder '{{{}}}' in SOPS path", + other + ))); + } + } + } + + if 0 != expected_placeholders.len() { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "SOPS provider URL missing templating placeholders: {}", + expected_placeholders + .drain() + .map(|p| format!("{{{}}}", p)) + .collect::>() + .join(", ") + ))); + } + } + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SopsPathPattern { + template: String, +} + +impl TryFrom for SopsPathPattern { + type Error = SecretSpecError; + + fn try_from(template: String) -> Result { + let pattern = Self { template }; + + pattern.validate()?; + + Ok(pattern) + } +} + +impl TryFrom<&str> for SopsPathPattern { + type Error = SecretSpecError; + + fn try_from(template: &str) -> Result { + let pattern = Self { + template: template.to_string(), + }; + + pattern.validate()?; + + Ok(pattern) + } +} + +impl SopsPathPattern { + pub fn validate(&self) -> Result<()> { + validate_template(&self.template) + } + + pub fn render(&self, project: &str, profile: &str) -> PathBuf { + let rendered = self + .template + .replace("{project}", project) + .replace("{profile}", profile); + + PathBuf::from(rendered) + } + + pub fn debug_template(&self) -> String { + self.template.clone() + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.development b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.development new file mode 100644 index 00000000..97941c4d --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.development @@ -0,0 +1,2 @@ +DB_PASSWORD=hunter2 +foobar=bar diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.development.enc b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.development.enc new file mode 100644 index 00000000..9c608092 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.development.enc @@ -0,0 +1,8 @@ +DB_PASSWORD=ENC[AES256_GCM,data:4/McGpgPvQ==,iv:/YHGXPV5McOOLAPrJb1mvpFasLqxojOQiesnSfYRl1g=,tag:YrpSz1ZjyhOoCQ2VtBPcdg==,type:str] +foobar=ENC[AES256_GCM,data:+g8k,iv:B5acKhd/09NvqEHIFDKOYfwH3U52Db+8GdBzhaxkcQU=,tag:n6VAUo+NpdhM4ocfE3lqvQ==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxdTJHNkJCQWFEdkZoZ1BO\nV2hXMjVXbC9oT0RFTHRXcFg3QkpQdzZSTVcwClA3V3p5R045WHRhdDBBODZ0dlY2\nbTBub1VOdGZ6MmdlRFRwSVVNc2dFNE0KLS0tIC9KK2FBRkZvV1BZaUcyYklUZ01w\nYVl0cC9hQUZ3ekF2akZGbnhRV2xvVEUKGbtu9xhyr5ZvZzfca5BBdMsASQP94Byg\neL+QytLVnaBgUTfpYQXn/7ceP+D7Jaox3wi2MZAjeYcTofsujetIzw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34 +sops_lastmodified=2026-07-23T17:54:51Z +sops_mac=ENC[AES256_GCM,data:75Tnw6ApRlthd873Q7vDF3ui6iz9mjhqEpC2PQxnZFVfN3swU1hmt7PaspO7AZmvZJS2xqa+E4Shl0rXZdNnL4qQYgzoqyLidrRZau5PQ3iva43NyPwm5qmRoSvrbTS88WYZRrZRzcJdFzz21rFYPfh+VRTUdeaKyLHecmhiyl0=,iv:3+MugzjT2MTgSCccrDISqsCO5mtOevGewqh++WvN43Y=,tag:MchBVrdRuSNx7mN6Bcjkrg==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.12.1 diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.production b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.production new file mode 100644 index 00000000..1ba40f2d --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.production @@ -0,0 +1,2 @@ +DB_PASSWORD=hunter3 +foobar=baz diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.production.enc b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.production.enc new file mode 100644 index 00000000..e06087d0 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/.env.production.enc @@ -0,0 +1,8 @@ +DB_PASSWORD=ENC[AES256_GCM,data:sq+/HIhPoA==,iv:krkAz+EERuI9uYliqAfq5D9iLBW1rsMBbq9ktgAy/dc=,tag:NxuNk2ODi5IqDlR4mMtgMg==,type:str] +foobar=ENC[AES256_GCM,data:L1xA,iv:J/X/6ulYyzYIVshZ4XoE1cC060v8iCqCs7u8skAWnNk=,tag:vMB+tw2XB8T0k+B2rKBdiA==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKeGhHakZqYTBlb1RIbWI5\nckVlaDVvL3NCUkt6aUg0QkkxcXEzbzV1eEcwCnc0OU5pTFBIWTlYQmczQWlrRWdG\nUFloeEg3SFBCVFZmV0pLcERTejFmZXMKLS0tIFNnYXV5SVpHQ293amg1OHNmTm1M\nZVhmSUU3UVhFUmtoM3RDY2FsK25uOXMKLU+zQr+oRVJlaiQ6+2zpkxXGx7Qtaa4v\nXOgDU6sBSUlaTGkrV8A6rpdKXvjj4F4DVNOwKzSOP/bgksxuiIPGIw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34 +sops_lastmodified=2026-07-23T17:54:51Z +sops_mac=ENC[AES256_GCM,data:rYV9uZk0gjBbArosRcY3Vqe3t77qrewHFABpFutBPazzp/0i7EvUdBMsPa+PL15pT3WbD2lMUxF+e0fzxpqrEIfUD9ScAKSA8R5EUMotbtTeqgkB1CG4xTj2EAV9AhQwbSKMcOXctI0+g4nxgqeli4KgIHmOOT7Yiszeqn4TFDE=,iv:kMwKCjWcIWoXkpnNpj7CrCFLQP4w6mAelXvKY5Ruo+w=,tag:R0MS5otIfTmZ4MRfCGkePw==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.12.1 diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development.enc.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development.enc.json new file mode 100644 index 00000000..d267dd29 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development.enc.json @@ -0,0 +1,15 @@ +{ + "foobar": "ENC[AES256_GCM,data:W28m,iv:70H9v8FGGo8S/L4lafu67P+kq6Wg2TQJuHfhNlAHhCQ=,tag:RprMy3IBMyurFZ4e02WEuA==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNMjkrVnhkbjJTRDJibjhs\ncUdrT1BxUzhLOFVuLzlIc2lZTGpkNUFZc3dNClhGaUhyR2VtVHgvZm1sa1NpRmEr\nMDBqaG5uTzA0c29xN1BmaHVZUCtOdzgKLS0tIE83RW9FSW5ESlhJcHRnbGhaTHdw\namFaemM1SUpoaHppOTZOdDJqQjBkbW8KGemmJyxH4iMuTCKpli2lJbVPsTrra9Ms\nBSPbANwhVBLJYgxtYQBXGFtRmqT+wUuqTV2y0BApbCO6GgQmt9d+Fg==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-06-12T02:43:15Z", + "mac": "ENC[AES256_GCM,data:IeK18Eut4vzhZn8/72v/4ipQJ/lPuTslk83HdeABr2c91ArfqRiilfz4cmfSbL9dboNsIomweWTnL/IdUfMTrg3pX2Agdz2kHnE1RV+S9rHc3CYbu0FOxNq5dhr3DmHWnZeKYDT9Cog6iumiuuUrTo9J/rlcYcaFIyBa8bL+zfE=,iv:UD71uBp4HqpzUyxX7V0QpJ8Z2lzTB9wEQr/KaHHvlhM=,tag:D4KuBOIkXeQwbct7zCQliw==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.12.1" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development.json new file mode 100644 index 00000000..19484345 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development.json @@ -0,0 +1,3 @@ +{ + "foobar": "bar" +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development/secrets.enc.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development/secrets.enc.json new file mode 100644 index 00000000..f9bc7312 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development/secrets.enc.json @@ -0,0 +1,15 @@ +{ + "foobar": "ENC[AES256_GCM,data:mFOR,iv:EkkxnNkEadmYF/4qc0D+1W2ZDLx3PtUCSuLRjrX/4gw=,tag:F0J74Q0Up+80d9OmWn3aSw==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCQWZNRnZ6M2Y3dE5MUUR6\nbVppS1pzd1hMUEFiQTAwL0d0ZmZZUGU5VENJCjFHMkRrSStweHlMTHFjRDBBaDNk\nUFI0aHRaY1pSeEdwOG44UVpyNy8xN1UKLS0tIGpaWndXaUdtdDNoMGU1bElFK1dF\nN1BjbTgvVk9VVXJud3Fjdm14bXhrUE0Ky3HeH9eEkbjI+6EDGlIGU5rHeZdgGwE4\nZA4s/eMN7sp81u03WeL8a2ftzJZPBQlNzDTwg40oBMj8+2eKqBlPSQ==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-06-10T22:17:42Z", + "mac": "ENC[AES256_GCM,data:nHicPXcfd9eeMF+kXN2CDKQ7Siyo55BefFKHnGNZoy1ZaYTYdfWzgf05p/X3NR013YhGw0yP/9U+xzLBrdP4iHDB6YFA3VZJjGkcVxiRWaBhDv0FcUawFF9VLJDxHewZ//GwH1jqf81n1WFz9uPmPv0w4ha0PYMnmwOFmxslv6Q=,iv:MRb28vjym6JptjFMtUQv4ZWwMTLzPzK6nBkaE+rSoQo=,tag:PirwBEuix6UMPYunQr/rkQ==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.12.1" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development/secrets.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development/secrets.json new file mode 100644 index 00000000..19484345 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/development/secrets.json @@ -0,0 +1,3 @@ +{ + "foobar": "bar" +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production.enc.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production.enc.json new file mode 100644 index 00000000..e4632f58 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production.enc.json @@ -0,0 +1,15 @@ +{ + "foobar": "ENC[AES256_GCM,data:vDcK,iv:dTorfSfF5dC/Kr1+Ua+bJk3k12hcEnKOENhmOPgzGe8=,tag:yUlwNC6lggUnzx3u6p3oeQ==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwcmU4SUQwOEJPbzFOQ3pP\nUkR1UHRqUWZQbEI3c1Y1OHdFN2JJb3JQMFNVCmFBTWxYR1JyTlJoa3RJYjRGRWxh\nT3VZWmNIUmNDVFIrY1Y4c3I4RjBpVFUKLS0tIEFZRi9qUGZmWFdOM3NsdkY4WE1W\nU2dDY1IrSytNRjBZdFBVWHNGUmlYbVkKRjTpRWIMNrOxbtT+2TNt5z6oBgvSlLBV\nSjtFngq0ysTRn7D6aP9kGqwIevGlKnt1Cyezzp5VgL+a7fgMV5XPWQ==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-06-12T02:43:12Z", + "mac": "ENC[AES256_GCM,data:+lm0za+e9yxe1XzGq2+ZTY8EM0WPW2JUBO85KRkxKrTqI+5eyR955XAyoleYZ1HEHlQiAnNaXbcPV7fbuIqODZy5LeUchYLrjb60jLumLD1mzmC9MaykzYTijA3aQfT9et1FDIrRlA/QZau8Wk4XnAr9xfVId9howRu3EOveU60=,iv:+9mcGL9D/tCtLULwxRF54RV2k/0D4rQbRh8Y7/HI9Ng=,tag:q1gWZmmSahUPOtzFf9GKJg==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.12.1" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production.json new file mode 100644 index 00000000..cbd009c1 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production.json @@ -0,0 +1,3 @@ +{ + "foobar": "baz" +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production/secrets.enc.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production/secrets.enc.json new file mode 100644 index 00000000..24f25892 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production/secrets.enc.json @@ -0,0 +1,15 @@ +{ + "foobar": "ENC[AES256_GCM,data:fkjx,iv:5V770iDFQd2izbLG3IVg0/UZMFsYpHrZ+Zuo2FWDtjo=,tag:jDZvCF6dkAW7lV6Da94Jpw==,type:str]", + "sops": { + "age": [ + { + "recipient": "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBEdWpvOGUybGlOU2RDU1J2\nVXo4Ky9PUnNiYzJRSFVYZUtmWXMybkZDdFJJCk1aWkp0L0pXT0NldXVPUEw5c25U\nOVN0UTVFRWtWWlJEdUFreE90RTVTa1EKLS0tIHZBdEtMbU54dUlrSlhRQlNMam1M\nRitheHY5enA0NHJtOTRpa29laUlwQ2sK2aFLYoXNv3GVingGfYbxLXAiWmBkZocj\nv4L9/dEgQ1H61lNR5krVqwVN9GQ2JHIuh3xhq7EQfyJqixbw0T91gQ==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-06-10T22:18:10Z", + "mac": "ENC[AES256_GCM,data:SSHXNHE+ci364C12rcu5QiS/w01lwPmcgc9I7eO7k3YsppZKodgxV/Y3yLKzcuLeP/KOBlxp1ufGaoYpaMAybqPMe3m+EMlJTCC88ygjiW5COf8dntGVUVXhR6P0Tl9c1Hq3vxUTCuKjDnO1naSl48E/e6qotwbSytpxP68sXzQ=,iv:GD9Cp+xX9A8msEjtgTKLknHJ5oVoWbcEIkQm12r9GXA=,tag:zZ0lBOiDSjamHwufh8n64Q==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.12.1" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production/secrets.json b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production/secrets.json new file mode 100644 index 00000000..cbd009c1 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/directory/some-project-name/production/secrets.json @@ -0,0 +1,3 @@ +{ + "foobar": "baz" +} 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 00000000..8781d8cc --- /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/single_file/some-project-name.enc.ini b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.ini new file mode 100644 index 00000000..44ab982d --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.ini @@ -0,0 +1,13 @@ +[development] +foobar = ENC[AES256_GCM,data:Q5TM,iv:zBty0+zxfHxa3xbPPxwTutd6arGPpr5vwLAHFbJQy+0=,tag:xKlptHBWCFwpKooY6g7c3A==,type:str] + +[production] +foobar = ENC[AES256_GCM,data:kJR5,iv:8HREB9FQu+/H2NZFvM7CwWD1Z+3MJvhTQlOw7uxtScY=,tag:blxjjlbHOOicPLsjZhoIeg==,type:str] + +[sops] +age__list_0__map_recipient = age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34 +age__list_0__map_enc = -----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOdVFYYjZPL0VBYzl6OWxC\nSTAxM2o3VzRuTW9haTJHMjErRUp5WWJLdFRBClRqRVhyUUpJQXRPbDN5MU0zcVVa\nc3NMTVZIc2k3SkZ5M0JVdTBSMCtnKzgKLS0tIDhoblVPUTBGdCtMMm1QQVNwcVMw\nMEpNVXlpQThHUFc1VjFGMTUxaVN1cE0KAp+W0WKhtvo/rvocn/JN4aCqyaS7xs64\nEcLR2mm6V9PNM7zLsTvu5bQ496uyZw3KbzwE6d55H3iazrZsCgAIQg==\n-----END AGE ENCRYPTED FILE-----\n +lastmodified = 2026-06-10T21:22:08Z +mac = ENC[AES256_GCM,data:ns42A4BX/atMSJXQOCN5ZfKUJuPVjPO6vwapYB74xHnENQzPrpLZOynxJys/rgh/NdfU/h7wQLTPo1QvOy1oMz+T+LKFUkHrFpkY8Atl5lDITj1r1TEzUkAwICwTXl4myny4dvWLAEGRtvyoFWK8+wAMCTug2y9fC3A44bVtyP4=,iv:enEYbAoWWo7/qd/nCVleRsosda4NzOUVx9MwZYJv0DI=,tag:J04pD1qp5CPNWEn6yumkuw==,type:str] +unencrypted_suffix = _unencrypted +version = 3.12.1 diff --git a/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.json b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.json new file mode 100644 index 00000000..ad237d1b --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.json @@ -0,0 +1,20 @@ +{ + "development": { + "foobar": "ENC[AES256_GCM,data:C1Q2,iv:UQo9LrCXcZsDlEg9++LEDEIb+4CwXKpty8ZlmIjGBsc=,tag:ghlXJUd/qjabVvBsbfwq4w==,type:str]" + }, + "production": { + "foobar": "ENC[AES256_GCM,data:G3Pl,iv:AtvLlvfRgexb5q6YpoyMhAUjkOiI3BY8OgVPxMvA+vA=,tag:feiy2PNYCwr5OUHNzRriNg==,type:str]" + }, + "sops": { + "age": [ + { + "recipient": "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhWGtXWjlaOFVBbmt6cW92\nVlNOSHVBVHNxaW1QWWlKZ3JUVWI4Y3VucENBCkY3SnJnUGNvT2FzMlJxMWlWbldT\ncXMvUlV1Tk5oeUFkbjc3ZGloK2VqcmsKLS0tIG52Sk9BSTh1MmNZNUJvaU01UEw0\nTy9UMWJMNURpTWY0aHMxRGFNTzBJcWMKISrvJLMnVoPgRY45Z2fnghnCTmCEmPXj\n/5NK8IWmxypf40XfTLQNigYPghVbzl8mJZ0vW90QUauqrseDBmuf2A==\n-----END AGE ENCRYPTED FILE-----\n" + } + ], + "lastmodified": "2026-06-10T21:22:08Z", + "mac": "ENC[AES256_GCM,data:W4mEAr+moawm+q5f/g+XJ50+jMxfmlH1zyhc1EHiO/vOEhysIoO8q8bo21uOD5WZRThIRnpmUGRlnfRTx/zWSEeMjP6U/6E2t5tUdZHWLId1GhDPNI44TKHD27QDOO6kZiN8kcpGr+vLl6EGp6q8auxFt6o+6Dfuf1Th551u+wI=,iv:zpVP6B1eVemYG9PvBqZmRD0z05qPvcEAlxKijnbUs50=,tag:U2LW+vMJTy+MZsAs/AIhjg==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.12.1" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.yaml b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.yaml new file mode 100644 index 00000000..d6938e02 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.enc.yaml @@ -0,0 +1,20 @@ +some-project-name: + development: + foobar: ENC[AES256_GCM,data:AOJg,iv:3OyAq3nKrBYyWrjN4Ix+LWQ0V1eMZYAC3HViLR/k0eU=,tag:dKk8epPsdJPkqmrz5inN0Q==,type:str] + production: + foobar: ENC[AES256_GCM,data:YtF7,iv:H3YL5P1aWDmK/hk1y6VxWDb42G9tObV3GkX9cRFLkR8=,tag:jex4dc7nSGSiXfClAVENqg==,type:str] +sops: + age: + - recipient: age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMME1QZzJrcUs1cnVpUk9E + SXpqZCtJRVdudk9HMmlzUEIzNWJ5bC9tbHhvCmpuTll6L1B3Y0cyLytGSWt5Y3JV + azhPMzZMZkFndUYwWlVzclRNRE41Q0kKLS0tIFNIYzB4QXJ1dlVtbjQwUVo3S2NF + YzN1TVB3d3F2dDVqVUNJMG0zMndlZkUKCc4dSqvIk7d/MkigeOaP7HHYXd38w3yo + OPG33e4JfWOOw5EcgHNNhkuBiPvJaMz5Xm3HjjzhboK+7dLK+bIwQA== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-06-10T21:22:08Z" + mac: ENC[AES256_GCM,data:RxAXaU3pfEGK7nAY9IGKia/lcXdDv05DvkpYvIfBBYM0lO8oWPR5zJwAaxX1C/vTMgvANdAfb0u+rJbVENgE4U7r/elTJD4nchIzsKCC1vHlCNCZRLxYw4dVdjCRr7tL98W2nEA+8edTG4PbC8q+bARYHHkJc+7o7yXfa0ZLC1A=,iv:Dm2TW4nMaISSi9OG/L6qjX+v0B+N7T6oS7CsNUnF/is=,tag:lCn9yhUtD04igmGxuUoMgQ==,type:str] + unencrypted_suffix: _unencrypted + version: 3.12.1 diff --git a/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.ini b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.ini new file mode 100644 index 00000000..8c02e61c --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.ini @@ -0,0 +1,5 @@ +[development] +foobar=bar + +[production] +foobar=baz diff --git a/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.json b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.json new file mode 100644 index 00000000..51db1fc5 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.json @@ -0,0 +1,8 @@ +{ + "development": { + "foobar": "bar" + }, + "production": { + "foobar": "baz" + } +} diff --git a/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.yaml b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.yaml new file mode 100644 index 00000000..62ded954 --- /dev/null +++ b/secretspec/src/provider/sops/test_fixtures/single_file/some-project-name.yaml @@ -0,0 +1,5 @@ +some-project-name: + development: + foobar: bar + production: + foobar: baz diff --git a/secretspec/src/provider/sops/tests.rs b/secretspec/src/provider/sops/tests.rs new file mode 100644 index 00000000..0f1a2dd3 --- /dev/null +++ b/secretspec/src/provider/sops/tests.rs @@ -0,0 +1,555 @@ +use super::*; +use crate::provider::sops::config::SopsConfig; +use std::{collections::HashMap, fs}; +use tempfile::TempDir; +use url::Url; + +fn build_sops_url(path: &str, query_parameters: Option>) -> Url { + let mut params: HashMap<&str, &str> = HashMap::from([ + ("age_key_file", "./src/provider/sops/test_fixtures/key.txt"), + ( + "age_recipients", + "age1jpa8rf5qmrg6pw444fcgpkaxg8x4neueszrexzagdjpunjlgeyzq304w34", + ), + ]); + + if let Some(custom) = query_parameters { + for (k, v) in custom { + params.insert(k, v); + } + } + + let query = params + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&"); + + Url::parse(&format!("sops://{path}?{query}")).unwrap() +} + +#[test] +fn test_sops_build_lookup_paths_single_file_vs_directory() { + let single_file_config = SopsConfig { + mode: SopsMode::SingleFile(PathBuf::from(".sops.yaml")), + ..Default::default() + }; + + let provider = SopsProvider::new(single_file_config); + + let paths = provider.lookup_paths(&AddressParts { + key: "database_url", + profile: "production", + project: "myapp", + }); + + assert_eq!( + paths.unwrap(), + vec![ + vec!["myapp", "production", "database_url"], + vec!["production", "database_url"], + vec!["database_url"] + ] + ); + + let dir_config = SopsConfig { + format: SopsFormat::Json, + mode: SopsMode::Directory { + path: PathBuf::from("secrets"), + pattern: SopsPathPattern::try_from("{project}.{profile}.sops.json").unwrap(), + format: SopsFormat::Json, + }, + ..Default::default() + }; + + let provider = SopsProvider::new(dir_config); + + let paths = provider.lookup_paths(&AddressParts { + key: "database_url", + profile: "production", + project: "myapp", + }); + assert_eq!(paths.unwrap(), vec![vec!["database_url"]]); +} + +#[test] +fn test_sops_normalized_json_selects_the_requested_key() { + let provider = SopsProvider::new(SopsConfig { + format: SopsFormat::Env, + mode: SopsMode::Directory { + path: PathBuf::from("secrets"), + pattern: SopsPathPattern::try_from("{project}.{profile}.env").unwrap(), + format: SopsFormat::Env, + }, + ..Default::default() + }); + let content = br#"{"DB_PASSWORD":"hunter2","API_KEY":"abc"}"#; + let requested = AddressParts { + project: "app", + profile: "production", + key: "API_KEY", + }; + assert_eq!( + provider.parse_decrypted_json(content, &requested).unwrap(), + Some("abc".to_string()) + ); + + let missing = AddressParts { + key: "MISSING", + ..requested + }; + assert_eq!( + provider.parse_decrypted_json(content, &missing).unwrap(), + None + ); +} + +#[test] +fn test_sops_dotenv_writes_use_a_flat_key() { + let provider = SopsProvider::new(SopsConfig { + format: SopsFormat::Env, + mode: SopsMode::SingleFile(PathBuf::from("secrets.env")), + ..Default::default() + }); + let parts = AddressParts { + project: "app", + profile: "production", + key: "API_KEY", + }; + assert_eq!(provider.set_path(&parts).unwrap(), r#"["API_KEY"]"#); +} + +#[test] +fn test_sops_invalid_format() { + let url = Url::parse("sops://./secrets.enc.json?format=invalid").unwrap(); + + let provider_result: std::result::Result, _> = (&url).try_into(); + + assert!(provider_result.is_err()); +} + +fn run_sops_single_file_test(ext: &str) { + let url = build_sops_url( + format!("src/provider/sops/test_fixtures/single_file/some-project-name.enc.{ext}").as_str(), + None, + ); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + let expected = [("development", "bar"), ("production", "baz")]; + + for (profile, expected_value) in expected { + if let Some(value) = provider + .get(Address::convention("some-project-name", profile, "foobar")) + .expect("Failed to fetch secret") + { + let secret = value.expose_secret(); + + assert_eq!( + expected_value, secret, + r#"Expected "{expected_value}", got "{secret}""# + ); + } + } +} + +#[test] +fn test_sops_single_file_get_ini() { + run_sops_single_file_test("ini"); +} + +#[test] +fn test_sops_single_file_get_yaml() { + run_sops_single_file_test("yaml"); +} + +#[test] +fn test_sops_single_file_get_json() { + run_sops_single_file_test("json"); +} + +#[test] +fn test_sops_directory_get_json() { + let url = build_sops_url( + "src/provider/sops/test_fixtures/directory/{project}/{profile}.enc.json", + None, + ); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + let expected = [("development", "bar"), ("production", "baz")]; + + for (profile, expected_value) in expected { + match provider.get(Address::convention("some-project-name", profile, "foobar")) { + Ok(value) => match value { + Some(secret_box) => { + let secret = secret_box.expose_secret(); + + assert_eq!( + expected_value, secret, + r#"Expected "{expected_value}", got "{secret}""# + ); + } + None => panic!( + "'foobar' under profile '{}' in project 'some-project-name' not found", + profile, + ), + }, + Err(e) => { + panic!("{}", e); + } + } + } +} + +#[test] +fn test_sops_directory_nested_get_json() { + let url = build_sops_url( + "src/provider/sops/test_fixtures/directory/{project}/{profile}/secrets.enc.json", + None, + ); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + let expected = [("development", "bar"), ("production", "baz")]; + + for (profile, expected_value) in expected { + match provider.get(Address::convention("some-project-name", profile, "foobar")) { + Ok(value) => match value { + Some(secret_box) => { + let secret = secret_box.expose_secret(); + + assert_eq!( + expected_value, secret, + r#"Expected "{expected_value}", got "{secret}""# + ); + } + None => panic!( + "'foobar' under profile '{}' in project 'some-project-name' not found", + profile, + ), + }, + Err(e) => { + panic!("{}", e); + } + } + } +} + +#[test] +fn test_sops_directory_get_dotenv() { + let built_url = build_sops_url( + "src/provider/sops/test_fixtures/directory/{project}/.env.{profile}.enc", + Some(HashMap::from([("format", "dotenv")])), + ); + + let provider: Box = (&built_url).try_into().expect("Provider init failed"); + + let expected = [("development", "bar"), ("production", "baz")]; + + for (profile, expected_value) in expected { + match provider.get(Address::convention("some-project-name", profile, "foobar")) { + Ok(value) => match value { + Some(secret_box) => { + let secret = secret_box.expose_secret(); + + assert_eq!( + expected_value, secret, + r#"Expected "{expected_value}", got "{secret}""# + ); + } + None => panic!( + "'foobar' under profile '{}' in project 'some-project-name' not found", + profile, + ), + }, + Err(e) => { + panic!("{}", e); + } + } + } +} + +#[test] +fn test_sops_set_directory_dotenv_with_format_override() { + let temp = TempDir::new().unwrap(); + let url = build_sops_url( + &format!("{}/{{project}}/.env.{{profile}}.enc", temp.path().display()), + Some(HashMap::from([("format", "dotenv")])), + ); + let provider: Box = (&url).try_into().expect("Provider init failed"); + let addr = Address::convention("myapp", "production", "API_KEY"); + + provider + .set(addr, &SecretString::new("dotenv-value".into())) + .expect("set failed"); + let value = provider.get(addr).unwrap().expect("missing value"); + + assert_eq!(value.expose_secret(), "dotenv-value"); +} + +#[test] +fn test_sops_set_single_file_dotenv_uses_a_flat_key() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("secrets.enc"); + let url = build_sops_url( + &path.to_string_lossy(), + Some(HashMap::from([("format", "dotenv")])), + ); + let provider: Box = (&url).try_into().expect("Provider init failed"); + let addr = Address::convention("myapp", "production", "API_KEY"); + + provider + .set(addr, &SecretString::new("flat-value".into())) + .expect("set failed"); + let value = provider.get(addr).unwrap().expect("missing value"); + + assert_eq!(value.expose_secret(), "flat-value"); +} + +#[test] +fn test_sops_json_override_works_with_ini_extension() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("secrets.enc.ini"); + let url = build_sops_url( + &path.to_string_lossy(), + Some(HashMap::from([("format", "json")])), + ); + let provider: Box = (&url).try_into().expect("Provider init failed"); + let addr = Address::convention("myapp", "production", "API_KEY"); + + provider + .set(addr, &SecretString::new("json-value".into())) + .expect("set failed"); + let value = provider.get(addr).unwrap().expect("missing value"); + + assert_eq!(value.expose_secret(), "json-value"); +} + +#[test] +fn test_sops_age_key_provider_credential_overrides_the_environment() { + let url = + Url::parse("sops://src/provider/sops/test_fixtures/single_file/some-project-name.enc.json") + .unwrap(); + let provider_url = ProviderUrl::new(url); + let config = SopsConfig::try_from(&provider_url).unwrap(); + let mut provider = SopsProvider::new(config); + let key_file = fs::read_to_string("src/provider/sops/test_fixtures/key.txt").unwrap(); + let age_key = key_file + .lines() + .find(|line| line.starts_with("AGE-SECRET-KEY-")) + .unwrap(); + let mut credentials = ProviderCredentials::new(); + credentials.insert(AGE_KEY.to_string(), SecretString::new(age_key.into())); + provider.with_credentials(credentials); + + let value = provider + .get(Address::convention( + "some-project-name", + "production", + "foobar", + )) + .unwrap() + .expect("missing value"); + + assert_eq!(value.expose_secret(), "baz"); +} + +#[test] +fn test_sops_set_single_file_creates_tree_and_sets_value() { + let temp = TempDir::new().unwrap(); + + let file_path = temp.path().join("secrets.enc.yaml"); + + let url = build_sops_url(&file_path.to_string_lossy(), None); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + provider + .set( + Address::convention("myapp", "production", "database_url"), + &SecretString::new("postgres://prod".into()), + ) + .expect("set failed"); + + let value = provider + .get(Address::convention("myapp", "production", "database_url")) + .expect("get failed") + .expect("missing value"); + + assert_eq!(value.expose_secret(), "postgres://prod"); +} + +#[test] +fn test_sops_set_directory_creates_file_and_sets_value() { + let temp = TempDir::new().unwrap(); + + let base = temp.path(); + + let url = build_sops_url( + format!( + "{}/{{project}}/{{profile}}.enc.json", + &base.to_string_lossy() + ) + .as_str(), + None, + ); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + // Set a value into a file that does not exist yet + provider + .set( + Address::convention("myapp", "development", "api_key"), + &SecretString::new("xyz123".into()), + ) + .expect("set failed"); + + let expected_file = base.join("myapp/development.enc.json"); + + assert!(expected_file.exists()); + + let value = provider + .get(Address::convention("myapp", "development", "api_key")) + .expect("get failed") + .expect("missing value"); + + assert_eq!(value.expose_secret(), "xyz123"); +} + +#[test] +fn test_sops_set_overwrites_existing_value() { + let temp = TempDir::new().unwrap(); + + let file_path = temp.path().join("secrets.enc.json"); + + fs::write(&file_path, "{}").unwrap(); + + let url = build_sops_url(&file_path.to_string_lossy(), None); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + provider + .set( + Address::convention("proj", "dev", "token"), + &SecretString::new("first".into()), + ) + .expect("set failed"); + + provider + .set( + Address::convention("proj", "dev", "token"), + &SecretString::new("second".into()), + ) + .expect("set failed"); + + let value = provider + .get(Address::convention("proj", "dev", "token")) + .expect("get failed") + .expect("missing value"); + + assert_eq!(value.expose_secret(), "second"); +} + +#[test] +fn test_sops_set_single_file_default_profile() { + let temp = TempDir::new().unwrap(); + + let file_path = temp.path().join("secrets.enc.yaml"); + + let url = build_sops_url(&file_path.to_string_lossy(), None); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + provider + .set( + Address::convention("myapp", "default", "service_url"), + &SecretString::new("http://localhost".into()), + ) + .expect("set failed"); + + let value = provider + .get(Address::convention("myapp", "default", "service_url")) + .expect("get failed") + .expect("missing value"); + + assert_eq!(value.expose_secret(), "http://localhost"); +} + +#[test] +fn test_sops_set_directory_multiple_profiles() { + let temp = TempDir::new().unwrap(); + + let base = temp.path(); + + let url = build_sops_url( + format!( + "{}/{{project}}/{{profile}}.enc.yaml", + &base.to_string_lossy() + ) + .as_str(), + None, + ); + + let provider: Box = (&url).try_into().expect("Provider init failed"); + + provider + .set( + Address::convention("myapp", "development", "db"), + &SecretString::new("dev-db".into()), + ) + .expect("set failed"); + + provider + .set( + Address::convention("myapp", "production", "db"), + &SecretString::new("prod-db".into()), + ) + .expect("set failed"); + + let dev = provider + .get(Address::convention("myapp", "development", "db")) + .unwrap() + .unwrap() + .expose_secret() + .to_string(); + + let prod = provider + .get(Address::convention("myapp", "production", "db")) + .unwrap() + .unwrap() + .expose_secret() + .to_string(); + + assert_eq!(dev, "dev-db"); + assert_eq!(prod, "prod-db"); +} + +#[test] +fn test_sops_provider_advertises_credentials() { + let expected = [ + "age_key", + "aws_secret_access_key", + "azure_client_secret", + "hc_vault_token", + "huawei_sdk_ak", + "huawei_sdk_sk", + "google_oauth_access_token", + ]; + assert_eq!( + crate::provider::credential_names_for_spec("sops://secrets.enc.yaml"), + expected + ); +} + +#[test] +fn test_sops_provider_rejects_credentials_in_uri() { + for name in CREDENTIAL_FIELDS.iter().map(|spec| spec.name) { + let url = Url::parse(&format!( + "sops://secrets.enc.yaml?{name}=must-not-be-in-config" + )) + .unwrap(); + let result: std::result::Result, _> = (&url).try_into(); + assert!(result.is_err(), "{name} was accepted as a URI parameter"); + } +}