Initial upload: api-token-vault Rust CLI tool with encrypted vault storage
This commit is contained in:
320
app/api-token-vault/src/env_injector.rs
Normal file
320
app/api-token-vault/src/env_injector.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user