diff --git a/src/env_injector.rs b/src/env_injector.rs new file mode 100644 index 0000000..4b66b6f --- /dev/null +++ b/src/env_injector.rs @@ -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, +} + +impl EnvEntry { + pub fn from_line(line: &str, line_num: usize) -> Option { + 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, + 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 = Vec::new(); + + let mut existing_keys: Vec = existing_entries.iter() + .map(|e| e.key.clone()) + .collect(); + + let mut lines: Vec = 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, 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, 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 { + 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, 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(); + } + } +}