├── .gitignore ├── tarpaulin.toml ├── src ├── provider.rs ├── lib.rs ├── main.rs ├── error.rs ├── provider │ ├── aws.rs │ ├── github.rs │ └── doppler.rs ├── client.rs └── cli.rs ├── tests └── fixtures │ └── mixed_whitespace.yml ├── release-plz.toml ├── .github └── workflows │ ├── release-plz.yml │ ├── ci.yml │ └── release.yml ├── dist-workspace.toml ├── Cargo.toml ├── CHANGELOG.md ├── cliff.toml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.profraw 3 | coverage/* 4 | -------------------------------------------------------------------------------- /tarpaulin.toml: -------------------------------------------------------------------------------- 1 | [test_config] 2 | skip-clean = true 3 | exclude-files = ["target/**/*"] 4 | target-dir = "coverage" 5 | out = ["Lcov"] 6 | output-dir = "coverage" 7 | -------------------------------------------------------------------------------- /src/provider.rs: -------------------------------------------------------------------------------- 1 | /// Defines an AWS provider implementation 2 | pub mod aws; 3 | /// Defines a GitHub provider implementation 4 | pub mod github; 5 | /// Defines a Doppler provider implementation 6 | pub mod doppler; 7 | -------------------------------------------------------------------------------- /tests/fixtures/mixed_whitespace.yml: -------------------------------------------------------------------------------- 1 | application.yml: |- 2 | banana: false 3 | apple: true 4 | flasdjfljasdlfjalsd: alsdkjflasjdflajdslf 5 | database.yml: |- 6 | banana: false 7 | apple: true 8 | flasdjfljasdlfjalsd: alsdkjflasjdflajdslf 9 | settings.yml: |- 10 | banana: false 11 | apple: true 12 | flasdjfljasdlfjalsd: alsdkjflasjdflajdslf 13 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! 2 | #![doc = include_str!("../README.md")] 3 | 4 | /// Defines the command line interface of nysm. 5 | pub mod cli; 6 | /// Defines a set of structs and a trait that can be used to abstract 7 | /// away individual secret provider details and allow for uniform access across 8 | /// providers. 9 | pub mod client; 10 | /// Defines all of the errors that can occur during normal operations. 11 | pub mod error; 12 | /// Defines provider implementations. 13 | pub mod provider; 14 | -------------------------------------------------------------------------------- /release-plz.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | # path of the git-cliff configuration 3 | changelog_config = "cliff.toml" 4 | 5 | # enable changelog updates 6 | changelog_update = true 7 | 8 | # update dependencies with `cargo update` 9 | dependencies_update = true 10 | 11 | # create tags for the releases 12 | git_tag_enable = true 13 | 14 | # disable GitHub releases 15 | git_release_enable = false 16 | 17 | # labels for the release PR 18 | pr_labels = ["release"] 19 | 20 | # disallow updating repositories with uncommitted changes 21 | allow_dirty = false 22 | 23 | # disallow packaging with uncommitted changes 24 | publish_allow_dirty = false 25 | 26 | # disable running `cargo-semver-checks` 27 | semver_check = false 28 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release-plz: 10 | name: Release-plz 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | token: ${{ secrets.RELEASE_PLZ_TOKEN }} 18 | 19 | - name: Install Rust toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | 22 | - name: Run release-plz 23 | uses: MarcoIeni/release-plz-action@v0.5 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} 26 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | 9 | jobs: 10 | build_and_test: 11 | name: Nysm Tests and Coverage 12 | runs-on: ubuntu-latest 13 | container: 14 | image: xd009642/tarpaulin:develop-nightly 15 | options: --security-opt seccomp=unconfined 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions-rust-lang/setup-rust-toolchain@v1 19 | with: 20 | toolchain: 1.87.0 21 | - name: Generate code coverage 22 | run: | 23 | cargo tarpaulin --engine llvm 24 | - name: Coveralls GitHub Action 25 | uses: coverallsapp/github-action@v1.1.2 26 | with: 27 | github-token: ${{ github.token }} 28 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.0" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell", "powershell"] 12 | # Target platforms to build apps for (Rust target-triple syntax) 13 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] 14 | # Which actions to run on pull requests 15 | pr-run-mode = "plan" 16 | # Path that installers should place binaries in 17 | install-path = "CARGO_HOME" 18 | # Whether to install an updater program 19 | install-updater = false 20 | allow-dirty = ["ci", "msi"] 21 | 22 | [dist.github-custom-runners] 23 | aarch64-unknown-linux-gnu = "ubuntu-22.04" 24 | x86_64-unknown-linux-gnu = "ubuntu-22.04" 25 | x86_64-unknown-linux-musl = "ubuntu-22.04" 26 | x86_64-pc-windows-msvc = "windows-2022" 27 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg(not(tarpaulin_include))] 2 | use clap::Parser; 3 | use nysm::{ 4 | cli::{ArgumentParser, Providers}, 5 | client::QuerySecrets, 6 | provider::{aws::AwsClient, github::GitHubClient, doppler::DopplerClient}, 7 | }; 8 | 9 | #[tokio::main] 10 | async fn main() -> Result<(), Box> { 11 | rustls::crypto::ring::default_provider() 12 | .install_default() 13 | .expect("Failed to install rustls crypto provider"); 14 | 15 | let cli = ArgumentParser::parse(); 16 | 17 | let (client, command): (Box, _) = match &cli.provider { 18 | Providers::Aws(aws) => { 19 | let client = Box::new(AwsClient::new(aws.region.clone()).await); 20 | (client, &aws.command) 21 | } 22 | Providers::Github(github) => { 23 | let token = github.token.clone() 24 | .ok_or("GitHub token is required. Set via --token or GITHUB_TOKEN env var")?; 25 | let client = Box::new(GitHubClient::new(token, github.owner.clone(), github.repo.clone())?); 26 | (client, &github.command) 27 | } 28 | Providers::Doppler(doppler) => { 29 | let token = doppler.token.clone() 30 | .ok_or("Doppler token is required. Set via --token or DOPPLER_TOKEN env var")?; 31 | let client = Box::new(DopplerClient::new(token, doppler.project.clone(), doppler.config.clone())?); 32 | (client, &doppler.command) 33 | } 34 | }; 35 | 36 | ArgumentParser::run_subcommand(client, command).await; 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nysm" 3 | version = "0.2.1" 4 | edition = "2024" 5 | description = "Manage secrets from Secrets Providers." 6 | authors = ["Endoze "] 7 | license = "MIT" 8 | readme = "README.md" 9 | repository = "https://github.com/Endoze/nysm" 10 | documentation = "https://docs.rs/nysm" 11 | homepage = "https://github.com/Endoze/nysm" 12 | rust-version = "1.86.0" 13 | keywords = ["secrets", "aws", "cli", "secretsmanager", "security"] 14 | categories = ["command-line-utilities", "config", "authentication"] 15 | 16 | [lib] 17 | name = "nysm" 18 | 19 | [dependencies] 20 | anyhow = "1" 21 | async-trait = "0.1" 22 | aws-config = "1.6.1" 23 | aws-sdk-secretsmanager = "1.74.0" 24 | aws-types = "1.3.7" 25 | base64 = "0.22" 26 | bat = "0.25" 27 | clap = { version = "4.4", features = ["derive", "env"]} 28 | octocrab = "0.44" 29 | reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } 30 | rustls = { version = "0.23", features = ["ring"] } 31 | serde = { version = "1", features = ["derive"] } 32 | sodiumoxide = "0.2" 33 | serde_json = "1" 34 | serde_yml = "0.0.12" 35 | tabled = "0.19" 36 | tempfile = "3" 37 | thiserror = "2.0" 38 | tokio = { version = "1", features = ["full"] } 39 | 40 | [lints.rust] 41 | unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } 42 | 43 | [dev-dependencies] 44 | lazy_static = { version = "1.4.0" } 45 | futures = "0.3.28" 46 | 47 | # The profile that 'cargo dist' will build with 48 | [profile.dist] 49 | inherits = "release" 50 | lto = "thin" 51 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.2.1] - 2025-08-09 6 | 7 | 8 | ## [0.2.0] - 2025-08-09 9 | 10 | ### Feat 11 | 12 | - Add support for github actions secrets 13 | - Add support for doppler secrets 14 | 15 | ### Breaking Changes 16 | 17 | - All providers are subcommands now which changes how the command line interface works. 18 | 19 | 20 | ## [0.1.10] - 2025-06-14 21 | 22 | ### Chore 23 | 24 | - Add keywords and categories for crates.io 25 | 26 | 27 | ## [0.1.9] - 2025-05-24 28 | 29 | ### Miscellaneous Tasks 30 | 31 | - Remove arm windows build 32 | 33 | 34 | ## [0.1.8] - 2025-05-24 35 | 36 | ### Feat 37 | 38 | - Add windows builds back 39 | 40 | 41 | ## [0.1.7] - 2025-05-24 42 | 43 | ### Feat 44 | 45 | - Add support for deleting secrets 46 | 47 | 48 | ## [0.1.6] - 2025-05-24 49 | 50 | ### Miscellaneous Tasks 51 | 52 | - Remove windows targets 53 | 54 | 55 | ## [0.1.5] - 2025-05-24 56 | 57 | ### Miscellaneous Tasks 58 | 59 | - Update rust version to 1.86.0 in Cargo.toml 60 | 61 | 62 | ## [0.1.4] - 2025-05-24 63 | 64 | ### Miscellaneous Tasks 65 | 66 | - Update explicit runners for architectures 67 | 68 | 69 | ## [0.1.3] - 2025-05-24 70 | 71 | ### Chore 72 | 73 | - Update release build platform 74 | 75 | 76 | ## [0.1.2] - 2025-05-24 77 | 78 | ### Feat 79 | 80 | - Add support for creating secrets 81 | 82 | ### Miscellaneous Tasks 83 | 84 | - Add tests for errors 85 | 86 | 87 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = """ 11 | # Changelog\n 12 | All notable changes to this project will be documented in this file.\n 13 | """ 14 | # template for the changelog body 15 | # https://keats.github.io/tera/docs/#introduction 16 | body = """ 17 | {% if version %}\ 18 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 19 | {% else %}\ 20 | ## [unreleased] 21 | {% endif %}\ 22 | {% for group, commits in commits | group_by(attribute="group") %} 23 | ### {{ group | upper_first }} 24 | {% for commit in commits %} 25 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 26 | {% endfor %} 27 | {% endfor %}\n 28 | """ 29 | # remove the leading and trailing whitespace from the template 30 | trim = true 31 | # changelog footer 32 | footer = """ 33 | 34 | """ 35 | # postprocessors 36 | postprocessors = [ 37 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 38 | ] 39 | [git] 40 | # parse the commits based on https://www.conventionalcommits.org 41 | conventional_commits = true 42 | # filter out the commits that are not conventional 43 | filter_unconventional = true 44 | # process each line of a commit as an individual commit 45 | split_commits = false 46 | # regex for preprocessing the commit messages 47 | commit_preprocessors = [ 48 | # { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, # replace issue numbers 49 | ] 50 | # regex for parsing and grouping commits 51 | commit_parsers = [ 52 | { message = "^feat", group = "Features" }, 53 | { message = "^fix", group = "Bug Fixes" }, 54 | { message = "^doc", group = "Documentation" }, 55 | { message = "^perf", group = "Performance" }, 56 | { message = "^refactor", group = "Refactor" }, 57 | { message = "^style", group = "Styling" }, 58 | { message = "^test", group = "Testing" }, 59 | { message = "^chore\\(release\\): prepare for", skip = true }, 60 | { message = "^chore\\(deps\\)", skip = true }, 61 | { message = "^chore\\(pr\\)", skip = true }, 62 | { message = "^chore\\(pull\\)", skip = true }, 63 | { message = "^chore|ci", group = "Miscellaneous Tasks" }, 64 | { body = ".*security", group = "Security" }, 65 | { message = "^revert", group = "Revert" }, 66 | ] 67 | # protect breaking changes from being skipped due to matching a skipping commit_parser 68 | protect_breaking_commits = false 69 | # filter out the commits that are not matched by commit parsers 70 | filter_commits = false 71 | # regex for matching git tags 72 | tag_pattern = "v[0-9].*" 73 | 74 | # regex for skipping tags 75 | skip_tags = "v0.1.0-beta.1" 76 | # regex for ignoring tags 77 | ignore_tags = "" 78 | # sort the tags topologically 79 | topo_order = false 80 | # sort the commits inside sections by oldest/newest order 81 | sort_commits = "oldest" 82 | # limit the number of commits included in the changelog. 83 | # limit_commits = 42 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nysm (Now You See Me) 2 | 3 | ![Build Status](https://github.com/endoze/nysm/actions/workflows/ci.yml/badge.svg?branch=master) 4 | [![Coverage Status](https://coveralls.io/repos/github/endoze/nysm/badge.svg?branch=master)](https://coveralls.io/github/endoze/nysm?branch=master) 5 | [![Crate](https://img.shields.io/crates/v/nysm.svg)](https://crates.io/crates/nysm) 6 | [![Docs](https://docs.rs/nysm/badge.svg)](https://docs.rs/nysm) 7 | 8 | #### Manage your secrets via the command line 9 | 10 | Nysm is a command line utility designed to make interacting with secrets management providers simple and intuitive. Whether you need to quickly view a secret, update configuration values, or manage your secrets workflow, Nysm provides a streamlined interface with support for multiple data formats and your preferred editor. 11 | 12 | ## Features 13 | 14 | - **List all secrets** in your account with names and descriptions 15 | - **View secret values** in multiple formats (JSON, YAML, plain text) 16 | - **Edit secrets** using your preferred editor with format conversion 17 | - **Create new secrets** interactively with optional descriptions 18 | - **Delete secrets** when no longer needed 19 | - **Multi-format support** for seamless workflow integration 20 | - **Syntax highlighting** and pagination for better readability 21 | 22 | # Installation 23 | 24 | ```sh 25 | cargo install nysm 26 | ``` 27 | 28 | # Usage 29 | 30 | List secrets: 31 | 32 | ```sh 33 | nysm list 34 | ``` 35 | 36 | Show a specific secret: 37 | 38 | ```sh 39 | nysm show some-secret-id 40 | ``` 41 | 42 | Edit an existing secret: 43 | 44 | ```sh 45 | nysm edit some-secret-id 46 | ``` 47 | 48 | Create a new secret: 49 | 50 | ```sh 51 | nysm create some-new-secret-id -d "This is a description for the secret" 52 | ``` 53 | 54 | Delete a secret: 55 | 56 | ```sh 57 | nysm delete some-secret-id 58 | ``` 59 | 60 | ## Advanced Usage 61 | 62 | ### Format Options 63 | 64 | Nysm supports multiple data formats for viewing and editing secrets: 65 | 66 | - `json` - JSON format (default for stored secrets) 67 | - `yaml` - YAML format (default for editing) 68 | - `text` - Plain text format 69 | 70 | You can specify different formats for storage and editing: 71 | 72 | ```sh 73 | # View a JSON secret as YAML (default behavior) 74 | nysm show my-secret 75 | 76 | # View a secret as JSON 77 | nysm show my-secret --print-format json 78 | 79 | # Edit a secret, converting from JSON storage to YAML for editing 80 | nysm edit my-secret --secret-format json --edit-format yaml 81 | 82 | # Create a secret and store it as JSON (converted from YAML editing) 83 | nysm create my-new-secret --secret-format json --edit-format yaml 84 | ``` 85 | 86 | ### Region Selection 87 | 88 | Specify a different region using the `-r` or `--region` flag: 89 | 90 | ```sh 91 | nysm -r us-west-2 list 92 | nysm --region eu-west-1 show my-secret 93 | ``` 94 | 95 | ### Editor Integration 96 | 97 | When creating or editing secrets, Nysm will open your preferred editor: 98 | 99 | - Uses the `EDITOR` environment variable (defaults to `vim`) 100 | - Temporary files are created with appropriate extensions for syntax highlighting 101 | - Changes are only saved if the file content is modified 102 | 103 | ## Configuration 104 | 105 | ### AWS Credentials 106 | 107 | Nysm uses standard AWS credential resolution: 108 | 109 | 1. Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) 110 | 2. AWS credentials file (`~/.aws/credentials`) 111 | 3. IAM roles (when running on EC2) 112 | 4. AWS SSO 113 | 114 | ### Required Permissions 115 | 116 | Your AWS credentials need the following IAM permissions: 117 | 118 | ```json 119 | { 120 | "Version": "2012-10-17", 121 | "Statement": [ 122 | { 123 | "Effect": "Allow", 124 | "Action": [ 125 | "secretsmanager:ListSecrets", 126 | "secretsmanager:GetSecretValue", 127 | "secretsmanager:CreateSecret", 128 | "secretsmanager:UpdateSecret", 129 | "secretsmanager:DeleteSecret" 130 | ], 131 | "Resource": "*" 132 | } 133 | ] 134 | } 135 | ``` 136 | 137 | ## Examples 138 | 139 | ### Managing Application Configuration 140 | 141 | ```sh 142 | # List all secrets to find your app config 143 | nysm list 144 | 145 | # View current database configuration 146 | nysm show myapp/database/config 147 | 148 | # Update database password 149 | nysm edit myapp/database/config 150 | 151 | # Create new API key secret 152 | nysm create myapp/api/keys -d "API keys for external services" 153 | ``` 154 | 155 | ### Working with Different Formats 156 | 157 | ```sh 158 | # View a plaintext secret (like an SSL certificate) 159 | nysm show ssl-cert --print-format text --secret-format text 160 | 161 | # Convert YAML configuration to JSON storage 162 | nysm create app-config --secret-format json --edit-format yaml 163 | ``` 164 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #[derive(thiserror::Error, Debug)] 3 | #[repr(u8)] 4 | /// Enum to define all of the possible errors that can occur during normal 5 | /// use of Nysm. 6 | pub enum NysmError { 7 | /// Error occurs when attempting to parse data as Json fails. 8 | #[error("Unable to parse data as json")] 9 | SerdeJson(#[from] serde_json::Error), 10 | 11 | /// Error occurs when attempting to parse data as Text fails. 12 | #[error("Unable to parse data as yaml")] 13 | SerdeYaml(#[from] serde_yml::Error), 14 | 15 | /// Error occurs when pretty printing the contents of a secret fails. 16 | #[error("Unable to pretty print data")] 17 | BatPrint(#[from] bat::error::Error), 18 | 19 | /// Error occurs when reading/writing a temporary file for secret 20 | /// editing fails. 21 | #[error("Unable to read/write file caused by: {}", .0)] 22 | IO(#[from] std::io::Error), 23 | 24 | /// Error occurs when retrieving a list of secrets from a provider fails. 25 | #[error("Failed to list secrets: {0}")] 26 | ListSecretsFailed(String), 27 | 28 | /// Error occurs when a specific secret value cannot be retrieved. 29 | #[error("Failed to retrieve secret value: {0}")] 30 | GetSecretValueFailed(String), 31 | 32 | /// Error occurs when the secret does not support read operations. 33 | #[error("Secret does not support read operations")] 34 | SecretNotReadable, 35 | 36 | /// Error occurs when updating a secret's value fails. 37 | #[error("Failed to update secret: {0}")] 38 | UpdateSecretFailed(String), 39 | 40 | /// Error occurs when creating a secret fails. 41 | #[error("Failed to create secret: {0}")] 42 | CreateSecretFailed(String), 43 | 44 | /// Error occurs when deleting a secret fails. 45 | #[error("Failed to delete secret: {0}")] 46 | DeleteSecretFailed(String), 47 | 48 | /// Error occurs when authentication with a provider fails. 49 | #[error("Authentication failed: {0}")] 50 | AuthenticationFailed(String), 51 | 52 | /// Error occurs when provider configuration is invalid. 53 | #[error("Invalid configuration: {0}")] 54 | InvalidConfiguration(String), 55 | } 56 | 57 | impl PartialEq for NysmError { 58 | fn eq(&self, other: &Self) -> bool { 59 | std::mem::discriminant(self) == std::mem::discriminant(other) 60 | } 61 | } 62 | 63 | #[cfg(not(tarpaulin_include))] 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | use serde::de::Error; 68 | 69 | #[test] 70 | fn test_serde_json_error() { 71 | let error = NysmError::SerdeJson(serde_json::Error::custom("custom error")); 72 | assert_eq!(error.to_string(), "Unable to parse data as json"); 73 | } 74 | 75 | #[test] 76 | fn test_serde_yaml_error() { 77 | let error = NysmError::SerdeYaml(serde_yml::Error::custom("custom error")); 78 | assert_eq!(error.to_string(), "Unable to parse data as yaml"); 79 | } 80 | 81 | #[test] 82 | fn test_bat_print_error() { 83 | let error = NysmError::BatPrint(Into::::into("custom error")); 84 | assert_eq!(error.to_string(), "Unable to pretty print data"); 85 | } 86 | 87 | #[test] 88 | fn test_io_error() { 89 | let error = NysmError::IO(std::io::Error::new( 90 | std::io::ErrorKind::Other, 91 | "custom error", 92 | )); 93 | assert_eq!( 94 | error.to_string(), 95 | "Unable to read/write file caused by: custom error" 96 | ); 97 | } 98 | 99 | #[test] 100 | fn test_list_secrets_failed_error() { 101 | let error = NysmError::ListSecretsFailed("connection timeout".to_string()); 102 | assert_eq!( 103 | error.to_string(), 104 | "Failed to list secrets: connection timeout" 105 | ); 106 | } 107 | 108 | #[test] 109 | fn test_get_secret_value_failed_error() { 110 | let error = NysmError::GetSecretValueFailed("secret not found".to_string()); 111 | assert_eq!( 112 | error.to_string(), 113 | "Failed to retrieve secret value: secret not found" 114 | ); 115 | } 116 | 117 | #[test] 118 | fn test_secret_not_readable_error() { 119 | let error = NysmError::SecretNotReadable; 120 | assert_eq!(error.to_string(), "Secret does not support read operations"); 121 | } 122 | 123 | #[test] 124 | fn test_update_secret_failed_error() { 125 | let error = NysmError::UpdateSecretFailed("permission denied".to_string()); 126 | assert_eq!( 127 | error.to_string(), 128 | "Failed to update secret: permission denied" 129 | ); 130 | } 131 | 132 | #[test] 133 | fn test_create_secret_failed_error() { 134 | let error = NysmError::CreateSecretFailed("secret already exists".to_string()); 135 | assert_eq!( 136 | error.to_string(), 137 | "Failed to create secret: secret already exists" 138 | ); 139 | } 140 | 141 | #[test] 142 | fn test_delete_secret_failed_error() { 143 | let error = NysmError::DeleteSecretFailed("secret in use".to_string()); 144 | assert_eq!(error.to_string(), "Failed to delete secret: secret in use"); 145 | } 146 | 147 | #[test] 148 | fn test_authentication_failed_error() { 149 | let error = NysmError::AuthenticationFailed("invalid token".to_string()); 150 | assert_eq!(error.to_string(), "Authentication failed: invalid token"); 151 | } 152 | 153 | #[test] 154 | fn test_invalid_configuration_error() { 155 | let error = NysmError::InvalidConfiguration("missing repository name".to_string()); 156 | assert_eq!( 157 | error.to_string(), 158 | "Invalid configuration: missing repository name" 159 | ); 160 | } 161 | 162 | #[test] 163 | fn test_partial_eq() { 164 | let error1 = NysmError::SerdeJson(serde_json::Error::custom("custom error")); 165 | let error2 = NysmError::SerdeJson(serde_json::Error::custom("custom error")); 166 | let error3 = NysmError::SerdeYaml(serde_yml::Error::custom("different error")); 167 | 168 | assert_eq!(error1, error2); 169 | assert_ne!(error1, error3); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/provider/aws.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #![cfg(not(tarpaulin_include))] 3 | use crate::client::{ 4 | CreateSecretResult, DeleteSecretResult, GetSecretValueResult, ListSecretsResult, QuerySecrets, 5 | Secret, UpdateSecretValueResult, 6 | }; 7 | use crate::error::NysmError; 8 | 9 | use async_trait::async_trait; 10 | use aws_config::meta::region::RegionProviderChain; 11 | use aws_sdk_secretsmanager::operation::{ 12 | create_secret::CreateSecretOutput, delete_secret::DeleteSecretOutput, 13 | get_secret_value::GetSecretValueOutput, list_secrets::ListSecretsOutput, 14 | update_secret::UpdateSecretOutput, 15 | }; 16 | use aws_types::region::Region; 17 | 18 | impl From for ListSecretsResult { 19 | fn from(value: ListSecretsOutput) -> Self { 20 | let list = value.secret_list(); 21 | 22 | let entries = list 23 | .iter() 24 | .map(|entry| Secret { 25 | name: entry.name.clone(), 26 | uri: entry.arn.clone(), 27 | description: entry.description.clone(), 28 | }) 29 | .collect(); 30 | 31 | ListSecretsResult { entries } 32 | } 33 | } 34 | 35 | impl From for GetSecretValueResult { 36 | fn from(value: GetSecretValueOutput) -> Self { 37 | let Some(secret) = value.secret_string() else { 38 | return GetSecretValueResult::default(); 39 | }; 40 | 41 | GetSecretValueResult { 42 | secret: secret.to_owned(), 43 | } 44 | } 45 | } 46 | 47 | impl From for UpdateSecretValueResult { 48 | fn from(value: UpdateSecretOutput) -> Self { 49 | Self { 50 | name: value.name().map(String::from), 51 | uri: value.arn().map(String::from), 52 | version_id: value.version_id().map(String::from), 53 | } 54 | } 55 | } 56 | 57 | impl From for CreateSecretResult { 58 | fn from(value: CreateSecretOutput) -> Self { 59 | Self { 60 | name: value.name().map(String::from), 61 | uri: value.arn().map(String::from), 62 | version_id: value.version_id().map(String::from), 63 | } 64 | } 65 | } 66 | 67 | impl From for DeleteSecretResult { 68 | fn from(value: DeleteSecretOutput) -> Self { 69 | Self { 70 | name: value.name().map(String::from), 71 | uri: value.arn().map(String::from), 72 | deletion_date: value.deletion_date().map(|d| d.to_string()), 73 | } 74 | } 75 | } 76 | 77 | /// Wrapper struct to hold onto an actual aws client that we can 78 | /// interact with. Implements [QuerySecrets] trait for AWS. 79 | /// 80 | /// You can create an [AwsClient] using the following code: 81 | /// 82 | /// ```rust,no_run 83 | /// use nysm::provider::aws::AwsClient; 84 | /// 85 | /// # let rt = tokio::runtime::Runtime::new().unwrap(); 86 | /// let region = String::from("us-west-2"); 87 | /// # rt.block_on(async { 88 | /// let client = AwsClient::new(Some(region)).await; 89 | /// # }) 90 | /// ``` 91 | /// 92 | pub struct AwsClient { 93 | client: aws_sdk_secretsmanager::client::Client, 94 | } 95 | 96 | impl AwsClient { 97 | /// Create a new client with an optional region specified 98 | /// otherwise will default to the region provided via the awscli configuration 99 | pub async fn new(region: Option) -> Self { 100 | let region = region.map(Region::new); 101 | let region_provider = RegionProviderChain::first_try(region).or_default_provider(); 102 | 103 | let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) 104 | .region(region_provider) 105 | .load() 106 | .await; 107 | let client = aws_sdk_secretsmanager::client::Client::new(&config); 108 | 109 | Self { client } 110 | } 111 | } 112 | 113 | #[async_trait] 114 | impl QuerySecrets for AwsClient { 115 | async fn secrets_list(&self) -> Result { 116 | match self.client.list_secrets().send().await { 117 | Ok(secrets_list) => { 118 | let mut results: Vec = vec![secrets_list.clone()]; 119 | let mut current_token = secrets_list.next_token().map(String::from); 120 | 121 | while let Some(token) = current_token.as_ref() { 122 | if let Ok(val) = self.client.list_secrets().next_token(token).send().await { 123 | current_token = val.next_token().map(String::from); 124 | results.push(val); 125 | } 126 | } 127 | 128 | let entries = results.into_iter().fold(vec![], |mut acc, elem| { 129 | acc.append(&mut Into::::into(elem).entries); 130 | 131 | acc 132 | }); 133 | 134 | Ok(ListSecretsResult { entries }) 135 | } 136 | Err(e) => Err(NysmError::ListSecretsFailed(format!( 137 | "AWS Secrets Manager: {}", 138 | e 139 | ))), 140 | } 141 | } 142 | 143 | async fn secret_value(&self, secret_id: String) -> Result { 144 | // TODO: return 2 different errors 145 | // depending on secret itself not existing 146 | // or secret string not existing 147 | match self 148 | .client 149 | .get_secret_value() 150 | .secret_id(secret_id) 151 | .send() 152 | .await 153 | { 154 | Ok(secret) => Ok(secret.into()), 155 | Err(e) => Err(NysmError::GetSecretValueFailed(format!( 156 | "AWS Secrets Manager: {}", 157 | e 158 | ))), 159 | } 160 | } 161 | 162 | async fn update_secret_value( 163 | &self, 164 | secret_id: String, 165 | secret_value: String, 166 | ) -> Result { 167 | match self 168 | .client 169 | .update_secret() 170 | .secret_id(secret_id) 171 | .secret_string(secret_value) 172 | .send() 173 | .await 174 | { 175 | Ok(secret) => Ok(secret.into()), 176 | Err(e) => Err(NysmError::UpdateSecretFailed(format!( 177 | "AWS Secrets Manager: {}", 178 | e 179 | ))), 180 | } 181 | } 182 | 183 | async fn create_secret( 184 | &self, 185 | secret_id: String, 186 | secret_value: String, 187 | description: Option, 188 | ) -> Result { 189 | let mut request = self 190 | .client 191 | .create_secret() 192 | .name(secret_id) 193 | .secret_string(secret_value); 194 | 195 | if let Some(desc) = description { 196 | request = request.description(desc); 197 | } 198 | 199 | match request.send().await { 200 | Ok(secret) => Ok(secret.into()), 201 | Err(e) => Err(NysmError::CreateSecretFailed(format!( 202 | "AWS Secrets Manager: {}", 203 | e 204 | ))), 205 | } 206 | } 207 | 208 | async fn delete_secret(&self, secret_id: String) -> Result { 209 | match self 210 | .client 211 | .delete_secret() 212 | .secret_id(secret_id) 213 | .send() 214 | .await 215 | { 216 | Ok(secret) => Ok(secret.into()), 217 | Err(e) => Err(NysmError::DeleteSecretFailed(format!( 218 | "AWS Secrets Manager: {}", 219 | e 220 | ))), 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/provider/github.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #![cfg(not(tarpaulin_include))] 3 | use crate::client::{ 4 | CreateSecretResult, DeleteSecretResult, GetSecretValueResult, ListSecretsResult, QuerySecrets, 5 | UpdateSecretValueResult, 6 | }; 7 | use crate::error::NysmError; 8 | 9 | use async_trait::async_trait; 10 | use base64::{Engine, engine::general_purpose::STANDARD}; 11 | use octocrab::Octocrab; 12 | use octocrab::models::PublicKey; 13 | use octocrab::models::repos::secrets::{CreateRepositorySecret, RepositorySecret}; 14 | 15 | /// Wrapper struct to hold a GitHub API client that implements 16 | /// the [QuerySecrets] trait for GitHub Actions secrets. 17 | /// 18 | /// GitHub Actions secrets are write-only - you cannot retrieve 19 | /// their values through the API. This provider supports listing, 20 | /// creating, updating, and deleting secrets, but not reading values. 21 | /// 22 | /// # Example 23 | /// ```rust,no_run 24 | /// use nysm::provider::github::GitHubClient; 25 | /// 26 | /// # let rt = tokio::runtime::Runtime::new().unwrap(); 27 | /// # rt.block_on(async { 28 | /// let client = GitHubClient::new( 29 | /// "ghp_xxxxxxxxxxxx".to_string(), 30 | /// "owner".to_string(), 31 | /// "repo".to_string() 32 | /// ).unwrap(); 33 | /// # }) 34 | /// ``` 35 | pub struct GitHubClient { 36 | client: Octocrab, 37 | owner: String, 38 | repo: String, 39 | } 40 | 41 | impl GitHubClient { 42 | /// Create a new GitHub client with authentication token and repository information. 43 | /// 44 | /// # Arguments 45 | /// * `token` - GitHub personal access token or GITHUB_TOKEN 46 | /// * `owner` - Repository owner (user or organization) 47 | /// * `repo` - Repository name 48 | /// 49 | /// # Returns 50 | /// Returns a Result containing the GitHubClient or an error if configuration is invalid. 51 | pub fn new(token: String, owner: String, repo: String) -> Result { 52 | if token.is_empty() { 53 | return Err(NysmError::InvalidConfiguration( 54 | "GitHub token cannot be empty".to_string(), 55 | )); 56 | } 57 | 58 | if owner.is_empty() { 59 | return Err(NysmError::InvalidConfiguration( 60 | "GitHub owner cannot be empty".to_string(), 61 | )); 62 | } 63 | 64 | if repo.is_empty() { 65 | return Err(NysmError::InvalidConfiguration( 66 | "GitHub repository name cannot be empty".to_string(), 67 | )); 68 | } 69 | 70 | let client = Octocrab::builder() 71 | .personal_token(token) 72 | .build() 73 | .map_err(|e| { 74 | NysmError::InvalidConfiguration(format!("Failed to create GitHub client: {}", e)) 75 | })?; 76 | 77 | Ok(Self { 78 | client, 79 | owner, 80 | repo, 81 | }) 82 | } 83 | 84 | /// Get the repository's public key for encrypting secrets. 85 | async fn get_repo_public_key(&self) -> Result { 86 | self 87 | .client 88 | .repos(&self.owner, &self.repo) 89 | .secrets() 90 | .get_public_key() 91 | .await 92 | .map_err(|e| { 93 | NysmError::AuthenticationFailed(format!("Failed to get repository public key: {}", e)) 94 | }) 95 | } 96 | 97 | /// Encrypt a secret value using the repository's public key. 98 | fn encrypt_secret( 99 | &self, 100 | public_key: &PublicKey, 101 | secret_value: &str, 102 | ) -> Result { 103 | sodiumoxide::init().map_err(|_| { 104 | NysmError::InvalidConfiguration("Failed to initialize sodium crypto library".to_string()) 105 | })?; 106 | 107 | let key_bytes = STANDARD 108 | .decode(&public_key.key) 109 | .map_err(|e| NysmError::InvalidConfiguration(format!("Invalid public key format: {}", e)))?; 110 | 111 | let public_key = sodiumoxide::crypto::box_::PublicKey::from_slice(&key_bytes) 112 | .ok_or_else(|| NysmError::InvalidConfiguration("Invalid public key".to_string()))?; 113 | 114 | let encrypted = sodiumoxide::crypto::sealedbox::seal(secret_value.as_bytes(), &public_key); 115 | 116 | Ok(STANDARD.encode(encrypted)) 117 | } 118 | } 119 | 120 | #[async_trait] 121 | impl QuerySecrets for GitHubClient { 122 | fn supports_read(&self) -> bool { 123 | false 124 | } 125 | 126 | async fn secrets_list(&self) -> Result { 127 | let secrets = self 128 | .client 129 | .repos(&self.owner, &self.repo) 130 | .secrets() 131 | .get_secrets() 132 | .await 133 | .map_err(|e| match e { 134 | octocrab::Error::GitHub { source, .. } => { 135 | NysmError::ListSecretsFailed(format!("GitHub API error: {}", source.message)) 136 | } 137 | _ => NysmError::ListSecretsFailed(format!("GitHub API error: {}", e)), 138 | })?; 139 | 140 | let entries = secrets 141 | .secrets 142 | .into_iter() 143 | .map(|secret: RepositorySecret| crate::client::Secret { 144 | name: Some(secret.name.clone()), 145 | uri: Some(format!( 146 | "/repos/{}/{}/actions/secrets/{}", 147 | self.owner, self.repo, secret.name 148 | )), 149 | description: None, 150 | }) 151 | .collect(); 152 | 153 | Ok(ListSecretsResult { entries }) 154 | } 155 | 156 | async fn secret_value(&self, _secret_id: String) -> Result { 157 | Err(NysmError::SecretNotReadable) 158 | } 159 | 160 | async fn update_secret_value( 161 | &self, 162 | secret_id: String, 163 | secret_value: String, 164 | ) -> Result { 165 | let public_key = self.get_repo_public_key().await?; 166 | let encrypted_value = self.encrypt_secret(&public_key, &secret_value)?; 167 | 168 | self 169 | .client 170 | .repos(&self.owner, &self.repo) 171 | .secrets() 172 | .create_or_update_secret( 173 | &secret_id, 174 | &CreateRepositorySecret { 175 | encrypted_value: &encrypted_value, 176 | key_id: &public_key.key_id, 177 | }, 178 | ) 179 | .await 180 | .map_err(|e| NysmError::UpdateSecretFailed(format!("GitHub API error: {}", e)))?; 181 | 182 | Ok(UpdateSecretValueResult { 183 | name: Some(secret_id.clone()), 184 | uri: Some(format!( 185 | "/repos/{}/{}/actions/secrets/{}", 186 | self.owner, self.repo, secret_id 187 | )), 188 | version_id: None, 189 | }) 190 | } 191 | 192 | async fn create_secret( 193 | &self, 194 | secret_id: String, 195 | secret_value: String, 196 | _description: Option, 197 | ) -> Result { 198 | let public_key = self.get_repo_public_key().await?; 199 | let encrypted_value = self.encrypt_secret(&public_key, &secret_value)?; 200 | 201 | self 202 | .client 203 | .repos(&self.owner, &self.repo) 204 | .secrets() 205 | .create_or_update_secret( 206 | &secret_id, 207 | &CreateRepositorySecret { 208 | encrypted_value: &encrypted_value, 209 | key_id: &public_key.key_id, 210 | }, 211 | ) 212 | .await 213 | .map_err(|e| NysmError::CreateSecretFailed(format!("GitHub API error: {}", e)))?; 214 | 215 | Ok(CreateSecretResult { 216 | name: Some(secret_id.clone()), 217 | uri: Some(format!( 218 | "/repos/{}/{}/actions/secrets/{}", 219 | self.owner, self.repo, secret_id 220 | )), 221 | version_id: None, 222 | }) 223 | } 224 | 225 | async fn delete_secret(&self, secret_id: String) -> Result { 226 | self 227 | .client 228 | .repos(&self.owner, &self.repo) 229 | .secrets() 230 | .delete_secret(&secret_id) 231 | .await 232 | .map_err(|e| NysmError::DeleteSecretFailed(format!("GitHub API error: {}", e)))?; 233 | 234 | Ok(DeleteSecretResult { 235 | name: Some(secret_id), 236 | uri: None, 237 | deletion_date: None, 238 | }) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/provider/doppler.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | #![cfg(not(tarpaulin_include))] 3 | use crate::client::{ 4 | CreateSecretResult, DeleteSecretResult, GetSecretValueResult, ListSecretsResult, QuerySecrets, 5 | Secret, UpdateSecretValueResult, 6 | }; 7 | use crate::error::NysmError; 8 | 9 | use async_trait::async_trait; 10 | use reqwest::{Client, StatusCode}; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_json::json; 13 | 14 | #[derive(Debug, Deserialize)] 15 | struct DopplerListSecretsResponse { 16 | secrets: std::collections::HashMap, 17 | } 18 | 19 | #[derive(Debug, Deserialize)] 20 | struct DopplerSecretResponse { 21 | value: DopplerSecretValue, 22 | } 23 | 24 | #[derive(Debug, Deserialize)] 25 | struct DopplerSecretValue { 26 | raw: String, 27 | } 28 | 29 | #[derive(Debug, Serialize)] 30 | struct DopplerUpdateSecretRequest { 31 | secrets: std::collections::HashMap, 32 | } 33 | 34 | /// Wrapper struct to hold a Doppler API client that implements 35 | /// the [QuerySecrets] trait for Doppler secrets management. 36 | /// 37 | /// Doppler is a centralized secrets management platform that provides 38 | /// secure storage and retrieval of secrets across environments. 39 | /// 40 | /// # Example 41 | /// ```rust,no_run 42 | /// use nysm::provider::doppler::DopplerClient; 43 | /// 44 | /// # let rt = tokio::runtime::Runtime::new().unwrap(); 45 | /// # rt.block_on(async { 46 | /// let client = DopplerClient::new( 47 | /// "dp.st.xxxxxxxxxxxx".to_string(), 48 | /// "dev".to_string(), 49 | /// "backend".to_string() 50 | /// ).unwrap(); 51 | /// # }) 52 | /// ``` 53 | pub struct DopplerClient { 54 | client: Client, 55 | token: String, 56 | project: String, 57 | config: String, 58 | } 59 | 60 | impl DopplerClient { 61 | /// Create a new Doppler client with authentication token and project information. 62 | /// 63 | /// # Arguments 64 | /// * `token` - Doppler service token (format: dp.st.xxxx) 65 | /// * `project` - Doppler project name 66 | /// * `config` - Doppler config/environment name (e.g., "dev", "staging", "prod") 67 | /// 68 | /// # Returns 69 | /// Returns a Result containing the DopplerClient or an error if configuration is invalid. 70 | pub fn new(token: String, project: String, config: String) -> Result { 71 | if token.is_empty() { 72 | return Err(NysmError::InvalidConfiguration( 73 | "Doppler token cannot be empty".to_string(), 74 | )); 75 | } 76 | 77 | if !token.starts_with("dp.") { 78 | return Err(NysmError::InvalidConfiguration( 79 | "Invalid Doppler token format. Expected format: dp.xx.xxxx".to_string(), 80 | )); 81 | } 82 | 83 | if project.is_empty() { 84 | return Err(NysmError::InvalidConfiguration( 85 | "Doppler project cannot be empty".to_string(), 86 | )); 87 | } 88 | 89 | if config.is_empty() { 90 | return Err(NysmError::InvalidConfiguration( 91 | "Doppler config cannot be empty".to_string(), 92 | )); 93 | } 94 | 95 | let client = Client::builder() 96 | .use_rustls_tls() 97 | .build() 98 | .map_err(|e| NysmError::InvalidConfiguration(format!("Failed to create HTTP client: {}", e)))?; 99 | 100 | Ok(Self { 101 | client, 102 | token, 103 | project, 104 | config, 105 | }) 106 | } 107 | 108 | fn get_base_url(&self) -> String { 109 | format!("https://api.doppler.com/v3/configs/config") 110 | } 111 | 112 | fn get_auth_header(&self) -> (&str, String) { 113 | ("Authorization", format!("Bearer {}", self.token)) 114 | } 115 | 116 | fn get_project_params(&self) -> Vec<(&str, &str)> { 117 | vec![("project", &self.project), ("config", &self.config)] 118 | } 119 | } 120 | 121 | #[async_trait] 122 | impl QuerySecrets for DopplerClient { 123 | async fn secrets_list(&self) -> Result { 124 | let url = format!("{}/secrets", self.get_base_url()); 125 | 126 | let response = self 127 | .client 128 | .get(&url) 129 | .header(self.get_auth_header().0, self.get_auth_header().1) 130 | .query(&self.get_project_params()) 131 | .send() 132 | .await 133 | .map_err(|e| NysmError::ListSecretsFailed(format!("Doppler API request failed: {}", e)))?; 134 | 135 | match response.status() { 136 | StatusCode::OK => { 137 | let data: DopplerListSecretsResponse = response 138 | .json() 139 | .await 140 | .map_err(|e| NysmError::ListSecretsFailed(format!("Failed to parse response: {}", e)))?; 141 | 142 | let entries = data 143 | .secrets 144 | .into_iter() 145 | .map(|(name, _)| Secret { 146 | name: Some(name.clone()), 147 | uri: Some(format!("{}/{}?project={}&config={}", 148 | self.get_base_url(), 149 | name, 150 | self.project, 151 | self.config 152 | )), 153 | description: None, 154 | }) 155 | .collect(); 156 | 157 | Ok(ListSecretsResult { entries }) 158 | } 159 | StatusCode::UNAUTHORIZED => Err(NysmError::AuthenticationFailed( 160 | "Invalid Doppler token".to_string(), 161 | )), 162 | status => { 163 | let error_text = response.text().await.unwrap_or_default(); 164 | Err(NysmError::ListSecretsFailed(format!( 165 | "Doppler API error ({}): {}", 166 | status, error_text 167 | ))) 168 | } 169 | } 170 | } 171 | 172 | async fn secret_value(&self, secret_id: String) -> Result { 173 | let url = format!("{}/secret", self.get_base_url()); 174 | 175 | let mut params = self.get_project_params(); 176 | params.push(("name", &secret_id)); 177 | 178 | let response = self 179 | .client 180 | .get(&url) 181 | .header(self.get_auth_header().0, self.get_auth_header().1) 182 | .query(¶ms) 183 | .send() 184 | .await 185 | .map_err(|e| NysmError::GetSecretValueFailed(format!("Doppler API request failed: {}", e)))?; 186 | 187 | match response.status() { 188 | StatusCode::OK => { 189 | let data: DopplerSecretResponse = response 190 | .json() 191 | .await 192 | .map_err(|e| NysmError::GetSecretValueFailed(format!("Failed to parse response: {}", e)))?; 193 | 194 | Ok(GetSecretValueResult { 195 | secret: data.value.raw, 196 | }) 197 | } 198 | StatusCode::UNAUTHORIZED => Err(NysmError::AuthenticationFailed( 199 | "Invalid Doppler token".to_string(), 200 | )), 201 | StatusCode::NOT_FOUND => Err(NysmError::GetSecretValueFailed(format!( 202 | "Secret '{}' not found in project '{}' config '{}'", 203 | secret_id, self.project, self.config 204 | ))), 205 | status => { 206 | let error_text = response.text().await.unwrap_or_default(); 207 | Err(NysmError::GetSecretValueFailed(format!( 208 | "Doppler API error ({}): {}", 209 | status, error_text 210 | ))) 211 | } 212 | } 213 | } 214 | 215 | async fn update_secret_value( 216 | &self, 217 | secret_id: String, 218 | secret_value: String, 219 | ) -> Result { 220 | let url = format!("{}/secrets", self.get_base_url()); 221 | 222 | let mut secrets = std::collections::HashMap::new(); 223 | secrets.insert(secret_id.clone(), secret_value); 224 | 225 | let request_body = DopplerUpdateSecretRequest { secrets }; 226 | 227 | let response = self 228 | .client 229 | .post(&url) 230 | .header(self.get_auth_header().0, self.get_auth_header().1) 231 | .header("Content-Type", "application/json") 232 | .query(&self.get_project_params()) 233 | .json(&request_body) 234 | .send() 235 | .await 236 | .map_err(|e| NysmError::UpdateSecretFailed(format!("Doppler API request failed: {}", e)))?; 237 | 238 | match response.status() { 239 | StatusCode::OK => { 240 | Ok(UpdateSecretValueResult { 241 | name: Some(secret_id.clone()), 242 | uri: Some(format!("{}/{}?project={}&config={}", 243 | self.get_base_url(), 244 | secret_id, 245 | self.project, 246 | self.config 247 | )), 248 | version_id: None, 249 | }) 250 | } 251 | StatusCode::UNAUTHORIZED => Err(NysmError::AuthenticationFailed( 252 | "Invalid Doppler token".to_string(), 253 | )), 254 | status => { 255 | let error_text = response.text().await.unwrap_or_default(); 256 | Err(NysmError::UpdateSecretFailed(format!( 257 | "Doppler API error ({}): {}", 258 | status, error_text 259 | ))) 260 | } 261 | } 262 | } 263 | 264 | async fn create_secret( 265 | &self, 266 | secret_id: String, 267 | secret_value: String, 268 | _description: Option, 269 | ) -> Result { 270 | let result = self.update_secret_value(secret_id.clone(), secret_value).await?; 271 | 272 | Ok(CreateSecretResult { 273 | name: result.name, 274 | uri: result.uri, 275 | version_id: result.version_id, 276 | }) 277 | } 278 | 279 | async fn delete_secret(&self, secret_id: String) -> Result { 280 | let url = format!("{}/secret", self.get_base_url()); 281 | 282 | let response = self 283 | .client 284 | .delete(&url) 285 | .header(self.get_auth_header().0, self.get_auth_header().1) 286 | .query(&self.get_project_params()) 287 | .json(&json!({ "name": secret_id })) 288 | .send() 289 | .await 290 | .map_err(|e| NysmError::DeleteSecretFailed(format!("Doppler API request failed: {}", e)))?; 291 | 292 | match response.status() { 293 | StatusCode::OK | StatusCode::NO_CONTENT => { 294 | Ok(DeleteSecretResult { 295 | name: Some(secret_id), 296 | uri: None, 297 | deletion_date: None, 298 | }) 299 | } 300 | StatusCode::UNAUTHORIZED => Err(NysmError::AuthenticationFailed( 301 | "Invalid Doppler token".to_string(), 302 | )), 303 | StatusCode::NOT_FOUND => Err(NysmError::DeleteSecretFailed(format!( 304 | "Secret '{}' not found in project '{}' config '{}'", 305 | secret_id, self.project, self.config 306 | ))), 307 | status => { 308 | let error_text = response.text().await.unwrap_or_default(); 309 | Err(NysmError::DeleteSecretFailed(format!( 310 | "Doppler API error ({}): {}", 311 | status, error_text 312 | ))) 313 | } 314 | } 315 | } 316 | } -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | use crate::error::NysmError; 3 | use async_trait::async_trait; 4 | use std::collections::VecDeque; 5 | 6 | /// Represents a response from a secret provider that wraps around 7 | /// the actual secret value held by a secret. 8 | #[derive(Default)] 9 | pub struct GetSecretValueResult { 10 | /// Secret string value associated with Secret. 11 | pub secret: String, 12 | } 13 | 14 | /// Represents an individual secret from a provider when querying 15 | /// for an entire list of secrets. 16 | pub struct Secret { 17 | /// Name of secret 18 | pub name: Option, 19 | /// Uniform resource locator of secret 20 | pub uri: Option, 21 | /// Description of secret 22 | pub description: Option, 23 | } 24 | 25 | /// Represents a response from a secret provider after updating the 26 | /// contents of a secret. 27 | pub struct UpdateSecretValueResult { 28 | /// Name of secret 29 | pub name: Option, 30 | /// Uniform resource locator of secret 31 | pub uri: Option, 32 | /// Version of secret after update operation 33 | pub version_id: Option, 34 | } 35 | 36 | /// Represents a response from a secret provider after creating a new secret. 37 | pub struct CreateSecretResult { 38 | /// Name of secret 39 | pub name: Option, 40 | /// Uniform resource locator of secret 41 | pub uri: Option, 42 | /// Version of secret after create operation 43 | pub version_id: Option, 44 | } 45 | 46 | /// Represents a response from a secret provider after deleting a secret. 47 | pub struct DeleteSecretResult { 48 | /// Name of secret 49 | pub name: Option, 50 | /// Uniform resource locator of secret 51 | pub uri: Option, 52 | /// Date that the secret will be deleted 53 | pub deletion_date: Option, 54 | } 55 | 56 | /// Represents a response from a secret provider that wraps around 57 | /// a list of secrets. 58 | #[derive(Default)] 59 | pub struct ListSecretsResult { 60 | /// Vector of secrets held by [ListSecretsResult] 61 | pub entries: Vec, 62 | } 63 | 64 | impl ListSecretsResult { 65 | /// Creates an iterator from the referenced [ListSecretsResult] 66 | pub fn iter(&self) -> GetSecretsResultIter { 67 | self.into_iter() 68 | } 69 | 70 | /// Returns a tableized string output of [ListSecretsResult] 71 | pub fn table_display(&self) -> String { 72 | let mut builder = tabled::builder::Builder::default(); 73 | builder.push_record(["Name", "Description", "URI"]); 74 | 75 | self.iter().for_each(|secret| { 76 | builder.push_record([ 77 | format!("{:.20}", secret.name.clone().unwrap_or_default()), 78 | format!("{:.20}", secret.description.clone().unwrap_or_default()), 79 | secret.uri.clone().unwrap_or_default().to_string(), 80 | ]); 81 | }); 82 | 83 | let mut table = builder.build(); 84 | table.with(tabled::settings::Style::ascii()); 85 | 86 | table.to_string() 87 | } 88 | } 89 | 90 | impl<'a> IntoIterator for &'a ListSecretsResult { 91 | type Item = &'a Secret; 92 | type IntoIter = GetSecretsResultIter<'a>; 93 | 94 | fn into_iter(self) -> Self::IntoIter { 95 | GetSecretsResultIter { 96 | items: self.entries.iter().collect(), 97 | } 98 | } 99 | } 100 | 101 | /// Iterator for GetSecretsResult 102 | pub struct GetSecretsResultIter<'a> { 103 | items: VecDeque<&'a Secret>, 104 | } 105 | 106 | impl<'a> Iterator for GetSecretsResultIter<'a> { 107 | type Item = &'a Secret; 108 | 109 | fn next(&mut self) -> Option { 110 | self.items.pop_back() 111 | } 112 | } 113 | 114 | /// This trait is designed to be implemented for each secret provider you'd 115 | /// like to interface with. 116 | /// 117 | /// It provides methods to retrieve a list of stored secrets, retrieve a 118 | /// specific secret's value, and to update a specific secret's value. 119 | #[async_trait] 120 | #[cfg(not(tarpaulin_include))] 121 | pub trait QuerySecrets { 122 | /// Indicates whether this provider supports reading secret values. 123 | /// 124 | /// Some providers (like GitHub Actions) only support write operations 125 | /// for security reasons. This method allows the CLI to adapt its 126 | /// behavior accordingly. 127 | /// 128 | /// # Returns 129 | /// Returns `true` if the provider supports reading secret values, 130 | /// `false` if it only supports write operations. 131 | fn supports_read(&self) -> bool { 132 | true 133 | } 134 | 135 | /// Requests a list of secrets from the secret provider. 136 | /// 137 | /// # Returns 138 | /// Returns a result containing either the [ListSecretsResult] struct or an [NysmError]. 139 | async fn secrets_list(&self) -> Result; 140 | 141 | /// Requests a secret value for a specific secret from the secret provider. 142 | /// 143 | /// # Arguments 144 | /// * `secret_id` - String identifier for secret to retrieve value from 145 | /// 146 | /// # Returns 147 | /// Returns a result containing either the [GetSecretValueResult] struct or an [NysmError]. 148 | async fn secret_value(&self, secret_id: String) -> Result; 149 | 150 | /// Requests an update to a secret value for a specific secret from the secret provider. 151 | /// 152 | /// # Arguments 153 | /// * `secret_id` - String identifier for secret to retrieve value from 154 | /// * `secret_value` - String contents to use as new secret value 155 | /// 156 | /// # Returns 157 | /// Returns a result containing either the [UpdateSecretValueResult] struct or an [NysmError]. 158 | async fn update_secret_value( 159 | &self, 160 | secret_id: String, 161 | secret_value: String, 162 | ) -> Result; 163 | 164 | /// Creates a new secret with the specified value and optional description. 165 | /// 166 | /// # Arguments 167 | /// * `secret_id` - String identifier for the new secret 168 | /// * `secret_value` - String contents to use as the secret value 169 | /// * `description` - Optional description for the secret 170 | /// 171 | /// # Returns 172 | /// Returns a result containing either the [CreateSecretResult] struct or an [NysmError]. 173 | async fn create_secret( 174 | &self, 175 | secret_id: String, 176 | secret_value: String, 177 | description: Option, 178 | ) -> Result; 179 | 180 | /// Deletes a secret from the secret provider. 181 | /// 182 | /// # Arguments 183 | /// * `secret_id` - String identifier for the secret to delete 184 | /// 185 | /// # Returns 186 | /// Returns a result containing either the [DeleteSecretResult] struct or an [NysmError]. 187 | async fn delete_secret(&self, secret_id: String) -> Result; 188 | } 189 | 190 | #[cfg(test)] 191 | mod tests { 192 | use super::*; 193 | use async_trait::async_trait; 194 | 195 | struct MockReadableProvider; 196 | struct MockWriteOnlyProvider; 197 | 198 | #[async_trait] 199 | impl QuerySecrets for MockReadableProvider { 200 | async fn secrets_list(&self) -> Result { 201 | Ok(ListSecretsResult::default()) 202 | } 203 | 204 | async fn secret_value(&self, _secret_id: String) -> Result { 205 | Ok(GetSecretValueResult { 206 | secret: "test-value".to_string(), 207 | }) 208 | } 209 | 210 | async fn update_secret_value( 211 | &self, 212 | _secret_id: String, 213 | _secret_value: String, 214 | ) -> Result { 215 | Ok(UpdateSecretValueResult { 216 | name: Some("test".to_string()), 217 | uri: None, 218 | version_id: None, 219 | }) 220 | } 221 | 222 | async fn create_secret( 223 | &self, 224 | _secret_id: String, 225 | _secret_value: String, 226 | _description: Option, 227 | ) -> Result { 228 | Ok(CreateSecretResult { 229 | name: Some("test".to_string()), 230 | uri: None, 231 | version_id: None, 232 | }) 233 | } 234 | 235 | async fn delete_secret(&self, _secret_id: String) -> Result { 236 | Ok(DeleteSecretResult { 237 | name: Some("test".to_string()), 238 | uri: None, 239 | deletion_date: None, 240 | }) 241 | } 242 | } 243 | 244 | #[async_trait] 245 | impl QuerySecrets for MockWriteOnlyProvider { 246 | fn supports_read(&self) -> bool { 247 | false 248 | } 249 | 250 | async fn secrets_list(&self) -> Result { 251 | Ok(ListSecretsResult::default()) 252 | } 253 | 254 | async fn secret_value(&self, _secret_id: String) -> Result { 255 | Err(NysmError::SecretNotReadable) 256 | } 257 | 258 | async fn update_secret_value( 259 | &self, 260 | _secret_id: String, 261 | _secret_value: String, 262 | ) -> Result { 263 | Ok(UpdateSecretValueResult { 264 | name: Some("test".to_string()), 265 | uri: None, 266 | version_id: None, 267 | }) 268 | } 269 | 270 | async fn create_secret( 271 | &self, 272 | _secret_id: String, 273 | _secret_value: String, 274 | _description: Option, 275 | ) -> Result { 276 | Ok(CreateSecretResult { 277 | name: Some("test".to_string()), 278 | uri: None, 279 | version_id: None, 280 | }) 281 | } 282 | 283 | async fn delete_secret(&self, _secret_id: String) -> Result { 284 | Ok(DeleteSecretResult { 285 | name: Some("test".to_string()), 286 | uri: None, 287 | deletion_date: None, 288 | }) 289 | } 290 | } 291 | 292 | #[test] 293 | fn test_default_supports_read_returns_true() { 294 | let provider = MockReadableProvider; 295 | assert!(provider.supports_read()); 296 | } 297 | 298 | #[test] 299 | fn test_write_only_provider_supports_read_returns_false() { 300 | let provider = MockWriteOnlyProvider; 301 | assert!(!provider.supports_read()); 302 | } 303 | 304 | #[tokio::test] 305 | async fn test_write_only_provider_returns_error_on_secret_value() { 306 | let provider = MockWriteOnlyProvider; 307 | let result = provider.secret_value("test-secret".to_string()).await; 308 | assert!(matches!(result, Err(NysmError::SecretNotReadable))); 309 | } 310 | 311 | #[test] 312 | fn test_list_secrets_result_table_display() { 313 | let result = ListSecretsResult { 314 | entries: vec![ 315 | Secret { 316 | name: Some("secret1".to_string()), 317 | description: Some("Description 1".to_string()), 318 | uri: Some("arn:aws:secretsmanager:us-east-1:123456789012:secret:secret1".to_string()), 319 | }, 320 | Secret { 321 | name: Some("very-long-secret-name-that-exceeds-twenty-chars".to_string()), 322 | description: Some( 323 | "Very long description that also exceeds twenty characters".to_string(), 324 | ), 325 | uri: Some("arn:aws:secretsmanager:us-east-1:123456789012:secret:long".to_string()), 326 | }, 327 | ], 328 | }; 329 | 330 | let table = result.table_display(); 331 | assert!(table.contains("Name")); 332 | assert!(table.contains("Description")); 333 | assert!(table.contains("URI")); 334 | assert!(table.contains("secret1")); 335 | assert!(table.contains("Description 1")); 336 | assert!(table.contains("very-long-secret-nam")); 337 | assert!(table.contains("Very long descriptio")); 338 | } 339 | 340 | #[test] 341 | fn test_list_secrets_result_iter() { 342 | let result = ListSecretsResult { 343 | entries: vec![ 344 | Secret { 345 | name: Some("secret1".to_string()), 346 | description: None, 347 | uri: None, 348 | }, 349 | Secret { 350 | name: Some("secret2".to_string()), 351 | description: None, 352 | uri: None, 353 | }, 354 | ], 355 | }; 356 | 357 | let names: Vec = result.iter().filter_map(|s| s.name.clone()).collect(); 358 | 359 | assert_eq!(names.len(), 2); 360 | assert!(names.contains(&"secret1".to_string())); 361 | assert!(names.contains(&"secret2".to_string())); 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # SPDX-License-Identifier: MIT or Apache-2.0 5 | # 6 | # CI that: 7 | # 8 | # * checks for a Git Tag that looks like a release 9 | # * builds artifacts with dist (archives, installers, hashes) 10 | # * uploads those artifacts to temporary workflow zip 11 | # * on success, uploads the artifacts to a GitHub Release 12 | # 13 | # Note that the GitHub Release will be created with a generated 14 | # title/body based on your changelogs. 15 | 16 | name: Release 17 | permissions: 18 | "contents": "write" 19 | 20 | # This task will run whenever you push a git tag that looks like a version 21 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 | # 26 | # If PACKAGE_NAME is specified, then the announcement will be for that 27 | # package (erroring out if it doesn't have the given version or isn't dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (dist-able) packages in the workspace with that version (this mode is 31 | # intended for workspaces with only one dist-able package, or with all dist-able 32 | # packages versioned/released in lockstep). 33 | # 34 | # If you push multiple tags at once, separate instances of this workflow will 35 | # spin up, creating an independent announcement for each one. However, GitHub 36 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 | # mistake. 38 | # 39 | # If there's a prerelease-style suffix to the version, then the release(s) 40 | # will be marked as a prerelease. 41 | on: 42 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-latest" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | submodules: recursive 62 | - name: Install dist 63 | # we specify bash to get pipefail; it guards against the `curl` command 64 | # failing. otherwise `sh` won't catch that `curl` returned non-0 65 | shell: bash 66 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.28.0/cargo-dist-installer.sh | sh" 67 | - name: Cache dist 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: cargo-dist-cache 71 | path: ~/.cargo/bin/dist 72 | # sure would be cool if github gave us proper conditionals... 73 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 74 | # functionality based on whether this is a pull_request, and whether it's from a fork. 75 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 76 | # but also really annoying to build CI around when it needs secrets to work right.) 77 | - id: plan 78 | run: | 79 | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 80 | echo "dist ran successfully" 81 | cat plan-dist-manifest.json 82 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 83 | - name: "Upload dist-manifest.json" 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: artifacts-plan-dist-manifest 87 | path: plan-dist-manifest.json 88 | 89 | # Build and packages all the platform-specific things 90 | build-local-artifacts: 91 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 92 | # Let the initial task tell us to not run (currently very blunt) 93 | needs: 94 | - plan 95 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 96 | strategy: 97 | fail-fast: false 98 | # Target platforms/runners are computed by dist in create-release. 99 | # Each member of the matrix has the following arguments: 100 | # 101 | # - runner: the github runner 102 | # - dist-args: cli flags to pass to dist 103 | # - install-dist: expression to run to install dist on the runner 104 | # 105 | # Typically there will be: 106 | # - 1 "global" task that builds universal installers 107 | # - N "local" tasks that build each platform's binaries and platform-specific installers 108 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 109 | runs-on: ${{ matrix.runner }} 110 | container: ${{ matrix.container && matrix.container.image || null }} 111 | env: 112 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 113 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 114 | steps: 115 | - name: enable windows longpaths 116 | run: | 117 | git config --global core.longpaths true 118 | - uses: actions/checkout@v4 119 | with: 120 | submodules: recursive 121 | - name: Install Rust non-interactively if not already installed 122 | if: ${{ matrix.container }} 123 | run: | 124 | if ! command -v cargo > /dev/null 2>&1; then 125 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 126 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 127 | fi 128 | - name: Install dist 129 | run: ${{ matrix.install_dist.run }} 130 | # Get the dist-manifest 131 | - name: Fetch local artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | pattern: artifacts-* 135 | path: target/distrib/ 136 | merge-multiple: true 137 | - name: Install dependencies 138 | run: | 139 | ${{ matrix.packages_install }} 140 | - name: Build artifacts 141 | run: | 142 | # Actually do builds and make zips and whatnot 143 | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 144 | echo "dist ran successfully" 145 | - id: cargo-dist 146 | name: Post-build 147 | # We force bash here just because github makes it really hard to get values up 148 | # to "real" actions without writing to env-vars, and writing to env-vars has 149 | # inconsistent syntax between shell and powershell. 150 | shell: bash 151 | run: | 152 | # Parse out what we just built and upload it to scratch storage 153 | echo "paths<> "$GITHUB_OUTPUT" 154 | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 155 | echo "EOF" >> "$GITHUB_OUTPUT" 156 | 157 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 158 | - name: "Upload artifacts" 159 | uses: actions/upload-artifact@v4 160 | with: 161 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 162 | path: | 163 | ${{ steps.cargo-dist.outputs.paths }} 164 | ${{ env.BUILD_MANIFEST_NAME }} 165 | 166 | # Build and package all the platform-agnostic(ish) things 167 | build-global-artifacts: 168 | needs: 169 | - plan 170 | - build-local-artifacts 171 | runs-on: "ubuntu-latest" 172 | env: 173 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 174 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 175 | steps: 176 | - uses: actions/checkout@v4 177 | with: 178 | submodules: recursive 179 | - name: Install cached dist 180 | uses: actions/download-artifact@v4 181 | with: 182 | name: cargo-dist-cache 183 | path: ~/.cargo/bin/ 184 | - run: chmod +x ~/.cargo/bin/dist 185 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 186 | - name: Fetch local artifacts 187 | uses: actions/download-artifact@v4 188 | with: 189 | pattern: artifacts-* 190 | path: target/distrib/ 191 | merge-multiple: true 192 | - id: cargo-dist 193 | shell: bash 194 | run: | 195 | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 196 | echo "dist ran successfully" 197 | 198 | # Parse out what we just built and upload it to scratch storage 199 | echo "paths<> "$GITHUB_OUTPUT" 200 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 201 | echo "EOF" >> "$GITHUB_OUTPUT" 202 | 203 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 204 | - name: "Upload artifacts" 205 | uses: actions/upload-artifact@v4 206 | with: 207 | name: artifacts-build-global 208 | path: | 209 | ${{ steps.cargo-dist.outputs.paths }} 210 | ${{ env.BUILD_MANIFEST_NAME }} 211 | # Determines if we should publish/announce 212 | host: 213 | needs: 214 | - plan 215 | - build-local-artifacts 216 | - build-global-artifacts 217 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 218 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 219 | env: 220 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 221 | runs-on: "ubuntu-latest" 222 | outputs: 223 | val: ${{ steps.host.outputs.manifest }} 224 | steps: 225 | - uses: actions/checkout@v4 226 | with: 227 | submodules: recursive 228 | - name: Install cached dist 229 | uses: actions/download-artifact@v4 230 | with: 231 | name: cargo-dist-cache 232 | path: ~/.cargo/bin/ 233 | - run: chmod +x ~/.cargo/bin/dist 234 | # Fetch artifacts from scratch-storage 235 | - name: Fetch artifacts 236 | uses: actions/download-artifact@v4 237 | with: 238 | pattern: artifacts-* 239 | path: target/distrib/ 240 | merge-multiple: true 241 | - id: host 242 | shell: bash 243 | run: | 244 | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 245 | echo "artifacts uploaded and released successfully" 246 | cat dist-manifest.json 247 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 248 | - name: "Upload dist-manifest.json" 249 | uses: actions/upload-artifact@v4 250 | with: 251 | # Overwrite the previous copy 252 | name: artifacts-dist-manifest 253 | path: dist-manifest.json 254 | # Create a GitHub Release while uploading all files to it 255 | - name: "Download GitHub Artifacts" 256 | uses: actions/download-artifact@v4 257 | with: 258 | pattern: artifacts-* 259 | path: artifacts 260 | merge-multiple: true 261 | - name: Cleanup 262 | run: | 263 | # Remove the granular manifests 264 | rm -f artifacts/*-dist-manifest.json 265 | - name: Create GitHub Release 266 | env: 267 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 268 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 269 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 270 | RELEASE_COMMIT: "${{ github.sha }}" 271 | run: | 272 | # Write and read notes from a file to avoid quoting breaking things 273 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 274 | 275 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 276 | 277 | announce: 278 | needs: 279 | - plan 280 | - host 281 | # use "always() && ..." to allow us to wait for all publish jobs while 282 | # still allowing individual publish jobs to skip themselves (for prereleases). 283 | # "host" however must run to completion, no skipping allowed! 284 | if: ${{ always() && needs.host.result == 'success' }} 285 | runs-on: "ubuntu-latest" 286 | env: 287 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 288 | steps: 289 | - uses: actions/checkout@v4 290 | with: 291 | submodules: recursive 292 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | #![deny(missing_docs)] 2 | use crate::client::QuerySecrets; 3 | use crate::error::NysmError; 4 | use bat::PrettyPrinter; 5 | use clap::ValueEnum; 6 | use clap::{Args, Parser, Subcommand}; 7 | use serde::{Deserialize, Serialize}; 8 | use std::io::IsTerminal; 9 | use tempfile::TempDir; 10 | 11 | /// This struct defines the main command line interface for Nysm. 12 | #[derive(Parser)] 13 | #[command(author, version, about, long_about = None)] 14 | #[command(propagate_version = true)] 15 | pub struct ArgumentParser { 16 | /// Which provider to use 17 | #[command(subcommand)] 18 | pub provider: Providers, 19 | } 20 | 21 | /// Available secret providers as subcommands 22 | #[derive(Subcommand, Debug)] 23 | pub enum Providers { 24 | /// AWS Secrets Manager 25 | Aws(AwsCommand), 26 | /// GitHub Actions Secrets 27 | Github(GitHubCommand), 28 | /// Doppler Secrets Management 29 | Doppler(DopplerCommand), 30 | } 31 | 32 | /// AWS provider command and arguments 33 | #[derive(Args, Debug)] 34 | pub struct AwsCommand { 35 | /// AWS region to retrieve secrets from 36 | #[arg(short, long)] 37 | pub region: Option, 38 | /// Which subcommand to use 39 | #[command(subcommand)] 40 | pub command: Commands, 41 | } 42 | 43 | /// GitHub provider command and arguments 44 | #[derive(Args, Debug)] 45 | pub struct GitHubCommand { 46 | /// GitHub personal access token (can also be set via GITHUB_TOKEN env var) 47 | #[arg(long, env = "GITHUB_TOKEN")] 48 | pub token: Option, 49 | /// GitHub repository owner (user or organization) 50 | #[arg(long)] 51 | pub owner: String, 52 | /// GitHub repository name 53 | #[arg(long)] 54 | pub repo: String, 55 | /// Which subcommand to use 56 | #[command(subcommand)] 57 | pub command: Commands, 58 | } 59 | 60 | /// Doppler provider command and arguments 61 | #[derive(Args, Debug)] 62 | pub struct DopplerCommand { 63 | /// Doppler service token (can also be set via DOPPLER_TOKEN env var) 64 | #[arg(long, env = "DOPPLER_TOKEN")] 65 | pub token: Option, 66 | /// Doppler project name 67 | #[arg(long)] 68 | pub project: String, 69 | /// Doppler config/environment name (e.g., "dev", "staging", "prod") 70 | #[arg(long)] 71 | pub config: String, 72 | /// Which subcommand to use 73 | #[command(subcommand)] 74 | pub command: Commands, 75 | } 76 | 77 | /// This enum defines the main command line subcommands for Nysm. 78 | #[derive(Subcommand, PartialEq, Debug)] 79 | pub enum Commands { 80 | /// Retrieve a list of secrets 81 | List(List), 82 | /// Edit the value of a specific secret 83 | Edit(Edit), 84 | /// Show the value of a specific secret 85 | Show(Show), 86 | /// Create a new secret 87 | Create(Create), 88 | /// Delete a secret 89 | Delete(Delete), 90 | } 91 | 92 | /// Retrieve a list of secrets 93 | #[derive(Args, PartialEq, Debug)] 94 | pub struct List {} 95 | 96 | /// Edit the value of a specific secret 97 | #[derive(Args, PartialEq, Debug)] 98 | pub struct Edit { 99 | /// ID of the secret to edit 100 | pub secret_id: String, 101 | #[clap( 102 | value_enum, 103 | short = 'f', 104 | long = "secret-format", 105 | default_value = "json" 106 | )] 107 | /// Format of the secret as stored by the provider 108 | pub secret_format: DataFormat, 109 | /// Format to edit the secret in 110 | #[clap(value_enum, short = 'e', long = "edit-format", default_value = "yaml")] 111 | pub edit_format: DataFormat, 112 | } 113 | 114 | /// Show the value of a specific secret 115 | #[derive(Args, PartialEq, Debug)] 116 | pub struct Show { 117 | /// ID of the secret to edit 118 | pub secret_id: String, 119 | /// Format to print the secret in 120 | #[clap(value_enum, short = 'p', long = "print-format", default_value = "yaml")] 121 | pub print_format: DataFormat, 122 | #[clap( 123 | value_enum, 124 | short = 'f', 125 | long = "secret-format", 126 | default_value = "json" 127 | )] 128 | /// Format of the secret as stored by the provider 129 | pub secret_format: DataFormat, 130 | } 131 | 132 | /// Create a new secret 133 | #[derive(Args, PartialEq, Debug)] 134 | pub struct Create { 135 | /// ID of the secret to create 136 | pub secret_id: String, 137 | /// Description of the secret 138 | #[clap(short = 'd', long = "description")] 139 | pub description: Option, 140 | /// Format of the secret as stored by the provider 141 | #[clap( 142 | value_enum, 143 | short = 'f', 144 | long = "secret-format", 145 | default_value = "json" 146 | )] 147 | pub secret_format: DataFormat, 148 | /// Format to edit the secret in 149 | #[clap(value_enum, short = 'e', long = "edit-format", default_value = "yaml")] 150 | pub edit_format: DataFormat, 151 | } 152 | 153 | /// Delete a secret 154 | #[derive(Args, PartialEq, Debug)] 155 | pub struct Delete { 156 | /// ID of the secret to delete 157 | pub secret_id: String, 158 | } 159 | 160 | /// Enum to describe the different data formats that can be used with Secrets 161 | #[derive(Clone, Debug, Deserialize, Serialize, ValueEnum, PartialEq)] 162 | #[serde(rename_all = "lowercase")] 163 | pub enum DataFormat { 164 | /// Json format 165 | Json, 166 | /// Yaml format 167 | Yaml, 168 | /// Plaintext format 169 | Text, 170 | } 171 | 172 | impl std::fmt::Display for DataFormat { 173 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 174 | std::fmt::Debug::fmt(self, f) 175 | } 176 | } 177 | 178 | impl ArgumentParser { 179 | /// Runs the given subcommand and uses the provided client 180 | /// 181 | /// # Arguments 182 | /// * `client` - Trait object that implements [QuerySecrets] 183 | /// * `command` - The command to execute 184 | /// 185 | #[cfg(not(tarpaulin_include))] 186 | pub async fn run_subcommand(client: Box, command: &Commands) { 187 | let result = match command { 188 | Commands::List(args) => { 189 | let result = list(&*client, args).await; 190 | 191 | match result { 192 | Ok(list) => println!("{}", list), 193 | Err(error) => println!("{}", error), 194 | } 195 | 196 | Ok(()) 197 | } 198 | Commands::Edit(args) => edit(&*client, args).await, 199 | Commands::Show(args) => show(&*client, args).await, 200 | Commands::Create(args) => create(&*client, args).await, 201 | Commands::Delete(args) => delete(&*client, args).await, 202 | }; 203 | 204 | if let Err(error) = result { 205 | println!("{}", error); 206 | } 207 | } 208 | } 209 | 210 | async fn list(client: &dyn QuerySecrets, _args: &List) -> Result { 211 | let secrets_list = client.secrets_list().await?; 212 | 213 | Ok(secrets_list.table_display()) 214 | } 215 | 216 | async fn show(client: &dyn QuerySecrets, args: &Show) -> Result<(), NysmError> { 217 | if !client.supports_read() { 218 | return Err(NysmError::SecretNotReadable); 219 | } 220 | 221 | let secret_value = client.secret_value(args.secret_id.clone()).await?; 222 | 223 | let formatted_secret = reformat_data( 224 | &secret_value.secret, 225 | &args.secret_format, 226 | &args.print_format, 227 | )?; 228 | 229 | let _ = pretty_print(formatted_secret, &args.print_format); 230 | 231 | Ok(()) 232 | } 233 | 234 | async fn edit(client: &dyn QuerySecrets, args: &Edit) -> Result<(), NysmError> { 235 | if client.supports_read() { 236 | let secret_value = client.secret_value(args.secret_id.clone()).await?; 237 | 238 | if let Ok(dir) = temporary_directory() { 239 | let update_contents = launch_editor( 240 | secret_value.secret, 241 | dir, 242 | &args.secret_format, 243 | &args.edit_format, 244 | )?; 245 | 246 | if let Some(contents) = update_contents { 247 | let _ = client 248 | .update_secret_value(args.secret_id.clone(), contents) 249 | .await?; 250 | } 251 | } 252 | } else { 253 | let template = match args.edit_format { 254 | DataFormat::Json => "{}".to_string(), 255 | DataFormat::Yaml => "# Enter new secret value below\n".to_string(), 256 | DataFormat::Text => "".to_string(), 257 | }; 258 | 259 | if let Ok(dir) = temporary_directory() { 260 | let update_contents = 261 | launch_editor(template.clone(), dir, &args.edit_format, &args.edit_format)?; 262 | 263 | if let Some(contents) = update_contents { 264 | if contents == template { 265 | println!("No changes made, skipping update."); 266 | } else { 267 | println!("Warning: This will completely replace the existing secret."); 268 | let formatted_contents = 269 | reformat_data(&contents, &args.edit_format, &args.secret_format)?; 270 | let _ = client 271 | .update_secret_value(args.secret_id.clone(), formatted_contents) 272 | .await?; 273 | } 274 | } 275 | } 276 | } 277 | 278 | Ok(()) 279 | } 280 | 281 | async fn create(client: &dyn QuerySecrets, args: &Create) -> Result<(), NysmError> { 282 | if let Ok(dir) = temporary_directory() { 283 | let initial_content = match args.edit_format { 284 | DataFormat::Json => "{}".to_string(), 285 | DataFormat::Yaml => "".to_string(), 286 | DataFormat::Text => "".to_string(), 287 | }; 288 | 289 | let secret_contents = 290 | launch_editor(initial_content, dir, &args.edit_format, &args.edit_format)?; 291 | 292 | if let Some(contents) = secret_contents { 293 | let formatted_contents = reformat_data(&contents, &args.edit_format, &args.secret_format)?; 294 | let _ = client 295 | .create_secret( 296 | args.secret_id.clone(), 297 | formatted_contents, 298 | args.description.clone(), 299 | ) 300 | .await?; 301 | } 302 | } 303 | 304 | Ok(()) 305 | } 306 | 307 | async fn delete(client: &dyn QuerySecrets, args: &Delete) -> Result<(), NysmError> { 308 | let _ = client.delete_secret(args.secret_id.clone()).await?; 309 | 310 | Ok(()) 311 | } 312 | 313 | fn strip_trailing_whitespace_from_block_scalars(content: &str) -> String { 314 | if content.contains(": |") { 315 | content 316 | .lines() 317 | .map(|line| line.trim_end()) 318 | .collect::>() 319 | .join("\n") 320 | } else { 321 | content.to_string() 322 | } 323 | } 324 | 325 | fn reformat_data( 326 | content: &str, 327 | source_format: &DataFormat, 328 | destination_format: &DataFormat, 329 | ) -> Result { 330 | Ok(match source_format { 331 | DataFormat::Json => { 332 | let json_value: serde_json::Value = serde_json::from_str(content)?; 333 | 334 | match destination_format { 335 | DataFormat::Json => serde_json::to_string_pretty(&json_value)?, 336 | DataFormat::Yaml => serde_yml::to_string(&json_value)?, 337 | DataFormat::Text => String::from(content), 338 | } 339 | } 340 | DataFormat::Yaml => match destination_format { 341 | DataFormat::Yaml => { 342 | serde_yml::from_str::(content)?; 343 | String::from(content) 344 | } 345 | DataFormat::Json => { 346 | let cleaned_content = strip_trailing_whitespace_from_block_scalars(content); 347 | let yaml_value: serde_yml::Value = serde_yml::from_str(&cleaned_content)?; 348 | serde_json::to_string_pretty(&yaml_value)? 349 | } 350 | DataFormat::Text => String::from(content), 351 | }, 352 | DataFormat::Text => String::from(content), 353 | }) 354 | } 355 | 356 | /// Pretty prints a string with bat. 357 | /// 358 | /// # Arguments 359 | /// * `content` - String to be pretty printed 360 | /// * `print_format` - Format to print the string as 361 | /// 362 | /// # Returns 363 | /// Returns a result with either an empty tuple or a NysmError. This can error if 364 | /// bat has trouble printing in the specified format. 365 | #[cfg(not(tarpaulin_include))] 366 | fn pretty_print(content: String, print_format: &DataFormat) -> Result<(), NysmError> { 367 | if std::io::stdout().is_terminal() { 368 | let language_string = print_format.to_string(); 369 | let mut printer = PrettyPrinter::new(); 370 | let _printer = match print_format { 371 | DataFormat::Yaml | DataFormat::Json => printer.language(&language_string), 372 | _ => &mut printer, 373 | }; 374 | 375 | #[allow(unused)] 376 | #[cfg(not(test))] 377 | let _ = _printer 378 | .grid(true) 379 | .line_numbers(true) 380 | .paging_mode(bat::PagingMode::QuitIfOneScreen) 381 | .pager("less") 382 | .theme("OneHalfDark") 383 | .input_from_bytes(content.as_bytes()) 384 | .print()?; 385 | } else { 386 | println!("{}", content); 387 | } 388 | 389 | Ok(()) 390 | } 391 | 392 | /// This method is designed to open up an editor with contents from a secret. 393 | /// 394 | /// # Arguments 395 | /// * `contents` - String contents to open up in an editor 396 | /// * `path` - Temporary directory to save the contents of the file to when editing a secret 397 | /// * `secret_format` - Format of the secret as given by the secret provider 398 | /// * `edit_format` - Format of the secret to use while editing the secret in an editor 399 | /// 400 | /// # Returns 401 | /// Returns a result containing the changes to the contents originally passed into the method. 402 | /// Can error if any IO operation fails (read/write of the temporary file). 403 | /// 404 | fn launch_editor

( 405 | contents: String, 406 | path: P, 407 | secret_format: &DataFormat, 408 | edit_format: &DataFormat, 409 | ) -> Result, NysmError> 410 | where 411 | P: AsRef, 412 | { 413 | let language_string = edit_format.to_string().to_lowercase(); 414 | let file_path = path.as_ref().join("data").with_extension(language_string); 415 | 416 | let file_contents = reformat_data(&contents, secret_format, edit_format)?; 417 | std::fs::write(&file_path, file_contents)?; 418 | 419 | let mut editor = match std::env::var("EDITOR") { 420 | Ok(editor) => editor, 421 | Err(_) => String::from("vim"), 422 | }; 423 | 424 | editor.push(' '); 425 | editor.push_str(&file_path.to_string_lossy()); 426 | 427 | #[cfg(test)] 428 | editor.insert_str(0, "vim(){ :; }; "); 429 | 430 | std::process::Command::new("/usr/bin/env") 431 | .arg("sh") 432 | .arg("-c") 433 | .arg(editor) 434 | .spawn() 435 | .expect("Error: Failed to run editor") 436 | .wait() 437 | .expect("Error: Editor returned a non-zero status"); 438 | 439 | let file_contents: String = std::fs::read_to_string(file_path)?; 440 | let json_data = reformat_data(&file_contents, edit_format, secret_format)?; 441 | 442 | if json_data.eq(&contents) { 443 | println!("It seems the file hasn't changed, not persisting changes."); 444 | 445 | Ok(None) 446 | } else { 447 | Ok(Some(json_data)) 448 | } 449 | } 450 | 451 | fn temporary_directory() -> std::io::Result { 452 | TempDir::new() 453 | } 454 | 455 | #[cfg(test)] 456 | mod tests { 457 | use super::*; 458 | use futures::FutureExt; 459 | use lazy_static::lazy_static; 460 | use serde_json::json; 461 | use std::env::VarError; 462 | use std::future::Future; 463 | use std::panic::AssertUnwindSafe; 464 | use std::panic::{RefUnwindSafe, UnwindSafe}; 465 | use std::{env, panic}; 466 | 467 | lazy_static! { 468 | static ref SERIAL_TEST: tokio::sync::Mutex<()> = Default::default(); 469 | } 470 | 471 | /// Sets environment variables to the given value for the duration of the closure. 472 | /// Restores the previous values when the closure completes or panics, before unwinding the panic. 473 | pub async fn async_with_env_vars(kvs: Vec<(&str, Option<&str>)>, closure: F) 474 | where 475 | F: Future + UnwindSafe + RefUnwindSafe, 476 | { 477 | let guard = SERIAL_TEST.lock().await; 478 | let mut old_kvs: Vec<(&str, Result)> = Vec::new(); 479 | 480 | for (k, v) in kvs { 481 | let old_v = env::var(k); 482 | old_kvs.push((k, old_v)); 483 | match v { 484 | None => unsafe { env::remove_var(k) }, 485 | Some(v) => unsafe { env::set_var(k, v) }, 486 | } 487 | } 488 | 489 | match closure.catch_unwind().await { 490 | Ok(_) => { 491 | for (k, v) in old_kvs { 492 | reset_env(k, v); 493 | } 494 | } 495 | Err(err) => { 496 | for (k, v) in old_kvs { 497 | reset_env(k, v); 498 | } 499 | drop(guard); 500 | panic::resume_unwind(err); 501 | } 502 | } 503 | } 504 | 505 | fn reset_env(k: &str, old: Result) { 506 | if let Ok(v) = old { 507 | unsafe { env::set_var(k, v) }; 508 | } else { 509 | unsafe { env::remove_var(k) }; 510 | } 511 | } 512 | 513 | type TestResult = Result<(), Box>; 514 | 515 | mod reformat_data { 516 | use super::*; 517 | 518 | #[test] 519 | fn from_json_to_yaml() -> TestResult { 520 | let data = r#"{"banana": true, "apple": false}"#; 521 | let expected = "apple: false\nbanana: true\n"; 522 | 523 | let result = reformat_data(data, &DataFormat::Json, &DataFormat::Yaml)?; 524 | 525 | assert_eq!(expected, result); 526 | 527 | Ok(()) 528 | } 529 | 530 | #[test] 531 | fn from_json_to_json() -> TestResult { 532 | let data = r#"{"banana": true, "apple": false}"#; 533 | let json_value = json!({ 534 | "apple": false, 535 | "banana": true, 536 | }); 537 | let expected = serde_json::to_string_pretty(&json_value)?; 538 | 539 | let result = reformat_data(data, &DataFormat::Json, &DataFormat::Json)?; 540 | 541 | assert_eq!(expected, result); 542 | 543 | Ok(()) 544 | } 545 | 546 | #[test] 547 | fn from_json_to_text() -> TestResult { 548 | let data = r#"{"apple":false,"banana":true}"#; 549 | let expected = json!({ 550 | "apple": false, 551 | "banana": true, 552 | }) 553 | .to_string(); 554 | 555 | let result = reformat_data(data, &DataFormat::Json, &DataFormat::Text)?; 556 | 557 | assert_eq!(expected, result); 558 | 559 | Ok(()) 560 | } 561 | 562 | #[test] 563 | fn from_yaml_to_json() -> TestResult { 564 | let yaml_string = r#"apple: false 565 | banana: true 566 | "#; 567 | let json_value = json!({ 568 | "apple": false, 569 | "banana": true, 570 | }); 571 | let expected = serde_json::to_string_pretty(&json_value)?; 572 | 573 | let result = reformat_data(yaml_string, &DataFormat::Yaml, &DataFormat::Json)?; 574 | 575 | assert_eq!(expected, result); 576 | 577 | Ok(()) 578 | } 579 | 580 | #[test] 581 | fn from_yaml_to_yaml() -> TestResult { 582 | let yaml_string = r#"apple: false 583 | banana: true 584 | "#; 585 | let expected = "apple: false\nbanana: true\n"; 586 | 587 | let result = reformat_data(yaml_string, &DataFormat::Yaml, &DataFormat::Yaml)?; 588 | 589 | assert_eq!(expected, result); 590 | 591 | Ok(()) 592 | } 593 | 594 | #[test] 595 | fn from_yaml_to_text() -> TestResult { 596 | let yaml_string = r#"apple: false 597 | banana: true 598 | "#; 599 | let expected = "apple: false\nbanana: true\n"; 600 | 601 | let result = reformat_data(yaml_string, &DataFormat::Yaml, &DataFormat::Text)?; 602 | 603 | assert_eq!(expected, result); 604 | 605 | Ok(()) 606 | } 607 | 608 | #[test] 609 | fn from_yaml_with_trailing_whitespace_to_json() -> TestResult { 610 | let yaml_string = "application.yml: |-\n banana: false \n apple: true\n flasdjfljasdlfjalsd: alsdkjflasjdflajdslf\n"; 611 | 612 | let result = reformat_data(yaml_string, &DataFormat::Yaml, &DataFormat::Json)?; 613 | 614 | assert!(!result.contains("false \\n")); 615 | assert!(result.contains("false\\n")); 616 | 617 | Ok(()) 618 | } 619 | 620 | #[test] 621 | fn from_text() -> TestResult { 622 | let text = "This is a plain string with no data structure."; 623 | let expected = "This is a plain string with no data structure."; 624 | 625 | let result = reformat_data(text, &DataFormat::Text, &DataFormat::Text)?; 626 | 627 | assert_eq!(expected, result); 628 | 629 | Ok(()) 630 | } 631 | } 632 | 633 | #[test] 634 | fn data_format_display() -> TestResult { 635 | assert_eq!(format!("{}", DataFormat::Json), "Json"); 636 | assert_eq!(format!("{}", DataFormat::Yaml), "Yaml"); 637 | assert_eq!(format!("{}", DataFormat::Text), "Text"); 638 | 639 | Ok(()) 640 | } 641 | 642 | #[test] 643 | fn test_yaml_with_mixed_whitespace_fixture() -> TestResult { 644 | let fixture_path = "tests/fixtures/mixed_whitespace.yml"; 645 | let problematic_yaml = 646 | std::fs::read_to_string(fixture_path).expect("Failed to read fixture file"); 647 | 648 | let result = reformat_data(&problematic_yaml, &DataFormat::Yaml, &DataFormat::Json)?; 649 | 650 | assert!(result.contains("application.yml")); 651 | assert!(result.contains("banana: false")); 652 | assert!(!result.contains("false \\n")); 653 | 654 | Ok(()) 655 | } 656 | 657 | mod argument_parsing { 658 | use super::*; 659 | 660 | #[test] 661 | fn aws_accepts_region() -> TestResult { 662 | let args = "nysm aws -r us-west-2 list".split_whitespace(); 663 | let arg_parser = ArgumentParser::try_parse_from(args)?; 664 | 665 | match &arg_parser.provider { 666 | Providers::Aws(aws) => { 667 | assert_eq!(aws.region, Some("us-west-2".to_string())); 668 | assert!(matches!(aws.command, Commands::List(_))); 669 | } 670 | _ => panic!("Expected AWS provider"), 671 | } 672 | 673 | Ok(()) 674 | } 675 | 676 | #[test] 677 | fn aws_sets_list_subcommand() -> TestResult { 678 | let args = "nysm aws list".split_whitespace(); 679 | let arg_parser = ArgumentParser::try_parse_from(args)?; 680 | 681 | match &arg_parser.provider { 682 | Providers::Aws(aws) => { 683 | assert_eq!(aws.command, Commands::List(List {})); 684 | } 685 | _ => panic!("Expected AWS provider"), 686 | } 687 | 688 | Ok(()) 689 | } 690 | 691 | #[test] 692 | fn aws_sets_show_subcommand() -> TestResult { 693 | let args = "nysm aws show testing-secrets".split_whitespace(); 694 | let arg_parser = ArgumentParser::try_parse_from(args)?; 695 | 696 | match &arg_parser.provider { 697 | Providers::Aws(aws) => { 698 | assert_eq!( 699 | aws.command, 700 | Commands::Show(Show { 701 | secret_id: "testing-secrets".into(), 702 | print_format: DataFormat::Yaml, 703 | secret_format: DataFormat::Json, 704 | }) 705 | ); 706 | } 707 | _ => panic!("Expected AWS provider"), 708 | } 709 | 710 | Ok(()) 711 | } 712 | 713 | #[test] 714 | fn aws_sets_edit_subcommand() -> TestResult { 715 | let args = "nysm aws edit testing-secrets".split_whitespace(); 716 | let arg_parser = ArgumentParser::try_parse_from(args)?; 717 | 718 | match &arg_parser.provider { 719 | Providers::Aws(aws) => { 720 | assert_eq!( 721 | aws.command, 722 | Commands::Edit(Edit { 723 | secret_id: "testing-secrets".into(), 724 | edit_format: DataFormat::Yaml, 725 | secret_format: DataFormat::Json, 726 | }) 727 | ); 728 | } 729 | _ => panic!("Expected AWS provider"), 730 | } 731 | 732 | Ok(()) 733 | } 734 | 735 | #[test] 736 | fn aws_sets_create_subcommand() -> TestResult { 737 | let args = "nysm aws create new-secret".split_whitespace(); 738 | let arg_parser = ArgumentParser::try_parse_from(args)?; 739 | 740 | match &arg_parser.provider { 741 | Providers::Aws(aws) => { 742 | assert_eq!( 743 | aws.command, 744 | Commands::Create(Create { 745 | secret_id: "new-secret".into(), 746 | description: None, 747 | edit_format: DataFormat::Yaml, 748 | secret_format: DataFormat::Json, 749 | }) 750 | ); 751 | } 752 | _ => panic!("Expected AWS provider"), 753 | } 754 | 755 | Ok(()) 756 | } 757 | 758 | #[test] 759 | fn aws_sets_create_subcommand_with_description() -> TestResult { 760 | let args = vec![ 761 | "nysm", 762 | "aws", 763 | "create", 764 | "new-secret", 765 | "-d", 766 | "Test secret", 767 | ]; 768 | let arg_parser = ArgumentParser::try_parse_from(args)?; 769 | 770 | match &arg_parser.provider { 771 | Providers::Aws(aws) => { 772 | assert_eq!( 773 | aws.command, 774 | Commands::Create(Create { 775 | secret_id: "new-secret".into(), 776 | description: Some("Test secret".into()), 777 | edit_format: DataFormat::Yaml, 778 | secret_format: DataFormat::Json, 779 | }) 780 | ); 781 | } 782 | _ => panic!("Expected AWS provider"), 783 | } 784 | 785 | Ok(()) 786 | } 787 | 788 | #[test] 789 | fn aws_sets_delete_subcommand() -> TestResult { 790 | let args = "nysm aws delete test-secret".split_whitespace(); 791 | let arg_parser = ArgumentParser::try_parse_from(args)?; 792 | 793 | match &arg_parser.provider { 794 | Providers::Aws(aws) => { 795 | assert_eq!( 796 | aws.command, 797 | Commands::Delete(Delete { 798 | secret_id: "test-secret".into(), 799 | }) 800 | ); 801 | } 802 | _ => panic!("Expected AWS provider"), 803 | } 804 | 805 | Ok(()) 806 | } 807 | 808 | #[test] 809 | fn github_accepts_all_options() -> TestResult { 810 | let args = vec![ 811 | "nysm", 812 | "github", 813 | "--token", 814 | "ghp_123456", 815 | "--owner", 816 | "myorg", 817 | "--repo", 818 | "myrepo", 819 | "list", 820 | ]; 821 | let arg_parser = ArgumentParser::try_parse_from(args)?; 822 | 823 | match &arg_parser.provider { 824 | Providers::Github(github) => { 825 | assert_eq!(github.token, Some("ghp_123456".to_string())); 826 | assert_eq!(github.owner, "myorg"); 827 | assert_eq!(github.repo, "myrepo"); 828 | assert!(matches!(github.command, Commands::List(_))); 829 | } 830 | _ => panic!("Expected GitHub provider"), 831 | } 832 | 833 | Ok(()) 834 | } 835 | 836 | #[test] 837 | fn github_requires_owner_and_repo() -> TestResult { 838 | let args = vec!["nysm", "github", "list"]; 839 | let result = ArgumentParser::try_parse_from(args); 840 | 841 | assert!(result.is_err()); 842 | 843 | Ok(()) 844 | } 845 | 846 | #[test] 847 | fn doppler_accepts_all_options() -> TestResult { 848 | let args = vec![ 849 | "nysm", 850 | "doppler", 851 | "--token", 852 | "dp.st.123456", 853 | "--project", 854 | "myproject", 855 | "--config", 856 | "production", 857 | "list", 858 | ]; 859 | let arg_parser = ArgumentParser::try_parse_from(args)?; 860 | 861 | match &arg_parser.provider { 862 | Providers::Doppler(doppler) => { 863 | assert_eq!(doppler.token, Some("dp.st.123456".to_string())); 864 | assert_eq!(doppler.project, "myproject"); 865 | assert_eq!(doppler.config, "production"); 866 | assert!(matches!(doppler.command, Commands::List(_))); 867 | } 868 | _ => panic!("Expected Doppler provider"), 869 | } 870 | 871 | Ok(()) 872 | } 873 | 874 | #[test] 875 | fn doppler_requires_project_and_config() -> TestResult { 876 | let args = vec!["nysm", "doppler", "list"]; 877 | let result = ArgumentParser::try_parse_from(args); 878 | 879 | assert!(result.is_err()); 880 | 881 | Ok(()) 882 | } 883 | } 884 | 885 | #[allow(clippy::field_reassign_with_default)] 886 | mod client { 887 | use super::*; 888 | use crate::client::{ 889 | CreateSecretResult, DeleteSecretResult, GetSecretValueResult, ListSecretsResult, Secret, 890 | UpdateSecretValueResult, 891 | }; 892 | use async_trait::async_trait; 893 | 894 | pub struct TestClient { 895 | fails_on_list_secrets: bool, 896 | fails_on_get_secret_value: bool, 897 | fails_on_update_secret_value: bool, 898 | fails_on_create_secret: bool, 899 | fails_on_delete_secret: bool, 900 | is_write_only: bool, 901 | on_create_secret: Option>, 902 | on_update_secret: Option>, 903 | on_delete_secret: Option>, 904 | } 905 | 906 | impl Default for TestClient { 907 | fn default() -> Self { 908 | Self { 909 | fails_on_list_secrets: false, 910 | fails_on_get_secret_value: false, 911 | fails_on_update_secret_value: false, 912 | fails_on_create_secret: false, 913 | fails_on_delete_secret: false, 914 | is_write_only: false, 915 | on_create_secret: None, 916 | on_update_secret: None, 917 | on_delete_secret: None, 918 | } 919 | } 920 | } 921 | 922 | #[async_trait] 923 | impl QuerySecrets for TestClient { 924 | fn supports_read(&self) -> bool { 925 | !self.is_write_only 926 | } 927 | 928 | async fn secrets_list(&self) -> Result { 929 | if self.fails_on_list_secrets { 930 | return Err(NysmError::ListSecretsFailed("Test error".to_string())); 931 | } 932 | 933 | Ok(ListSecretsResult { 934 | entries: vec![Secret { 935 | name: Some("secret-one".into()), 936 | uri: Some("some-unique-id-one".into()), 937 | description: Some("blah blah blah".into()), 938 | }], 939 | }) 940 | } 941 | 942 | async fn secret_value(&self, _secret_id: String) -> Result { 943 | if self.is_write_only { 944 | return Err(NysmError::SecretNotReadable); 945 | } 946 | 947 | if self.fails_on_get_secret_value { 948 | return Err(NysmError::GetSecretValueFailed("Test error".to_string())); 949 | } 950 | 951 | let secret_value = json!({ 952 | "apple": true, 953 | "banana": false, 954 | }); 955 | 956 | let secret_value = serde_json::to_string_pretty(&secret_value)?; 957 | 958 | Ok(GetSecretValueResult { 959 | secret: secret_value, 960 | }) 961 | } 962 | 963 | async fn update_secret_value( 964 | &self, 965 | _secret_id: String, 966 | secret_value: String, 967 | ) -> Result { 968 | if self.fails_on_update_secret_value { 969 | return Err(NysmError::UpdateSecretFailed("Test error".to_string())); 970 | } 971 | 972 | if let Some(callback) = &self.on_update_secret { 973 | callback(&secret_value); 974 | } 975 | 976 | Ok(UpdateSecretValueResult { 977 | name: Some("testy-test-secret".into()), 978 | uri: Some("some-unique-id".into()), 979 | version_id: Some("definitely-a-new-version-id".into()), 980 | }) 981 | } 982 | 983 | async fn create_secret( 984 | &self, 985 | _secret_id: String, 986 | secret_value: String, 987 | _description: Option, 988 | ) -> Result { 989 | if self.fails_on_create_secret { 990 | return Err(NysmError::CreateSecretFailed("Test error".to_string())); 991 | } 992 | 993 | if let Some(callback) = &self.on_create_secret { 994 | callback(&secret_value); 995 | } 996 | 997 | Ok(CreateSecretResult { 998 | name: Some("new-test-secret".into()), 999 | uri: Some("some-new-unique-id".into()), 1000 | version_id: Some("new-secret-version-id".into()), 1001 | }) 1002 | } 1003 | 1004 | async fn delete_secret(&self, secret_id: String) -> Result { 1005 | if self.fails_on_delete_secret { 1006 | return Err(NysmError::DeleteSecretFailed("Test error".to_string())); 1007 | } 1008 | 1009 | if let Some(callback) = &self.on_delete_secret { 1010 | callback(&secret_id); 1011 | } 1012 | 1013 | Ok(DeleteSecretResult { 1014 | name: Some("deleted-secret".into()), 1015 | uri: Some("some-deleted-unique-id".into()), 1016 | deletion_date: Some("2024-01-01".into()), 1017 | }) 1018 | } 1019 | } 1020 | 1021 | mod list_output { 1022 | use super::*; 1023 | 1024 | #[tokio::test] 1025 | async fn error_when_api_list_call_fails() -> TestResult { 1026 | let mut client = TestClient::default(); 1027 | client.fails_on_list_secrets = true; 1028 | 1029 | let result = list(&client, &List {}).await; 1030 | 1031 | assert_eq!( 1032 | result, 1033 | Err(NysmError::ListSecretsFailed("Test error".to_string())) 1034 | ); 1035 | 1036 | Ok(()) 1037 | } 1038 | 1039 | #[tokio::test] 1040 | async fn ok_when_list_api_call_succeeds() -> TestResult { 1041 | let client = TestClient::default(); 1042 | 1043 | let result = list(&client, &List {}).await; 1044 | 1045 | assert!(result.is_ok()); 1046 | 1047 | Ok(()) 1048 | } 1049 | } 1050 | 1051 | mod show_output { 1052 | use super::*; 1053 | 1054 | #[tokio::test] 1055 | async fn error_when_api_show_call_fails() -> TestResult { 1056 | let mut client = TestClient::default(); 1057 | client.fails_on_get_secret_value = true; 1058 | 1059 | let result = show( 1060 | &client, 1061 | &Show { 1062 | secret_id: "fake".into(), 1063 | print_format: DataFormat::Json, 1064 | secret_format: DataFormat::Json, 1065 | }, 1066 | ) 1067 | .await; 1068 | 1069 | assert_eq!( 1070 | result, 1071 | Err(NysmError::GetSecretValueFailed("Test error".to_string())) 1072 | ); 1073 | 1074 | Ok(()) 1075 | } 1076 | 1077 | #[tokio::test] 1078 | async fn ok_when_api_show_call_succeeds() -> TestResult { 1079 | let client = TestClient::default(); 1080 | 1081 | let result = show( 1082 | &client, 1083 | &Show { 1084 | secret_id: "fake".into(), 1085 | print_format: DataFormat::Json, 1086 | secret_format: DataFormat::Json, 1087 | }, 1088 | ) 1089 | .await; 1090 | 1091 | assert!(result.is_ok()); 1092 | 1093 | Ok(()) 1094 | } 1095 | 1096 | #[tokio::test] 1097 | async fn error_when_provider_does_not_support_read() -> TestResult { 1098 | let mut client = TestClient::default(); 1099 | client.is_write_only = true; 1100 | 1101 | let result = show( 1102 | &client, 1103 | &Show { 1104 | secret_id: "write-only-secret".into(), 1105 | print_format: DataFormat::Yaml, 1106 | secret_format: DataFormat::Json, 1107 | }, 1108 | ) 1109 | .await; 1110 | 1111 | assert_eq!(result, Err(NysmError::SecretNotReadable)); 1112 | 1113 | Ok(()) 1114 | } 1115 | } 1116 | 1117 | mod edit_output { 1118 | use super::*; 1119 | 1120 | #[tokio::test] 1121 | async fn error_when_api_update_call_fails() -> TestResult { 1122 | async_with_env_vars( 1123 | vec![("EDITOR", Some("echo 'another: true\n' >> "))], 1124 | AssertUnwindSafe(async { 1125 | let mut client = TestClient::default(); 1126 | client.fails_on_update_secret_value = true; 1127 | 1128 | let result = edit( 1129 | &client, 1130 | &Edit { 1131 | secret_id: "fake".into(), 1132 | edit_format: DataFormat::Yaml, 1133 | secret_format: DataFormat::Json, 1134 | }, 1135 | ) 1136 | .await; 1137 | 1138 | assert_eq!( 1139 | result, 1140 | Err(NysmError::UpdateSecretFailed("Test error".to_string())) 1141 | ); 1142 | }), 1143 | ) 1144 | .await; 1145 | 1146 | Ok(()) 1147 | } 1148 | 1149 | #[tokio::test] 1150 | async fn json_error_when_api_update_call_fails_due_to_syntax() -> TestResult { 1151 | async_with_env_vars( 1152 | vec![("EDITOR", Some("echo 'another: true\n' >> "))], 1153 | AssertUnwindSafe(async { 1154 | let client = TestClient::default(); 1155 | 1156 | let result = edit( 1157 | &client, 1158 | &Edit { 1159 | secret_id: "fake".into(), 1160 | edit_format: DataFormat::Json, 1161 | secret_format: DataFormat::Json, 1162 | }, 1163 | ) 1164 | .await; 1165 | 1166 | assert_eq!( 1167 | result, 1168 | Err(NysmError::SerdeJson( 1169 | serde_json::from_str::(";;;").unwrap_err() 1170 | )) 1171 | ); 1172 | }), 1173 | ) 1174 | .await; 1175 | 1176 | Ok(()) 1177 | } 1178 | 1179 | #[tokio::test] 1180 | async fn yaml_error_when_api_update_call_fails_due_to_syntax() -> TestResult { 1181 | async_with_env_vars( 1182 | vec![("EDITOR", Some("echo '@invalid_yaml' >> "))], 1183 | AssertUnwindSafe(async { 1184 | let client = TestClient::default(); 1185 | 1186 | let result = edit( 1187 | &client, 1188 | &Edit { 1189 | secret_id: "fake".into(), 1190 | edit_format: DataFormat::Yaml, 1191 | secret_format: DataFormat::Yaml, 1192 | }, 1193 | ) 1194 | .await; 1195 | 1196 | assert_eq!( 1197 | result, 1198 | Err(NysmError::SerdeYaml( 1199 | serde_yml::from_str::("::::").unwrap_err() 1200 | )) 1201 | ); 1202 | }), 1203 | ) 1204 | .await; 1205 | 1206 | Ok(()) 1207 | } 1208 | 1209 | #[tokio::test] 1210 | async fn error_when_api_get_call_fails() -> TestResult { 1211 | async_with_env_vars( 1212 | vec![("EDITOR", Some("echo >/dev/null 2>&1 <<<"))], 1213 | AssertUnwindSafe(async { 1214 | let mut client = TestClient::default(); 1215 | client.fails_on_get_secret_value = true; 1216 | 1217 | let result = edit( 1218 | &client, 1219 | &Edit { 1220 | secret_id: "fake".into(), 1221 | edit_format: DataFormat::Json, 1222 | secret_format: DataFormat::Json, 1223 | }, 1224 | ) 1225 | .await; 1226 | 1227 | assert_eq!( 1228 | result, 1229 | Err(NysmError::GetSecretValueFailed("Test error".to_string())) 1230 | ); 1231 | }), 1232 | ) 1233 | .await; 1234 | 1235 | Ok(()) 1236 | } 1237 | 1238 | #[tokio::test] 1239 | async fn ok_when_api_get_calls_succeed() -> TestResult { 1240 | async_with_env_vars( 1241 | vec![("EDITOR", Some("echo >/dev/null 2>&1 <<<"))], 1242 | AssertUnwindSafe(async { 1243 | let client = TestClient::default(); 1244 | 1245 | let result = edit( 1246 | &client, 1247 | &Edit { 1248 | secret_id: "fake".into(), 1249 | edit_format: DataFormat::Json, 1250 | secret_format: DataFormat::Json, 1251 | }, 1252 | ) 1253 | .await; 1254 | 1255 | assert!(result.is_ok()); 1256 | }), 1257 | ) 1258 | .await; 1259 | 1260 | Ok(()) 1261 | } 1262 | 1263 | #[tokio::test] 1264 | async fn ok_when_no_editor_environment_variable() -> TestResult { 1265 | async_with_env_vars( 1266 | vec![("EDITOR", None)], 1267 | AssertUnwindSafe(async { 1268 | let client = TestClient::default(); 1269 | 1270 | let result = edit( 1271 | &client, 1272 | &Edit { 1273 | secret_id: "fake".into(), 1274 | edit_format: DataFormat::Json, 1275 | secret_format: DataFormat::Json, 1276 | }, 1277 | ) 1278 | .await; 1279 | 1280 | assert!(result.is_ok()); 1281 | }), 1282 | ) 1283 | .await; 1284 | 1285 | Ok(()) 1286 | } 1287 | 1288 | #[tokio::test] 1289 | async fn ok_when_api_get_calls_succeed_and_no_change() -> TestResult { 1290 | async_with_env_vars( 1291 | vec![("EDITOR", Some("echo >/dev/null 2>&1 <<<"))], 1292 | AssertUnwindSafe(async { 1293 | let client = TestClient::default(); 1294 | 1295 | let result = edit( 1296 | &client, 1297 | &Edit { 1298 | secret_id: "secret-one".into(), 1299 | edit_format: DataFormat::Json, 1300 | secret_format: DataFormat::Json, 1301 | }, 1302 | ) 1303 | .await; 1304 | 1305 | assert!(result.is_ok()); 1306 | }), 1307 | ) 1308 | .await; 1309 | 1310 | Ok(()) 1311 | } 1312 | 1313 | #[tokio::test] 1314 | async fn uses_correct_formats_for_editing() -> TestResult { 1315 | async_with_env_vars( 1316 | vec![("EDITOR", Some("echo 'updated_key: yaml_value\n' > "))], 1317 | AssertUnwindSafe(async { 1318 | let mut client = TestClient::default(); 1319 | 1320 | client.on_update_secret = Some(Box::new(|secret_string| { 1321 | let parsed: serde_json::Value = 1322 | serde_json::from_str(secret_string).expect("Should be valid JSON"); 1323 | assert_eq!(parsed["updated_key"], "yaml_value"); 1324 | })); 1325 | 1326 | let result = edit( 1327 | &client, 1328 | &Edit { 1329 | secret_id: "secret-one".into(), 1330 | edit_format: DataFormat::Yaml, 1331 | secret_format: DataFormat::Json, 1332 | }, 1333 | ) 1334 | .await; 1335 | 1336 | assert!(result.is_ok()); 1337 | }), 1338 | ) 1339 | .await; 1340 | 1341 | Ok(()) 1342 | } 1343 | 1344 | #[tokio::test] 1345 | async fn write_only_provider_uses_json_template() -> TestResult { 1346 | async_with_env_vars( 1347 | vec![("EDITOR", Some("echo '{\"new_key\": \"new_value\"}' > "))], 1348 | AssertUnwindSafe(async { 1349 | let mut client = TestClient::default(); 1350 | client.is_write_only = true; 1351 | client.on_update_secret = Some(Box::new(|secret_string| { 1352 | let parsed: serde_json::Value = 1353 | serde_json::from_str(secret_string).expect("Should be valid JSON"); 1354 | assert_eq!(parsed["new_key"], "new_value"); 1355 | })); 1356 | 1357 | let result = edit( 1358 | &client, 1359 | &Edit { 1360 | secret_id: "write-only-secret".into(), 1361 | edit_format: DataFormat::Json, 1362 | secret_format: DataFormat::Json, 1363 | }, 1364 | ) 1365 | .await; 1366 | 1367 | assert!(result.is_ok()); 1368 | }), 1369 | ) 1370 | .await; 1371 | 1372 | Ok(()) 1373 | } 1374 | 1375 | #[tokio::test] 1376 | async fn write_only_provider_uses_yaml_template() -> TestResult { 1377 | async_with_env_vars( 1378 | vec![("EDITOR", Some("echo 'key: value\nanother: true' > "))], 1379 | AssertUnwindSafe(async { 1380 | let mut client = TestClient::default(); 1381 | client.is_write_only = true; 1382 | client.on_update_secret = Some(Box::new(|secret_string| { 1383 | let parsed: serde_json::Value = 1384 | serde_json::from_str(secret_string).expect("Should be valid JSON"); 1385 | assert_eq!(parsed["key"], "value"); 1386 | assert_eq!(parsed["another"], true); 1387 | })); 1388 | 1389 | let result = edit( 1390 | &client, 1391 | &Edit { 1392 | secret_id: "write-only-secret".into(), 1393 | edit_format: DataFormat::Yaml, 1394 | secret_format: DataFormat::Json, 1395 | }, 1396 | ) 1397 | .await; 1398 | 1399 | assert!(result.is_ok()); 1400 | }), 1401 | ) 1402 | .await; 1403 | 1404 | Ok(()) 1405 | } 1406 | 1407 | #[tokio::test] 1408 | async fn write_only_provider_uses_text_template() -> TestResult { 1409 | async_with_env_vars( 1410 | vec![("EDITOR", Some("echo 'plain text secret' > "))], 1411 | AssertUnwindSafe(async { 1412 | let mut client = TestClient::default(); 1413 | client.is_write_only = true; 1414 | client.on_update_secret = Some(Box::new(|secret_string| { 1415 | assert_eq!(secret_string.trim(), "plain text secret"); 1416 | })); 1417 | 1418 | let result = edit( 1419 | &client, 1420 | &Edit { 1421 | secret_id: "write-only-secret".into(), 1422 | edit_format: DataFormat::Text, 1423 | secret_format: DataFormat::Text, 1424 | }, 1425 | ) 1426 | .await; 1427 | 1428 | assert!(result.is_ok()); 1429 | }), 1430 | ) 1431 | .await; 1432 | 1433 | Ok(()) 1434 | } 1435 | 1436 | #[tokio::test] 1437 | async fn write_only_provider_skips_update_when_no_changes() -> TestResult { 1438 | async_with_env_vars( 1439 | vec![("EDITOR", Some("echo '{}' > "))], 1440 | AssertUnwindSafe(async { 1441 | let mut client = TestClient::default(); 1442 | client.is_write_only = true; 1443 | let update_called = std::sync::Arc::new(std::sync::Mutex::new(false)); 1444 | let update_called_clone = update_called.clone(); 1445 | client.on_update_secret = Some(Box::new(move |_| { 1446 | *update_called_clone.lock().unwrap() = true; 1447 | })); 1448 | 1449 | let result = edit( 1450 | &client, 1451 | &Edit { 1452 | secret_id: "write-only-secret".into(), 1453 | edit_format: DataFormat::Json, 1454 | secret_format: DataFormat::Json, 1455 | }, 1456 | ) 1457 | .await; 1458 | 1459 | assert!(result.is_ok()); 1460 | assert!( 1461 | !*update_called.lock().unwrap(), 1462 | "Update should not be called when content is unchanged" 1463 | ); 1464 | }), 1465 | ) 1466 | .await; 1467 | 1468 | Ok(()) 1469 | } 1470 | 1471 | #[tokio::test] 1472 | async fn write_only_provider_cannot_read_secret() -> TestResult { 1473 | let mut client = TestClient::default(); 1474 | client.is_write_only = true; 1475 | 1476 | let result = client.secret_value("test-secret".to_string()).await; 1477 | assert!(matches!(result, Err(NysmError::SecretNotReadable))); 1478 | 1479 | Ok(()) 1480 | } 1481 | } 1482 | 1483 | mod create_output { 1484 | use super::*; 1485 | 1486 | #[tokio::test] 1487 | async fn error_when_api_create_call_fails() -> TestResult { 1488 | async_with_env_vars( 1489 | vec![("EDITOR", Some("echo 'test: value\n' >> "))], 1490 | AssertUnwindSafe(async { 1491 | let mut client = TestClient::default(); 1492 | client.fails_on_create_secret = true; 1493 | 1494 | let result = create( 1495 | &client, 1496 | &Create { 1497 | secret_id: "fake".into(), 1498 | description: None, 1499 | edit_format: DataFormat::Yaml, 1500 | secret_format: DataFormat::Json, 1501 | }, 1502 | ) 1503 | .await; 1504 | 1505 | assert_eq!( 1506 | result, 1507 | Err(NysmError::CreateSecretFailed("Test error".to_string())) 1508 | ); 1509 | }), 1510 | ) 1511 | .await; 1512 | 1513 | Ok(()) 1514 | } 1515 | 1516 | #[tokio::test] 1517 | async fn ok_when_api_create_call_succeeds() -> TestResult { 1518 | async_with_env_vars( 1519 | vec![("EDITOR", Some("echo 'test: value\n' >> "))], 1520 | AssertUnwindSafe(async { 1521 | let client = TestClient::default(); 1522 | 1523 | let result = create( 1524 | &client, 1525 | &Create { 1526 | secret_id: "new-secret".into(), 1527 | description: Some("Test description".into()), 1528 | edit_format: DataFormat::Yaml, 1529 | secret_format: DataFormat::Json, 1530 | }, 1531 | ) 1532 | .await; 1533 | 1534 | assert!(result.is_ok()); 1535 | }), 1536 | ) 1537 | .await; 1538 | 1539 | Ok(()) 1540 | } 1541 | 1542 | #[tokio::test] 1543 | async fn ok_when_no_changes_made_in_editor() -> TestResult { 1544 | async_with_env_vars( 1545 | vec![("EDITOR", Some("echo >/dev/null 2>&1 <<<"))], 1546 | AssertUnwindSafe(async { 1547 | let client = TestClient::default(); 1548 | 1549 | let result = create( 1550 | &client, 1551 | &Create { 1552 | secret_id: "new-secret".into(), 1553 | description: None, 1554 | edit_format: DataFormat::Json, 1555 | secret_format: DataFormat::Json, 1556 | }, 1557 | ) 1558 | .await; 1559 | 1560 | assert!(result.is_ok()); 1561 | }), 1562 | ) 1563 | .await; 1564 | 1565 | Ok(()) 1566 | } 1567 | 1568 | #[tokio::test] 1569 | async fn uses_correct_formats_for_editing() -> TestResult { 1570 | async_with_env_vars( 1571 | vec![("EDITOR", Some("echo 'key: yaml_value\n' > "))], 1572 | AssertUnwindSafe(async { 1573 | let mut client = TestClient::default(); 1574 | 1575 | client.on_create_secret = Some(Box::new(|secret_string| { 1576 | let parsed: serde_json::Value = 1577 | serde_json::from_str(secret_string).expect("Should be valid JSON"); 1578 | assert_eq!(parsed["key"], "yaml_value"); 1579 | })); 1580 | 1581 | let result = create( 1582 | &client, 1583 | &Create { 1584 | secret_id: "new-secret".into(), 1585 | description: None, 1586 | edit_format: DataFormat::Yaml, 1587 | secret_format: DataFormat::Json, 1588 | }, 1589 | ) 1590 | .await; 1591 | 1592 | assert!(result.is_ok()); 1593 | }), 1594 | ) 1595 | .await; 1596 | 1597 | Ok(()) 1598 | } 1599 | } 1600 | 1601 | mod delete_output { 1602 | use super::*; 1603 | 1604 | #[tokio::test] 1605 | async fn error_when_api_delete_call_fails() -> TestResult { 1606 | let mut client = TestClient::default(); 1607 | client.fails_on_delete_secret = true; 1608 | 1609 | let result = delete( 1610 | &client, 1611 | &Delete { 1612 | secret_id: "fake".into(), 1613 | }, 1614 | ) 1615 | .await; 1616 | 1617 | assert_eq!( 1618 | result, 1619 | Err(NysmError::DeleteSecretFailed("Test error".to_string())) 1620 | ); 1621 | 1622 | Ok(()) 1623 | } 1624 | 1625 | #[tokio::test] 1626 | async fn ok_when_api_delete_call_succeeds() -> TestResult { 1627 | let client = TestClient::default(); 1628 | 1629 | let result = delete( 1630 | &client, 1631 | &Delete { 1632 | secret_id: "test-secret".into(), 1633 | }, 1634 | ) 1635 | .await; 1636 | 1637 | assert!(result.is_ok()); 1638 | 1639 | Ok(()) 1640 | } 1641 | 1642 | #[tokio::test] 1643 | async fn calls_callback_with_secret_id() -> TestResult { 1644 | let mut client = TestClient::default(); 1645 | 1646 | client.on_delete_secret = Some(Box::new(|secret_id| { 1647 | assert_eq!(secret_id, "test-secret"); 1648 | })); 1649 | 1650 | let result = delete( 1651 | &client, 1652 | &Delete { 1653 | secret_id: "test-secret".into(), 1654 | }, 1655 | ) 1656 | .await; 1657 | 1658 | assert!(result.is_ok()); 1659 | 1660 | Ok(()) 1661 | } 1662 | } 1663 | } 1664 | } 1665 | --------------------------------------------------------------------------------