Initial upload: api-token-vault Rust CLI tool with encrypted vault storage

This commit is contained in:
2026-01-31 22:54:35 +00:00
parent 8f743c7184
commit 35504ace62

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();
}
}
}