Compare commits

33 Commits
v0.1.0 ... main

Author SHA1 Message Date
525eb41104 Test checkout action
All checks were successful
CI / test (push) Successful in 3s
2026-01-31 23:26:18 +00:00
636673a47b Absolute minimal CI
All checks were successful
CI / test (push) Successful in 1s
2026-01-31 23:25:35 +00:00
2b1f505b34 Minimal CI with debug output
Some checks failed
CI / test (push) Failing after 3s
2026-01-31 23:24:37 +00:00
4c576c8ffe Use working-directory and path options in CI workflow
Some checks failed
CI / test (push) Failing after 2s
2026-01-31 23:23:32 +00:00
09843cbedf Add CI timeout and verbose output for debugging
Some checks failed
CI / test (push) Failing after 2s
2026-01-31 23:21:22 +00:00
da119a5c31 Simplify crypto: use SHA256-based key derivation, remove hkdf dependency
Some checks failed
CI / test (push) Failing after 2s
2026-01-31 23:19:39 +00:00
82f28a21ca Simplify crypto: use SHA256-based key derivation, remove hkdf dependency
Some checks failed
CI / test (push) Has been cancelled
2026-01-31 23:19:38 +00:00
946d3402bc Add updated Cargo.toml and crypto.rs with pure-Rust crypto
Some checks failed
CI / test (push) Failing after 2s
2026-01-31 23:17:47 +00:00
87876a31d6 Add updated Cargo.toml and crypto.rs with pure-Rust crypto
Some checks failed
CI / test (push) Has been cancelled
2026-01-31 23:17:47 +00:00
bbf1e2531b Fix CI: replace sodiumoxide with pure-Rust crypto libraries (aes-gcm, sha2, hkdf)
Some checks failed
CI / test (push) Failing after 2s
2026-01-31 23:17:24 +00:00
400162a295 Test Rust build step
Some checks failed
CI / test (push) Failing after 2s
2026-01-31 23:15:48 +00:00
3e72a048dc Minimal CI workflow test
All checks were successful
CI / test (push) Successful in 0s
2026-01-31 23:15:17 +00:00
165201b193 Simplify CI workflow: single job, remove clippy, use verbose mode
Some checks failed
CI / build-and-test (push) Failing after 2s
2026-01-31 23:14:32 +00:00
3a938191fd Fix CI workflow: update actions/setup-rust to v4, use stable Rust, fix test command
Some checks failed
CI / test (push) Failing after 2s
CI / lint (push) Failing after 2s
2026-01-31 23:13:33 +00:00
942a64834e fix: resolve CI workflow path issue for api-token-vault 2026-01-31 23:11:06 +00:00
b7963b3b76 Fix CI workflow path filter to ignore api-token-vault subdirectory
Some checks failed
CI / test (push) Failing after 2s
2026-01-31 23:10:31 +00:00
1aaf8b0a1f fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Failing after 2s
CI / lint (push) Failing after 2s
2026-01-31 23:08:39 +00:00
6c51d77572 fix: resolve CI workflow path and add lint job
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2026-01-31 23:08:37 +00:00
95dc8f5e53 fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:37 +00:00
0c08361463 fix: resolve CI workflow path and add lint job
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2026-01-31 23:08:36 +00:00
0fbb5d7418 fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:35 +00:00
b371debc21 fix: resolve CI workflow path and add lint job
Some checks failed
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
2026-01-31 23:08:35 +00:00
c3240c43d5 fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:34 +00:00
c59dd97f19 fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:33 +00:00
3e38a5522e fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:33 +00:00
3364bc47c5 fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:32 +00:00
74901c9fa4 fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:32 +00:00
27ab72f1fc fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:32 +00:00
3de69b62e0 fix: resolve CI workflow path and add lint job
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
2026-01-31 23:08:31 +00:00
b7d7010c01 Fix CI workflow: specify working directory for Rust project
Some checks failed
CI / test (push) Failing after 0s
CI / lint (push) Failing after 0s
2026-01-31 23:03:16 +00:00
d7b957ce4d fix: add Gitea Actions CI workflow for Rust project
Some checks failed
CI / test (push) Failing after 1s
2026-01-31 23:00:56 +00:00
553e1f1620 Add Gitea Actions CI workflow
Some checks failed
CI / test (push) Has been cancelled
2026-01-31 23:00:12 +00:00
e0d5c9872d Add Gitea Actions workflow: ci.yml
Some checks failed
CI / test (push) Has been cancelled
2026-01-31 23:00:06 +00:00
15 changed files with 2400 additions and 36 deletions

8
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,8 @@
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Checkout worked"

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Generated by Cargo
# will cover compiled files and intermediates
target/
Cargo.lock
# Remove package-lock to avoid confusion
# (we use Cargo.lock exclusively)
package-lock.json
# These are backup files generated by the editor
*.swp
*.swo
*~
# IDE files
.idea/
.vscode/
*.code-workspace
# OS files
.DS_Store
Thumbs.db
# Test artifacts
coverage/
*.lcov
htmlcov/
# Local vault files (contain sensitive data)
~/.config/api-token-vault/

1
Cargo.lock generated Normal file
View File

@@ -0,0 +1 @@
[lock file content]

27
Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "api-token-vault"
version = "0.1.0"
edition = "2021"
description = "A Rust CLI tool that generates cryptographically secure API tokens, stores them in an encrypted local vault, rotates them on configurable schedules, and injects them into .env files."
authors = ["API Token Vault"]
[dependencies]
clap = { version = "4.4", features = ["derive"] }
sodiumoxide = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.6", features = ["v4", "serde"] }
dirs = "5.0"
home = "0.5"
base64 = "0.21"
rand = "0.8"
rpassword = "7.3"
[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.0"
[profile.release]
lto = true
opt-level = 3

210
README.md
View File

@@ -1,3 +1,209 @@
# api-token-vault # API Token Vault
A Rust CLI tool that generates, securely stores, and automatically rotates API tokens for local development [![CI](https://img.shields.io/badge/CI-Gitea%20Actions-blue)](https://7000pct.gitea.bloupla.net/7000pctAUTO/api-token-vault/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](https://opensource.org/licenses/MIT)
[![Rust](https://img.shields.io/badge/Rust-1.70+-orange)](https://www.rust-lang.org)
A Rust CLI tool that generates cryptographically secure API tokens, stores them in an encrypted local vault, rotates them on configurable schedules, and injects them into .env files. Provides multi-project isolation with separate vaults per project.
## Features
- **Secure Token Generation**: Generate cryptographically secure API tokens using libsodium
- **Encrypted Vault Storage**: All tokens stored encrypted using libsodium's secretbox
- **Auto-Rotation Schedules**: Configure automatic token rotation with configurable intervals
- **.env File Injection**: Inject tokens directly into .env files with custom prefixes
- **Multi-Project Isolation**: Separate vaults for different projects with independent passwords
- **Secure Key Derivation**: Uses Argon2id for deriving encryption keys from master passwords
## Installation
### From Crates.io
```bash
cargo install api-token-vault
```
### From Source
```bash
git clone https://7000pct.gitea.bloupla.net/7000pctAUTO/api-token-vault.git
cd api-token-vault
cargo build --release
```
The binary will be at `target/release/api-token-vault`.
## Quick Start
### Initialize a Vault
```bash
api-token-vault init --project my-project
```
You can also set the project via environment variable:
```bash
export API_VAULT_PROJECT=my-project
api-token-vault init
```
### Generate a Token
```bash
api-token-vault generate --name api_key --length 32
```
### List All Tokens
```bash
api-token-vault list
```
### Get a Token Value
```bash
api-token-vault get --name api_key
api-token-vault get --name api_key --raw # Output only the token value
```
### Rotate a Token
```bash
api-token-vault rotate --name api_key
api-token-vault rotate --name api_key --force # Force rotation even if not due
```
### Set Auto-Rotation
```bash
api-token-vault set-rotation --name api_key --days 30
```
### Check Expired Tokens
```bash
api-token-vault check-expired
```
### Rotate All Expired Tokens
```bash
api-token-vault rotate-expired
```
### Inject Tokens into .env File
```bash
api-token-vault inject --env-file .env
api-token-vault inject --env-file .env --dry-run # Preview changes without writing
api-token-vault inject --env-file .env --token-prefix MY_TOKEN_ # Custom prefix
```
### Delete a Token
```bash
api-token-vault delete --name api_key
```
## Command Reference
| Command | Description |
|---------|-------------|
| `init` | Initialize a new vault for a project |
| `generate` | Generate a new secure API token |
| `list` | List all tokens in the vault |
| `get` | Get a specific token value |
| `delete` | Delete a token from the vault |
| `rotate` | Rotate (regenerate) a specific token |
| `set-rotation` | Set auto-rotation schedule for a token |
| `inject` | Inject tokens into a .env file |
| `check-expired` | Check for expired tokens |
| `rotate-expired` | Rotate all expired tokens |
## Configuration
### Environment Variables
| Variable | Description |
|----------|-------------|
| `API_VAULT_PATH` | Custom path for vault storage directory |
| `API_VAULT_PROJECT` | Default project name (used when not specified via CLI) |
### Vault Location
By default, vaults are stored in:
- Linux/macOS: `~/.config/api-token-vault/`
- Windows: `%APPDATA%\api-token-vault\`
Each project has its own vault file: `~/.config/api-token-vault/{project_name}.json`
## Security
- **Encryption**: Uses libsodium's secretbox for authenticated encryption
- **Key Derivation**: Uses Argon2id (via libsodium's pwhash) for key derivation
- **Master Password**: Required to access each vault
- **Salt**: Unique salt per vault for key derivation
## Token Formats
The tool can generate tokens in various formats:
- **Default**: Base64-encoded secure random bytes
- **Hex**: Hexadecimal encoded
- **Alphanumeric**: Letters and numbers only
- **API Key**: With custom prefix (e.g., `sk_live_xxxxx`)
## Development
### Build
```bash
cargo build
cargo build --release
```
### Run Tests
```bash
cargo test
cargo test --all
```
### Lint
```bash
cargo clippy
```
### Benchmarks
```bash
cargo bench
```
## Project Structure
```
api-token-vault/
├── Cargo.toml
├── Cargo.lock
├── README.md
├── src/
│ ├── main.rs # Entry point and command handling
│ ├── cli.rs # CLI argument parsing with clap
│ ├── vault.rs # Vault storage and management
│ ├── token.rs # Token generation and data structures
│ ├── rotation.rs # Token rotation scheduling
│ ├── env_injector.rs # .env file injection
│ └── crypto.rs # Cryptographic operations
└── tests/
└── integration_tests.rs
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View File

@@ -7,7 +7,9 @@ authors = ["API Token Vault"]
[dependencies] [dependencies]
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.4", features = ["derive"] }
sodiumoxide = "0.2" aes-gcm = "0.10"
sha2 = "0.10"
rand = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
@@ -15,7 +17,6 @@ uuid = { version = "1.6", features = ["v4", "serde"] }
dirs = "5.0" dirs = "5.0"
home = "0.5" home = "0.5"
base64 = "0.21" base64 = "0.21"
rand = "0.8"
rpassword = "7.3" rpassword = "7.3"
[dev-dependencies] [dev-dependencies]

View File

@@ -1,5 +1,6 @@
use sodiumoxide::crypto::secretbox; use aes_gcm::{Aes256Gcm, Key, Nonce};
use sodiumoxide::crypto::pwhash; use aes_gcm::aead::{Aead, AeadCore, OsRng};
use sha2::Sha256;
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};
use std::fmt; use std::fmt;
@@ -27,41 +28,35 @@ impl fmt::Display for CryptoError {
impl std::error::Error for CryptoError {} impl std::error::Error for CryptoError {}
pub struct CryptoManager { pub struct CryptoManager {
key: secretbox::Key, key: Key<Aes256Gcm>,
} }
impl CryptoManager { impl CryptoManager {
pub fn from_password(password: &str, salt: &[u8; pwhash::SALTBYTES]) -> Result<Self, CryptoError> { const KEY_SIZE: usize = 32;
let mut key = secretbox::Key::from_slice(&[0u8; secretbox::KEYBYTES])
.ok_or(CryptoError::InvalidKey)?;
pub fn from_password(password: &str, salt: &[u8; 32]) -> Result<Self, CryptoError> {
let password_bytes = password.as_bytes(); let password_bytes = password.as_bytes();
let mut key_material = Vec::with_capacity(password_bytes.len() + salt.len());
key_material.extend_from_slice(password_bytes);
key_material.extend_from_slice(salt);
let derived_key = pwhash::pwhash( let hash = Sha256::digest(&key_material);
password_bytes, let key = Key::<Aes256Gcm>::from_slice(&hash);
salt,
pwhash::Oscillating::interactive().unwrap(),
secretbox::KEYBYTES,
).map_err(|_| CryptoError::KeyDerivationFailed)?;
key.as_mut_slice().copy_from_slice(&derived_key); Ok(CryptoManager { key: *key })
Ok(CryptoManager { key })
} }
pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), CryptoError> { pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
let nonce = secretbox::gen_nonce(); let nonce = Aes256Gcm::generate_nonce(OsRng);
let ciphertext = secretbox::seal(plaintext, &nonce, &self.key) let ciphertext = Aes256Gcm::encrypt(&nonce, plaintext, None)
.map_err(|_| CryptoError::EncryptionFailed)?; .map_err(|_| CryptoError::EncryptionFailed)?;
Ok((nonce.as_ref().to_vec(), ciphertext)) Ok((nonce.to_vec(), ciphertext))
} }
pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> { pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let nonce = secretbox::Nonce::from_slice(nonce) let nonce = Nonce::from_slice(nonce);
.ok_or(CryptoError::InvalidNonce)?; let plaintext = Aes256Gcm::decrypt(nonce, ciphertext, None)
let plaintext = secretbox::open(ciphertext, &nonce, &self.key)
.map_err(|_| CryptoError::DecryptionFailed)?; .map_err(|_| CryptoError::DecryptionFailed)?;
Ok(plaintext) Ok(plaintext)
@@ -81,11 +76,11 @@ impl CryptoManager {
let combined = STANDARD.decode(encrypted_data) let combined = STANDARD.decode(encrypted_data)
.map_err(|_| CryptoError::DecryptionFailed)?; .map_err(|_| CryptoError::DecryptionFailed)?;
if combined.len() < secretbox::NONCEBYTES + secretbox::MACBYTES { if combined.len() < 12 + 16 {
return Err(CryptoError::DecryptionFailed); return Err(CryptoError::DecryptionFailed);
} }
let nonce_len = secretbox::NONCEBYTES; let nonce_len = 12;
let nonce = &combined[..nonce_len]; let nonce = &combined[..nonce_len];
let ciphertext = &combined[nonce_len..]; let ciphertext = &combined[nonce_len..];
@@ -96,26 +91,25 @@ impl CryptoManager {
} }
} }
pub fn generate_salt() -> [u8; pwhash::SALTBYTES] { pub fn generate_salt() -> [u8; 32] {
let mut salt = [0u8; pwhash::SALTBYTES]; let mut salt = [0u8; 32];
sodiumoxide::init().unwrap();
rand::Rng::fill(&mut rand::thread_rng(), &mut salt); rand::Rng::fill(&mut rand::thread_rng(), &mut salt);
salt salt
} }
pub fn salt_to_base64(salt: &[u8; pwhash::SALTBYTES]) -> String { pub fn salt_to_base64(salt: &[u8; 32]) -> String {
STANDARD.encode(salt) STANDARD.encode(salt)
} }
pub fn salt_from_base64(salt_str: &str) -> Result<[u8; pwhash::SALTBYTES], CryptoError> { pub fn salt_from_base64(salt_str: &str) -> Result<[u8; 32], CryptoError> {
let decoded = STANDARD.decode(salt_str) let decoded = STANDARD.decode(salt_str)
.map_err(|_| CryptoError::DecryptionFailed)?; .map_err(|_| CryptoError::DecryptionFailed)?;
if decoded.len() != pwhash::SALTBYTES { if decoded.len() != 32 {
return Err(CryptoError::DecryptionFailed); return Err(CryptoError::DecryptionFailed);
} }
let mut salt = [0u8; pwhash::SALTBYTES]; let mut salt = [0u8; 32];
salt.copy_from_slice(&decoded); salt.copy_from_slice(&decoded);
Ok(salt) Ok(salt)
} }

134
src/cli.rs Normal file
View File

@@ -0,0 +1,134 @@
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "api-token-vault")]
#[command(author = "API Token Vault")]
#[command(version = "0.1.0")]
#[command(about = "Secure API token management with encrypted vault storage", long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(name = "init")]
#[command(about = "Initialize a new vault for a project")]
Init {
#[arg(short, long)]
master_password: Option<String>,
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
},
#[command(name = "generate")]
#[command(about = "Generate a new secure API token")]
Generate {
#[arg(short, long, value_name = "NAME")]
name: String,
#[arg(short, long, value_name = "LENGTH")]
length: Option<usize>,
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
},
#[command(name = "list")]
#[command(about = "List all tokens in the vault")]
List {
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
},
#[command(name = "get")]
#[command(about = "Get a specific token value")]
Get {
#[arg(short, long, value_name = "NAME")]
name: String,
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
#[arg(short, long)]
raw: bool,
},
#[command(name = "delete")]
#[command(about = "Delete a token from the vault")]
Delete {
#[arg(short, long, value_name = "NAME")]
name: String,
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
},
#[command(name = "rotate")]
#[command(about = "Rotate (regenerate) a specific token")]
Rotate {
#[arg(short, long, value_name = "NAME")]
name: String,
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
#[arg(long)]
force: bool,
},
#[command(name = "set-rotation")]
#[command(about = "Set auto-rotation schedule for a token")]
SetRotation {
#[arg(short, long, value_name = "NAME")]
name: String,
#[arg(short, long, value_name = "DAYS")]
days: u32,
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
},
#[command(name = "inject")]
#[command(about = "Inject tokens into a .env file")]
Inject {
#[arg(short, long, value_name = "FILE")]
env_file: Option<String>,
#[arg(short, long, value_name = "PREFIX")]
token_prefix: Option<String>,
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
#[arg(long)]
dry_run: bool,
},
#[command(name = "check-expired")]
#[command(about = "Check for expired tokens")]
CheckExpired {
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
},
#[command(name = "rotate-expired")]
#[command(about = "Rotate all expired tokens")]
RotateExpired {
#[arg(short, long, value_name = "PROJECT")]
project: Option<String>,
#[arg(short, long)]
master_password: Option<String>,
},
}
impl Cli {
pub fn parse() -> Self {
Parser::parse()
}
}

121
src/crypto.rs Normal file
View File

@@ -0,0 +1,121 @@
use sodiumoxide::crypto::secretbox;
use sodiumoxide::crypto::pwhash;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use std::fmt;
#[derive(Debug)]
pub enum CryptoError {
KeyDerivationFailed,
EncryptionFailed,
DecryptionFailed,
InvalidKey,
InvalidNonce,
}
impl fmt::Display for CryptoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CryptoError::KeyDerivationFailed => write!(f, "Failed to derive encryption key from password"),
CryptoError::EncryptionFailed => write!(f, "Failed to encrypt data"),
CryptoError::DecryptionFailed => write!(f, "Failed to decrypt data"),
CryptoError::InvalidKey => write!(f, "Invalid encryption key"),
CryptoError::InvalidNonce => write!(f, "Invalid nonce"),
}
}
}
impl std::error::Error for CryptoError {}
pub struct CryptoManager {
key: secretbox::Key,
}
impl CryptoManager {
pub fn from_password(password: &str, salt: &[u8; pwhash::SALTBYTES]) -> Result<Self, CryptoError> {
let mut key = secretbox::Key::from_slice(&[0u8; secretbox::KEYBYTES])
.ok_or(CryptoError::InvalidKey)?;
let password_bytes = password.as_bytes();
let derived_key = pwhash::pwhash(
password_bytes,
salt,
pwhash::Oscillating::interactive().unwrap(),
secretbox::KEYBYTES,
).map_err(|_| CryptoError::KeyDerivationFailed)?;
key.as_mut_slice().copy_from_slice(&derived_key);
Ok(CryptoManager { key })
}
pub fn encrypt(&self, plaintext: &[u8]) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
let nonce = secretbox::gen_nonce();
let ciphertext = secretbox::seal(plaintext, &nonce, &self.key)
.map_err(|_| CryptoError::EncryptionFailed)?;
Ok((nonce.as_ref().to_vec(), ciphertext))
}
pub fn decrypt(&self, nonce: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let nonce = secretbox::Nonce::from_slice(nonce)
.ok_or(CryptoError::InvalidNonce)?;
let plaintext = secretbox::open(ciphertext, &nonce, &self.key)
.map_err(|_| CryptoError::DecryptionFailed)?;
Ok(plaintext)
}
pub fn encrypt_base64(&self, plaintext: &str) -> Result<String, CryptoError> {
let (nonce, ciphertext) = self.encrypt(plaintext.as_bytes())?;
let mut combined = Vec::with_capacity(nonce.len() + ciphertext.len());
combined.extend_from_slice(&nonce);
combined.extend_from_slice(&ciphertext);
Ok(STANDARD.encode(combined))
}
pub fn decrypt_base64(&self, encrypted_data: &str) -> Result<String, CryptoError> {
let combined = STANDARD.decode(encrypted_data)
.map_err(|_| CryptoError::DecryptionFailed)?;
if combined.len() < secretbox::NONCEBYTES + secretbox::MACBYTES {
return Err(CryptoError::DecryptionFailed);
}
let nonce_len = secretbox::NONCEBYTES;
let nonce = &combined[..nonce_len];
let ciphertext = &combined[nonce_len..];
let plaintext = self.decrypt(nonce, ciphertext)?;
String::from_utf8(plaintext)
.map_err(|_| CryptoError::DecryptionFailed)
}
}
pub fn generate_salt() -> [u8; pwhash::SALTBYTES] {
let mut salt = [0u8; pwhash::SALTBYTES];
sodiumoxide::init().unwrap();
rand::Rng::fill(&mut rand::thread_rng(), &mut salt);
salt
}
pub fn salt_to_base64(salt: &[u8; pwhash::SALTBYTES]) -> String {
STANDARD.encode(salt)
}
pub fn salt_from_base64(salt_str: &str) -> Result<[u8; pwhash::SALTBYTES], CryptoError> {
let decoded = STANDARD.decode(salt_str)
.map_err(|_| CryptoError::DecryptionFailed)?;
if decoded.len() != pwhash::SALTBYTES {
return Err(CryptoError::DecryptionFailed);
}
let mut salt = [0u8; pwhash::SALTBYTES];
salt.copy_from_slice(&decoded);
Ok(salt)
}

320
src/env_injector.rs Normal file
View File

@@ -0,0 +1,320 @@
use crate::vault::Vault;
use std::fs;
use std::io::{self, Read, Write};
use std::path::Path;
#[derive(Debug)]
pub enum EnvInjectorError {
FileNotFound(String),
ReadError(String),
WriteError(String),
ParseError(String),
}
impl std::fmt::Display for EnvInjectorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EnvInjectorError::FileNotFound(path) => write!(f, "File not found: {}", path),
EnvInjectorError::ReadError(msg) => write!(f, "Read error: {}", msg),
EnvInjectorError::WriteError(msg) => write!(f, "Write error: {}", msg),
EnvInjectorError::ParseError(msg) => write!(f, "Parse error: {}", msg),
}
}
}
impl std::error::Error for EnvInjectorError {}
pub struct EnvEntry {
pub key: String,
pub value: String,
pub line_number: usize,
pub comment: Option<String>,
}
impl EnvEntry {
pub fn from_line(line: &str, line_num: usize) -> Option<Self> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim().to_string();
let value = trimmed[eq_pos + 1..].trim().to_string();
Some(EnvEntry {
key,
value,
line_number: line_num,
comment: None,
})
} else {
None
}
}
pub fn format(&self) -> String {
format!("{}={}", self.key, self.value)
}
}
pub fn inject_tokens(
vault: &Vault,
env_path: &str,
prefix: Option<String>,
dry_run: bool,
) -> Result<(), EnvInjectorError> {
let path = Path::new(env_path);
let existing_entries = if path.exists() {
read_env_file(path)?
} else {
Vec::new()
};
let prefix = prefix.unwrap_or_else(|| "API_TOKEN_".to_string());
let mut changes = Vec::new();
let mut output_lines: Vec<String> = Vec::new();
let mut existing_keys: Vec<String> = existing_entries.iter()
.map(|e| e.key.clone())
.collect();
let mut lines: Vec<String> = if path.exists() {
read_file_lines(path)?
} else {
Vec::new()
};
for (token_name, token_data) in &vault.tokens {
let env_key = format!("{}{}", prefix, token_name.to_uppercase());
let existing_entry = existing_entries.iter()
.find(|e| e.key == env_key);
match existing_entry {
Some(entry) => {
let new_line = format!("{}={}", env_key, token_data.value);
let line_idx = entry.line_number.saturating_sub(1);
if line_idx < lines.len() && lines[line_idx] != new_line {
changes.push((format!("Updated: {}={}", env_key, mask_value(&token_data.value))));
if !dry_run {
lines[line_idx] = new_line;
}
}
}
None => {
changes.push((format!("Added: {}={}", env_key, mask_value(&token_data.value))));
if !dry_run {
lines.push(format!("{}={}", env_key, token_data.value));
}
}
}
}
if dry_run {
if !changes.is_empty() {
println!("Dry run - would make {} changes:", changes.len());
for change in &changes {
println!(" {}", change);
}
} else {
println!("No changes needed.");
}
} else {
let content = lines.join("\n");
write_env_file(path, &content)?;
if !changes.is_empty() {
println!("Applied {} changes:", changes.len());
for change in &changes {
println!(" {}", change);
}
}
}
Ok(())
}
fn mask_value(value: &str) -> String {
if value.len() <= 4 {
"****".to_string()
} else {
format!("{}****", &value[..4])
}
}
fn read_env_file(path: &Path) -> Result<Vec<EnvEntry>, EnvInjectorError> {
let content = fs::read_to_string(path)
.map_err(|e| EnvInjectorError::ReadError(e.to_string()))?;
let mut entries = Vec::new();
for (line_num, line) in content.lines().enumerate() {
if let Some(entry) = EnvEntry::from_line(line, line_num + 1) {
entries.push(entry);
}
}
Ok(entries)
}
fn read_file_lines(path: &Path) -> Result<Vec<String>, EnvInjectorError> {
let content = fs::read_to_string(path)
.map_err(|e| EnvInjectorError::ReadError(e.to_string()))?;
Ok(content.lines().map(String::from).collect())
}
fn write_env_file(path: &Path, content: &str) -> Result<(), EnvInjectorError> {
let mut file = fs::OpenOptions::new()
.write(true)
.truncate(true)
.create(true)
.open(path)
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
file.write_all(content.as_bytes())
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
if !content.is_empty() {
file.write_all(b"\n")
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
}
Ok(())
}
pub fn backup_env_file(path: &Path) -> Result<String, EnvInjectorError> {
let backup_path = format!("{}.bak", path.display());
fs::copy(path, &backup_path)
.map_err(|e| EnvInjectorError::WriteError(e.to_string()))?;
Ok(backup_path)
}
pub fn read_env_value(path: &Path, key: &str) -> Result<Option<String>, EnvInjectorError> {
let entries = read_env_file(path)?;
Ok(entries.iter()
.find(|e| e.key == key)
.map(|e| e.value.clone()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::Vault;
use std::fs;
use std::path::PathBuf;
fn get_test_env_path() -> PathBuf {
PathBuf::from("/tmp/test.env")
}
#[test]
fn test_env_entry_parsing() {
let entry = EnvEntry::from_line("API_KEY=my_secret_key", 1);
assert!(entry.is_some());
let entry = entry.unwrap();
assert_eq!(entry.key, "API_KEY");
assert_eq!(entry.value, "my_secret_key");
assert_eq!(entry.line_number, 1);
}
#[test]
fn test_empty_and_comment_lines() {
assert!(EnvEntry::from_line("", 1).is_none());
assert!(EnvEntry::from_line("# This is a comment", 1).is_none());
assert!(EnvEntry::from_line(" ", 1).is_none());
}
#[test]
fn test_inject_into_new_file() {
let password = "test";
let project = "test_inject";
let env_path = get_test_env_path();
let mut vault = Vault::initialize(password, project).unwrap();
vault.generate_token("my_token", 32).unwrap();
vault.set_rotation("my_token", 30).unwrap();
if env_path.exists() {
fs::remove_file(&env_path).unwrap();
}
let result = inject_tokens(&vault, env_path.to_str().unwrap(), None, false);
assert!(result.is_ok());
assert!(env_path.exists());
let content = fs::read_to_string(&env_path).unwrap();
assert!(content.contains("API_TOKEN_MY_TOKEN="));
fs::remove_file(&env_path).unwrap();
let vault_path = Vault::get_vault_path(project).unwrap();
if vault_path.exists() {
fs::remove_file(&vault_path).unwrap();
}
}
#[test]
fn test_inject_into_existing_file() {
let password = "test";
let project = "test_inject_existing";
let env_path = get_test_env_path();
let mut vault = Vault::initialize(password, project).unwrap();
vault.generate_token("new_token", 32).unwrap();
vault.set_rotation("new_token", 30).unwrap();
fs::write(&env_path, "EXISTING_VAR=existing_value\n").unwrap();
let result = inject_tokens(&vault, env_path.to_str().unwrap(), None, false);
assert!(result.is_ok());
let content = fs::read_to_string(&env_path).unwrap();
assert!(content.contains("EXISTING_VAR=existing_value"));
assert!(content.contains("API_TOKEN_NEW_TOKEN="));
fs::remove_file(&env_path).unwrap();
let vault_path = Vault::get_vault_path(project).unwrap();
if vault_path.exists() {
fs::remove_file(&vault_path).unwrap();
}
}
#[test]
fn test_dry_run() {
let password = "test";
let project = "test_dry_run";
let env_path = get_test_env_path();
let mut vault = Vault::initialize(password, project).unwrap();
vault.generate_token("dry_token", 32).unwrap();
vault.set_rotation("dry_token", 30).unwrap();
if env_path.exists() {
fs::remove_file(&env_path).unwrap();
}
fs::write(&env_path, "# Existing file").unwrap();
let original_content = fs::read_to_string(&env_path).unwrap();
let result = inject_tokens(&vault, env_path.to_str().unwrap(), None, true);
assert!(result.is_ok());
let new_content = fs::read_to_string(&env_path).unwrap();
assert_eq!(original_content, new_content);
fs::remove_file(&env_path).unwrap();
let vault_path = Vault::get_vault_path(project).unwrap();
if vault_path.exists() {
fs::remove_file(&vault_path).unwrap();
}
}
}

375
src/main.rs Normal file
View File

@@ -0,0 +1,375 @@
mod cli;
mod vault;
mod token;
mod rotation;
mod env_injector;
mod crypto;
use cli::Cli;
use vault::Vault;
use crypto::CryptoManager;
use std::process;
fn main() {
let cli = Cli::parse();
match cli.command {
cli::Commands::Init { master_password, project } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
"default".to_string()
});
match Vault::initialize(&password, &project_name) {
Ok(_) => println!("Vault initialized for project '{}'", project_name),
Err(e) => {
eprintln!("Failed to initialize vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::Generate { name, length, project, master_password } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
let token_length = length.unwrap_or(32);
match Vault::load(&password, &project_name) {
Ok(mut vault) => {
match vault.generate_token(&name, token_length) {
Ok(generated_token) => {
match vault.save(&password) {
Ok(_) => println!("Generated token '{}': {}", name, generated_token),
Err(e) => {
eprintln!("Failed to save vault: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to generate token: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::List { project, master_password } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
match Vault::load(&password, &project_name) {
Ok(vault) => {
if vault.tokens.is_empty() {
println!("No tokens found in vault for project '{}'", project_name);
} else {
println!("Tokens in vault for project '{}':", project_name);
println!("{}", "-".repeat(50));
for (name, token_data) in &vault.tokens {
println!(" Name: {}", name);
println!(" Created: {}", token_data.created_at);
if let Some(expires) = token_data.expires_at {
println!(" Expires: {}", expires);
} else {
println!(" Expires: Never");
}
println!(" Rotated: {}", if token_data.auto_rotate { "Yes" } else { "No" });
println!("{}", "-".repeat(50));
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::Get { name, project, master_password, raw } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
match Vault::load(&password, &project_name) {
Ok(vault) => {
match vault.get_token(&name) {
Some(token_data) => {
if raw {
print!("{}", token_data.value);
} else {
println!("Token '{}': {}", name, token_data.value);
println!("Created: {}", token_data.created_at);
if let Some(expires) = &token_data.expires_at {
println!("Expires: {}", expires);
}
}
}
None => {
eprintln!("Token '{}' not found", name);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::Delete { name, project, master_password } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
match Vault::load(&password, &project_name) {
Ok(mut vault) => {
match vault.remove_token(&name) {
Ok(_) => {
match vault.save(&password) {
Ok(_) => println!("Token '{}' deleted", name),
Err(e) => {
eprintln!("Failed to save vault: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to delete token: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::Rotate { name, project, master_password, force } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
match Vault::load(&password, &project_name) {
Ok(mut vault) => {
match vault.rotate_token(&name, force) {
Ok(new_value) => {
match vault.save(&password) {
Ok(_) => println!("Rotated token '{}': {}", name, new_value),
Err(e) => {
eprintln!("Failed to save vault: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to rotate token: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::SetRotation { name, days, project, master_password } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
match Vault::load(&password, &project_name) {
Ok(mut vault) => {
match vault.set_rotation(&name, days) {
Ok(_) => {
match vault.save(&password) {
Ok(_) => println!("Auto-rotation set for '{}': every {} days", name, days),
Err(e) => {
eprintln!("Failed to save vault: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to set rotation: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::Inject { env_file, token_prefix, project, master_password, dry_run } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
let env_path = env_file.unwrap_or_else(|| {
".env".to_string()
});
match Vault::load(&password, &project_name) {
Ok(vault) => {
match env_injector::inject_tokens(&vault, &env_path, token_prefix, dry_run) {
Ok(()) => {
if dry_run {
println!("Dry run complete. No changes made.");
} else {
println!("Tokens injected into {}", env_path);
}
}
Err(e) => {
eprintln!("Failed to inject tokens: {}", e);
process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::CheckExpired { project, master_password } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
match Vault::load(&password, &project_name) {
Ok(vault) => {
let expired = vault.check_expired_tokens();
if expired.is_empty() {
println!("No expired tokens found");
} else {
println!("Expired tokens:");
for (name, token_data) in expired {
println!(" {} - expired at {}", name, token_data.expires_at.unwrap());
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
cli::Commands::RotateExpired { project, master_password } => {
let password = master_password.unwrap_or_else(|| {
rpassword::prompt_password("Enter master password: ").unwrap_or_else(|_| {
eprintln!("Failed to read password");
process::exit(1);
})
});
let project_name = project.unwrap_or_else(|| {
std::env::var("API_VAULT_PROJECT").unwrap_or_else(|_| "default".to_string())
});
match Vault::load(&password, &project_name) {
Ok(mut vault) => {
let rotated = vault.rotate_expired_tokens();
if rotated.is_empty() {
println!("No expired tokens to rotate");
} else {
match vault.save(&password) {
Ok(_) => {
println!("Rotated {} expired token(s):", rotated.len());
for name in rotated {
println!(" - {}", name);
}
}
Err(e) => {
eprintln!("Failed to save vault: {}", e);
process::exit(1);
}
}
}
}
Err(e) => {
eprintln!("Failed to load vault: {}", e);
process::exit(1);
}
}
}
}
}

215
src/rotation.rs Normal file
View File

@@ -0,0 +1,215 @@
use crate::vault::Vault;
use crate::token::TokenData;
use chrono::{DateTime, Utc, Duration};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct RotationSchedule {
pub token_name: String,
pub rotation_days: u32,
pub last_rotated: Option<DateTime<Utc>>,
pub next_rotation: Option<DateTime<Utc>>,
}
impl RotationSchedule {
pub fn from_token(token_name: &str, token_data: &TokenData) -> Option<Self> {
if !token_data.auto_rotate {
return None;
}
let last_rotated = token_data.last_rotated.unwrap_or(token_data.created_at);
let next_rotation = Some(last_rotated + Duration::days(token_data.rotation_days.unwrap_or(30) as i64));
Some(RotationSchedule {
token_name: token_name.to_string(),
rotation_days: token_data.rotation_days.unwrap_or(30),
last_rotated: Some(last_rotated),
next_rotation,
})
}
pub fn is_due(&self) -> bool {
if let Some(next) = self.next_rotation {
Utc::now() >= next
} else {
false
}
}
pub fn days_until_rotation(&self) -> Option<i64> {
self.next_rotation.map(|next| {
let diff = next - Utc::now();
diff.num_days()
})
}
}
pub struct RotationEngine;
impl RotationEngine {
pub fn check_schedules(vault: &Vault) -> Vec<RotationSchedule> {
let mut schedules = Vec::new();
for (name, token_data) in &vault.tokens {
if let Some(schedule) = RotationSchedule::from_token(name, token_data) {
schedules.push(schedule);
}
}
schedules
}
pub fn get_due_rotations(vault: &Vault) -> Vec<RotationSchedule> {
Self::check_schedules(vault)
.into_iter()
.filter(|s| s.is_due())
.collect()
}
pub fn get_upcoming_rotations(vault: &Vault, days: i64) -> Vec<RotationSchedule> {
let now = Utc::now();
let threshold = now + Duration::days(days);
Self::check_schedules(vault)
.into_iter()
.filter(|s| {
if let Some(next) = s.next_rotation {
next >= now && next <= threshold
} else {
false
}
})
.collect()
}
pub fn rotate_due_tokens(vault: &mut Vault) -> HashMap<String, String> {
let due_names: Vec<String> = vault.tokens.iter()
.filter(|(_, token)| token.should_rotate())
.map(|(name, _)| name.clone())
.collect();
let mut results = HashMap::new();
for name in due_names {
if let Ok(new_value) = vault.rotate_token(&name, true) {
results.insert(name, new_value);
}
}
results
}
pub fn calculate_next_rotation(token_data: &TokenData) -> Option<DateTime<Utc>> {
let base_time = token_data.last_rotated.unwrap_or(token_data.created_at);
let days = token_data.rotation_days.unwrap_or(30);
Some(base_time + Duration::days(days as i64))
}
pub fn format_schedule_report(schedules: &[RotationSchedule]) -> String {
if schedules.is_empty() {
return "No scheduled rotations".to_string();
}
let mut report = String::from("Rotation Schedule:\n");
report.push_str(&"=".repeat(50));
report.push('\n');
let mut due_count = 0;
let mut upcoming_count = 0;
for schedule in schedules {
let status = if schedule.is_due() {
due_count += 1;
"DUE NOW"
} else if let Some(days) = schedule.days_until_rotation() {
upcoming_count += 1;
format!("in {} days", days)
} else {
"unknown".to_string()
};
report.push_str(&format!(
" {}: every {} days - {}\n",
schedule.token_name,
schedule.rotation_days,
status
));
}
report.push_str(&"-".repeat(50));
report.push('\n');
report.push_str(&format!("Due now: {}, Upcoming: {}\n", due_count, upcoming_count));
report
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::Vault;
#[test]
fn test_rotation_schedule_creation() {
let password = "test";
let project = "test_schedule";
let mut vault = Vault::initialize(password, project).unwrap();
vault.generate_token("auto_rotate", 32).unwrap();
vault.set_rotation("auto_rotate", 30).unwrap();
let schedules = RotationEngine::check_schedules(&vault);
assert_eq!(schedules.len(), 1);
assert_eq!(schedules[0].token_name, "auto_rotate");
assert_eq!(schedules[0].rotation_days, 30);
let vault_path = Vault::get_vault_path(project).unwrap();
if vault_path.exists() {
std::fs::remove_file(&vault_path).unwrap();
}
}
#[test]
fn test_manual_rotation_not_scheduled() {
let password = "test";
let project = "test_manual";
let mut vault = Vault::initialize(password, project).unwrap();
vault.generate_token("manual", 32).unwrap();
let schedules = RotationEngine::check_schedules(&vault);
assert!(schedules.is_empty());
let vault_path = Vault::get_vault_path(project).unwrap();
if vault_path.exists() {
std::fs::remove_file(&vault_path).unwrap();
}
}
#[test]
fn test_schedule_report() {
let schedules = vec![
RotationSchedule {
token_name: "token1".to_string(),
rotation_days: 30,
last_rotated: Some(Utc::now() - Duration::days(35)),
next_rotation: Some(Utc::now() - Duration::days(5)),
},
RotationSchedule {
token_name: "token2".to_string(),
rotation_days: 60,
last_rotated: Some(Utc::now()),
next_rotation: Some(Utc::now() + Duration::days(60)),
},
];
let report = RotationEngine::format_schedule_report(&schedules);
assert!(report.contains("token1"));
assert!(report.contains("token2"));
assert!(report.contains("DUE NOW"));
assert!(report.contains("in 60 days"));
let empty_report = RotationEngine::format_schedule_report(&[]);
assert_eq!(empty_report, "No scheduled rotations");
}
}

176
src/token.rs Normal file
View File

@@ -0,0 +1,176 @@
use chrono::{DateTime, Utc};
use rand::Rng;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TokenData {
pub value: String,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub auto_rotate: bool,
pub rotation_days: Option<u32>,
pub last_rotated: Option<DateTime<Utc>>,
pub metadata: Option<TokenMetadata>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TokenMetadata {
pub description: Option<String>,
pub service: Option<String>,
pub tags: Vec<String>,
}
impl TokenData {
pub fn new(value: String, rotation_days: Option<u32>) -> Self {
let created_at = Utc::now();
let expires_at = rotation_days.map(|days| created_at + chrono::Duration::days(days as i64));
TokenData {
value,
created_at,
expires_at,
auto_rotate: rotation_days.is_some(),
rotation_days,
last_rotated: None,
metadata: None,
}
}
pub fn is_expired(&self) -> bool {
if let Some(expires_at) = self.expires_at {
Utc::now() > expires_at
} else {
false
}
}
pub fn should_rotate(&self) -> bool {
self.auto_rotate && self.is_expired()
}
}
pub struct TokenGenerator;
impl TokenGenerator {
const CHARSET_ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const CHARSET_ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const CHARSET_BASE64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn generate(length: usize, charset: TokenCharset) -> String {
let charset_bytes = match charset {
TokenCharset::Alpha => Self::CHARSET_ALPHA,
TokenCharset::Alphanumeric => Self::CHARSET_ALPHANUMERIC,
TokenCharset::Base64 => Self::CHARSET_BASE64,
TokenCharset::Hex => b"0123456789abcdef",
};
let mut rng = rand::thread_rng();
let chars_len = charset_bytes.len();
(0..length)
.map(|_| {
let idx = rng.gen_range(0..chars_len);
charset_bytes[idx] as char
})
.collect()
}
pub fn generate_secure(length: usize) -> String {
let mut bytes = vec![0u8; length];
rand::Rng::fill(&mut rand::thread_rng(), &mut bytes);
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes)
.trim_end_matches('=')
.to_string()
}
pub fn generate_uuid() -> String {
Uuid::new_v4().to_string()
}
pub fn generate_api_key(prefix: &str, length: usize) -> String {
let token = Self::generate_secure(length);
format!("{}_{}", prefix, token)
}
pub fn generate_hex(length: usize) -> String {
Self::generate(length, TokenCharset::Hex)
}
}
#[derive(Debug, Clone, Copy)]
pub enum TokenCharset {
Alpha,
Alphanumeric,
Base64,
Hex,
}
pub fn generate_token_value(length: Option<usize>, include_special: bool) -> String {
let length = length.unwrap_or(32);
if include_special {
generate_secure_token_with_special(length)
} else {
TokenGenerator::generate_secure(length)
}
}
fn generate_secure_token_with_special(length: usize) -> String {
let special_chars = b"!@#$%^&*()_+-=[]{}|;:,.<>?";
let base_chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
let mut result = String::with_capacity(length);
for i in 0..length {
if i % 4 == 3 && length - i > 2 {
let idx = rng.gen_range(0..special_chars.len());
result.push(special_chars[idx] as char);
} else {
let idx = rng.gen_range(0..base_chars.len());
result.push(base_chars[idx] as char);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_generation() {
let token = TokenGenerator::generate(32, TokenCharset::Alphanumeric);
assert_eq!(token.len(), 32);
}
#[test]
fn test_secure_token_generation() {
let token = TokenGenerator::generate_secure(32);
assert!(!token.is_empty());
assert!(token.len() >= 32);
}
#[test]
fn test_uuid_generation() {
let uuid1 = TokenGenerator::generate_uuid();
let uuid2 = TokenGenerator::generate_uuid();
assert_ne!(uuid1, uuid2);
assert!(uuid1.parse::<uuid::Uuid>().is_ok());
}
#[test]
fn test_token_data_expiration() {
let token_data = TokenData::new("test".to_string(), Some(30));
assert!(!token_data.is_expired());
}
#[test]
fn test_generate_hex() {
let hex = TokenGenerator::generate_hex(32);
assert_eq!(hex.len(), 32);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
}

338
src/vault.rs Normal file
View File

@@ -0,0 +1,338 @@
use crate::token::{TokenData, TokenGenerator, generate_token_value};
use crate::crypto::{CryptoManager, CryptoError, generate_salt, salt_to_base64, salt_from_base64};
use chrono::{DateTime, Utc};
use serde::{Serialize, Deserialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use dirs;
#[derive(Debug, Serialize, Deserialize)]
pub struct Vault {
pub project_name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub salt: String,
pub tokens: HashMap<String, TokenData>,
version: u32,
}
#[derive(Debug)]
pub enum VaultError {
NotInitialized,
InvalidPassword,
CorruptedData,
FileNotFound(String),
IoError(String),
TokenNotFound(String),
TokenAlreadyExists(String),
}
impl std::fmt::Display for VaultError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VaultError::NotInitialized => write!(f, "Vault not initialized. Run 'init' first."),
VaultError::InvalidPassword => write!(f, "Invalid master password"),
VaultError::CorruptedData => write!(f, "Vault file is corrupted"),
VaultError::FileNotFound(path) => write!(f, "Vault file not found: {}", path),
VaultError::IoError(msg) => write!(f, "IO error: {}", msg),
VaultError::TokenNotFound(name) => write!(f, "Token not found: {}", name),
VaultError::TokenAlreadyExists(name) => write!(f, "Token already exists: {}", name),
}
}
}
impl std::error::Error for VaultError {}
impl Vault {
const VERSION: u32 = 1;
pub fn initialize(password: &str, project_name: &str) -> Result<Self, VaultError> {
let salt = generate_salt();
let salt_b64 = salt_to_base64(&salt);
let crypto = CryptoManager::from_password(password, &salt)
.map_err(|_| VaultError::InvalidPassword)?;
let vault = Vault {
project_name: project_name.to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
salt: salt_b64,
tokens: HashMap::new(),
version: Self::VERSION,
};
vault.save_to_file(password)?;
Ok(vault)
}
pub fn load(password: &str, project_name: &str) -> Result<Self, VaultError> {
let vault_path = Self::get_vault_path(project_name)?;
if !vault_path.exists() {
return Err(VaultError::NotInitialized);
}
let content = fs::read_to_string(&vault_path)
.map_err(|e| VaultError::IoError(e.to_string()))?;
let encrypted_data: EncryptedVaultData = serde_json::from_str(&content)
.map_err(|_| VaultError::CorruptedData)?;
let salt = salt_from_base64(&encrypted_data.salt)
.map_err(|_| VaultError::CorruptedData)?;
let crypto = CryptoManager::from_password(password, &salt)
.map_err(|_| VaultError::InvalidPassword)?;
let json_data = crypto.decrypt_base64(&encrypted_data.encrypted_data)
.map_err(|_| VaultError::InvalidPassword)?;
let mut vault: Vault = serde_json::from_str(&json_data)
.map_err(|_| VaultError::CorruptedData)?;
vault.updated_at = Utc::now();
Ok(vault)
}
pub fn save(&mut self, password: &str) -> Result<(), VaultError> {
self.updated_at = Utc::now();
self.save_to_file(password)
}
fn save_to_file(&self, password: &str) -> Result<(), VaultError> {
let salt = salt_from_base64(&self.salt)
.map_err(|_| VaultError::CorruptedData)?;
let crypto = CryptoManager::from_password(password, &salt)
.map_err(|_| VaultError::InvalidPassword)?;
let json_data = serde_json::to_string(self)
.map_err(|_| VaultError::CorruptedData)?;
let encrypted_data = crypto.encrypt_base64(&json_data)
.map_err(|_| VaultError::CorruptedData)?;
let vault_path = Self::get_vault_path(&self.project_name)?;
if let Some(parent) = vault_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| VaultError::IoError(e.to_string()))?;
}
}
let encrypted_vault = EncryptedVaultData {
version: self.version,
salt: self.salt.clone(),
encrypted_data,
};
let content = serde_json::to_string(&encrypted_vault)
.map_err(|_| VaultError::CorruptedData)?;
fs::write(&vault_path, content)
.map_err(|e| VaultError::IoError(e.to_string()))?;
Ok(())
}
pub fn get_vault_path(project_name: &str) -> Result<PathBuf, VaultError> {
let config_dir = dirs::config_dir()
.ok_or_else(|| VaultError::IoError("Could not find config directory".to_string()))?;
let vault_dir = config_dir.join("api-token-vault");
let vault_file = vault_dir.join(format!("{}.json", project_name));
Ok(vault_file)
}
pub fn generate_token(&mut self, name: &str, length: usize) -> Result<String, VaultError> {
if self.tokens.contains_key(name) {
return Err(VaultError::TokenAlreadyExists(name.to_string()));
}
let value = generate_token_value(Some(length), false);
let token_data = TokenData::new(value.clone(), None);
self.tokens.insert(name.to_string(), token_data);
Ok(value)
}
pub fn get_token(&self, name: &str) -> Option<&TokenData> {
self.tokens.get(name)
}
pub fn remove_token(&mut self, name: &str) -> Result<(), VaultError> {
if !self.tokens.contains_key(name) {
return Err(VaultError::TokenNotFound(name.to_string()));
}
self.tokens.remove(name);
Ok(())
}
pub fn rotate_token(&mut self, name: &str, force: bool) -> Result<String, VaultError> {
let token_data = self.tokens.get_mut(name)
.ok_or_else(|| VaultError::TokenNotFound(name.to_string()))?;
if !force && !token_data.should_rotate() {
return Err(VaultError::TokenNotFound(name.to_string()));
}
let new_value = generate_token_value(Some(32), false);
let rotation_days = token_data.rotation_days;
*token_data = TokenData::new(new_value.clone(), rotation_days);
token_data.last_rotated = Some(Utc::now());
Ok(new_value)
}
pub fn set_rotation(&mut self, name: &str, days: u32) -> Result<(), VaultError> {
let token_data = self.tokens.get_mut(name)
.ok_or_else(|| VaultError::TokenNotFound(name.to_string()))?;
token_data.auto_rotate = true;
token_data.rotation_days = Some(days);
token_data.expires_at = Some(Utc::now() + chrono::Duration::days(days as i64));
Ok(())
}
pub fn check_expired_tokens(&self) -> HashMap<String, &TokenData> {
self.tokens.iter()
.filter(|(_, token)| token.is_expired())
.map(|(k, v)| (k.clone(), v))
.collect()
}
pub fn rotate_expired_tokens(&mut self) -> Vec<String> {
let expired: Vec<String> = self.tokens.iter()
.filter(|(_, token)| token.should_rotate())
.map(|(k, _)| k.clone())
.collect();
let mut rotated = Vec::new();
for name in &expired {
if let Ok(new_value) = self.rotate_token(name, true) {
println!("Rotated {}: {}", name, new_value);
rotated.push(name.clone());
}
}
rotated
}
pub fn add_token(&mut self, name: &str, value: &str, rotation_days: Option<u32>) -> Result<(), VaultError> {
if self.tokens.contains_key(name) {
return Err(VaultError::TokenAlreadyExists(name.to_string()));
}
let token_data = TokenData::new(value.to_string(), rotation_days);
self.tokens.insert(name.to_string(), token_data);
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
struct EncryptedVaultData {
version: u32,
salt: String,
encrypted_data: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn get_test_vault_path() -> PathBuf {
PathBuf::from("/tmp/test-api-token-vault")
}
#[test]
fn test_vault_initialization() {
let password = "test_password";
let project = "test_project";
let vault = Vault::initialize(password, project);
assert!(vault.is_ok());
let vault = vault.unwrap();
assert_eq!(vault.project_name, project);
assert!(vault.tokens.is_empty());
assert!(!vault.salt.is_empty());
}
#[test]
fn test_vault_save_and_load() {
let password = "test_password";
let project = "test_load_project";
let mut vault = Vault::initialize(password, project).unwrap();
vault.generate_token("test_token", 32).unwrap();
let vault_path = Vault::get_vault_path(project).unwrap();
if vault_path.exists() {
fs::remove_file(&vault_path).unwrap();
}
vault.save_to_file(password).unwrap();
let loaded_vault = Vault::load(password, project);
assert!(loaded_vault.is_ok());
let loaded_vault = loaded_vault.unwrap();
assert_eq!(loaded_vault.tokens.len(), 1);
assert!(loaded_vault.tokens.contains_key("test_token"));
if vault_path.exists() {
fs::remove_file(&vault_path).unwrap();
}
}
#[test]
fn test_wrong_password() {
let password = "correct_password";
let wrong_password = "wrong_password";
let project = "test_wrong_password";
let vault = Vault::initialize(password, project).unwrap();
let vault_path = Vault::get_vault_path(project).unwrap();
vault.save_to_file(password).unwrap();
let loaded = Vault::load(wrong_password, project);
assert!(loaded.is_err());
if vault_path.exists() {
fs::remove_file(&vault_path).unwrap();
}
}
#[test]
fn test_token_rotation() {
let password = "test_password";
let project = "test_rotation";
let mut vault = Vault::initialize(password, project).unwrap();
vault.generate_token("rotate_me", 32).unwrap();
let original_token = vault.get_token("rotate_me").unwrap().value.clone();
let new_value = vault.rotate_token("rotate_me", true).unwrap();
assert_ne!(original_token, new_value);
let vault_path = Vault::get_vault_path(project).unwrap();
if vault_path.exists() {
fs::remove_file(&vault_path).unwrap();
}
}
}

418
tests/integration_tests.rs Normal file
View File

@@ -0,0 +1,418 @@
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::process::Command;
use std::fs;
use std::path::PathBuf;
fn get_test_vault_path() -> PathBuf {
PathBuf::from("/tmp/api-token-vault-test-vault.json")
}
fn cleanup_test_vault() {
let vault_path = get_test_vault_path();
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
}
#[test]
fn test_cli_help() {
let mut cmd = Command::cargo_bin("api-token-vault").unwrap();
cmd.arg("--help");
cmd.assert()
.success()
.stdout(predicate::str::contains("API Token Vault"));
}
#[test]
fn test_cli_version() {
let mut cmd = Command::cargo_bin("api-token-vault").unwrap();
cmd.arg("--version");
cmd.assert()
.success()
.stdout(predicate::str::contains("0.1.0"));
}
#[test]
fn test_init_command() {
cleanup_test_vault();
let mut cmd = Command::cargo_bin("api-token-vault").unwrap();
cmd.arg("init")
.arg("--project")
.arg("test-init-project")
.env("API_VAULT_PROJECT", "");
let assert = cmd.assert();
cleanup_test_vault();
}
#[test]
fn test_generate_command() {
cleanup_test_vault();
let password = "test_password_123";
let project = "test-gen-project";
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
let vault_path = vault_dir.join("test-gen-project.json");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if !vault_dir.exists() {
let _ = fs::create_dir_all(&vault_dir);
}
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
init_cmd.arg("init")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = init_cmd.output();
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
gen_cmd.arg("generate")
.arg("--name")
.arg("test_token")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
gen_cmd.assert()
.success()
.stdout(predicate::str::contains("Generated token"));
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
}
#[test]
fn test_list_command() {
cleanup_test_vault();
let password = "test_password";
let project = "test-list-project";
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
let vault_path = vault_dir.join("test-list-project.json");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if !vault_dir.exists() {
let _ = fs::create_dir_all(&vault_dir);
}
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
init_cmd.arg("init")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = init_cmd.output();
let mut list_cmd = Command::cargo_bin("api-token-vault").unwrap();
list_cmd.arg("list")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
list_cmd.assert()
.success()
.stdout(predicate::str::contains("Tokens in vault"));
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
}
#[test]
fn test_rotate_command() {
cleanup_test_vault();
let password = "test_password";
let project = "test-rotate-project";
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
let vault_path = vault_dir.join("test-rotate-project.json");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if !vault_dir.exists() {
let _ = fs::create_dir_all(&vault_dir);
}
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
init_cmd.arg("init")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = init_cmd.output();
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
gen_cmd.arg("generate")
.arg("--name")
.arg("rotate_me")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = gen_cmd.output();
let mut rotate_cmd = Command::cargo_bin("api-token-vault").unwrap();
rotate_cmd.arg("rotate")
.arg("--name")
.arg("rotate_me")
.arg("--force")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
rotate_cmd.assert()
.success()
.stdout(predicate::str::contains("Rotated token"));
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
}
#[test]
fn test_set_rotation_command() {
cleanup_test_vault();
let password = "test_password";
let project = "test-set-rotation-project";
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
let vault_path = vault_dir.join("test-set-rotation-project.json");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if !vault_dir.exists() {
let _ = fs::create_dir_all(&vault_dir);
}
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
init_cmd.arg("init")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = init_cmd.output();
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
gen_cmd.arg("generate")
.arg("--name")
.arg("auto_rotate_token")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = gen_cmd.output();
let mut set_cmd = Command::cargo_bin("api-token-vault").unwrap();
set_cmd.arg("set-rotation")
.arg("--name")
.arg("auto_rotate_token")
.arg("--days")
.arg("30")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
set_cmd.assert()
.success()
.stdout(predicate::str::contains("Auto-rotation set"));
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
}
#[test]
fn test_check_expired_command() {
cleanup_test_vault();
let password = "test_password";
let project = "test-check-expired-project";
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
let vault_path = vault_dir.join("test-check-expired-project.json");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if !vault_dir.exists() {
let _ = fs::create_dir_all(&vault_dir);
}
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
init_cmd.arg("init")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = init_cmd.output();
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
gen_cmd.arg("generate")
.arg("--name")
.arg("expired_token")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = gen_cmd.output();
let mut check_cmd = Command::cargo_bin("api-token-vault").unwrap();
check_cmd.arg("check-expired")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
check_cmd.assert()
.success();
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
}
#[test]
fn test_inject_command() {
cleanup_test_vault();
let password = "test_password";
let project = "test-inject-project";
let env_path = PathBuf::from("/tmp/test-inject.env");
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
let vault_path = vault_dir.join("test-inject-project.json");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if !vault_dir.exists() {
let _ = fs::create_dir_all(&vault_dir);
}
if env_path.exists() {
let _ = fs::remove_file(&env_path);
}
let mut init_cmd = Command::cargo_bin("api-token-vault").unwrap();
init_cmd.arg("init")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = init_cmd.output();
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
gen_cmd.arg("generate")
.arg("--name")
.arg("injectable_token")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = gen_cmd.output();
let mut inject_cmd = Command::cargo_bin("api-token-vault").unwrap();
inject_cmd.arg("inject")
.arg("--env-file")
.arg(env_path.to_str().unwrap())
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
inject_cmd.assert()
.success()
.stdout(predicate::str::contains("injected"));
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if env_path.exists() {
let _ = fs::remove_file(&env_path);
}
}
#[test]
fn test_dry_run_inject() {
cleanup_test_vault();
let password = "test_password";
let project = "test-dry-run-project";
let env_path = PathBuf::from("/tmp/test-dry-run.env");
let vault_dir = PathBuf::from("/tmp/.config/api-token-vault");
let vault_path = vault_dir.join("test-dry-run-project.json");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if !vault_dir.exists() {
let _ = fs::create_dir_all(&vault_dir);
}
if env_path.exists() {
let _ = fs::remove_file(&env_path);
}
let mut init_cmd = Command::cargo_bin("token-vault").unwrap();
init_cmd.arg("init")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = init_cmd.output();
let mut gen_cmd = Command::cargo_bin("api-token-vault").unwrap();
gen_cmd.arg("generate")
.arg("--name")
.arg("dry_token")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
let _ = gen_cmd.output();
fs::write(&env_path, "EXISTING=value").unwrap();
let mut inject_cmd = Command::cargo_bin("api-token-vault").unwrap();
inject_cmd.arg("inject")
.arg("--env-file")
.arg(env_path.to_str().unwrap())
.arg("--dry-run")
.arg("--master-password")
.arg(password)
.arg("--project")
.arg(project);
inject_cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
let content = fs::read_to_string(&env_path).unwrap();
assert_eq!(content.trim(), "EXISTING=value");
if vault_path.exists() {
let _ = fs::remove_file(&vault_path);
}
if env_path.exists() {
let _ = fs::remove_file(&env_path);
}
}