diff --git a/Cargo.lock b/Cargo.lock index 6f84cf9..2ff13d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,6 +968,7 @@ dependencies = [ "secret", "shellflip", "smol_str", + "tempfile", "tokio", "wasmtime", ] @@ -3406,9 +3407,9 @@ checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" [[package]] name = "tempfile" -version = "3.19.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", "getrandom 0.3.2", diff --git a/Cargo.toml b/Cargo.toml index fcf9b7b..6c67ab2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,9 @@ publish.workspace = true authors.workspace = true +[dev-dependencies] +tempfile = "3.20.0" + [dependencies] anyhow = { workspace = true } hyper = { workspace = true } diff --git a/src/dotenv.rs b/src/dotenv.rs new file mode 100644 index 0000000..3f79f1b --- /dev/null +++ b/src/dotenv.rs @@ -0,0 +1,527 @@ +use smol_str::SmolStr; +use std::collections::HashMap; +use std::env::current_dir; +use std::fs; +use std::path::{Path, PathBuf}; + +// Define an enum for environment types +#[derive(Debug, PartialEq)] +pub enum EnvArgType { + RspHeader, + ReqHeader, + Env, + Secrets, +} + +#[derive(Debug, PartialEq)] +enum EnvArgFileType { + RspHeaders, + ReqHeaders, + Variables, + Secrets, + DotEnv, +} + +impl From for EnvArgFileType { + fn from(env_arg_type: EnvArgType) -> Self { + match env_arg_type { + EnvArgType::RspHeader => EnvArgFileType::RspHeaders, + EnvArgType::ReqHeader => EnvArgFileType::ReqHeaders, + EnvArgType::Env => EnvArgFileType::Variables, + EnvArgType::Secrets => EnvArgFileType::Secrets, + } + } +} + +pub struct DotEnvInjector { + file_path: PathBuf, +} + +impl DotEnvInjector { + pub fn new(file_path: Option) -> Self { + let file_path = match file_path { + Some(path) => path, + None => current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf()), + }; + Self { file_path } + } + + pub fn merge_with_dotenv_variables( + &self, + should_read_dotenv: bool, + env_arg_type: EnvArgType, + args_variables: HashMap, + ) -> HashMap { + if !should_read_dotenv { + return args_variables; + } + + let mut merged_variables = HashMap::new(); + + // Start with the provided arguments + for (key, value) in args_variables { + merged_variables.insert(key.clone(), value.clone()); + } + + // Read and merge .env file variables + if let Ok(dotenv_vars) = self.read_dotenv_file(EnvArgFileType::DotEnv) { + for (key, value) in dotenv_vars { + if let Some(filtered_key) = self.filter_named_keys(&key, &env_arg_type) { + merged_variables.entry(filtered_key).or_insert(value); + } + } + } + + // Read and merge specific .env.{variables|req_headers|rsp_headers|secrets} file variables + let env_arg_file_type: EnvArgFileType = env_arg_type.into(); + if let Ok(dotenv_vars) = self.read_dotenv_file(env_arg_file_type) { + for (key, value) in dotenv_vars { + if !key.starts_with("FASTEDGE_VAR_") { + merged_variables.entry(key).or_insert(value); + } + } + } + + merged_variables + } + + fn filter_named_keys(&self, key: &SmolStr, env_arg_type: &EnvArgType) -> Option { + let env_arg_prefix_str = match env_arg_type { + EnvArgType::RspHeader => "FASTEDGE_VAR_RSP_HEADER_", + EnvArgType::ReqHeader => "FASTEDGE_VAR_REQ_HEADER_", + EnvArgType::Env => "FASTEDGE_VAR_ENV_", + EnvArgType::Secrets => "FASTEDGE_VAR_SECRET_", + }; + + if key.starts_with("FASTEDGE_VAR_") { + if key.starts_with(env_arg_prefix_str) { + Some(SmolStr::new(key.trim_start_matches(env_arg_prefix_str))) + } else { + None + } + } else if *env_arg_type == EnvArgType::Env { + Some(SmolStr::new(key)) + } else { + None + } + } + + fn read_dotenv_file( + &self, + env_arg_file_type: EnvArgFileType, + ) -> Result, std::io::Error> { + let env_arg_file_type_str = match env_arg_file_type { + EnvArgFileType::RspHeaders => ".env.rsp_headers", + EnvArgFileType::ReqHeaders => ".env.req_headers", + EnvArgFileType::Variables => ".env.variables", + EnvArgFileType::Secrets => ".env.secrets", + EnvArgFileType::DotEnv => ".env", + }; + + let filename = self.file_path.join(env_arg_file_type_str); + let mut variables = HashMap::new(); + + if let Ok(lines) = fs::read_to_string(filename) { + for _line in lines.lines() { + let line = match _line.split_once('#') { + Some((line, _)) => line, + None => _line, + }; + if line.trim().is_empty() { + continue; + } + if let Some((key, value)) = line.split_once('=') { + // Insert the key-value pair into the HashMap + variables.insert( + self.strip_quoted_strings(key), + self.strip_quoted_strings(value), + ); + } + } + } + Ok(variables) + } + + fn strip_quoted_strings(&self, value: &str) -> SmolStr { + SmolStr::new(value.trim().trim_matches(|c| c == '"' || c == '\'')) + } +} + +#[cfg(test)] +mod dotenv_injector_tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_read_dotenv_file() { + // Create a temporary directory + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join(".env"); + fs::write( + &file_path, + "\ + KEY1=value1 + KEY2=value2 + KEY3==value3 + KEY4=\"value4\" + 'KEY5'='value5' + KEY6=\"some value with spaces\" + KEY7=\"some \"edge\" case with = and `quotes`\" + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + let result = injector.read_dotenv_file(EnvArgFileType::DotEnv).unwrap(); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY1"), SmolStr::new("value1")); + expected.insert(SmolStr::new("KEY2"), SmolStr::new("value2")); + expected.insert(SmolStr::new("KEY3"), SmolStr::new("=value3")); + expected.insert(SmolStr::new("KEY4"), SmolStr::new("value4")); + expected.insert(SmolStr::new("KEY5"), SmolStr::new("value5")); + expected.insert(SmolStr::new("KEY6"), SmolStr::new("some value with spaces")); + expected.insert( + SmolStr::new("KEY7"), + SmolStr::new("some \"edge\" case with = and `quotes`"), + ); + + assert_eq!(result, expected); + } + + #[test] + fn test_read_dotenv_file_with_errored_lines() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join(".env"); + fs::write( + &file_path, + "\ + KEY1=value1 + This line is not valid + KEY2=value2 + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + let result = injector.read_dotenv_file(EnvArgFileType::DotEnv).unwrap(); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY1"), SmolStr::new("value1")); + expected.insert(SmolStr::new("KEY2"), SmolStr::new("value2")); + + assert_eq!(result, expected); + } + + #[test] + fn test_read_dotenv_secrets_file_with_comments() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join(".env.secrets"); + fs::write( + &file_path, + "\ + KEY3=value3 + # some_comment=value + KEY4 = value4 # This is a comment - ignore it + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + let result = injector.read_dotenv_file(EnvArgFileType::Secrets).unwrap(); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY3"), SmolStr::new("value3")); + expected.insert(SmolStr::new("KEY4"), SmolStr::new("value4")); + + assert_eq!(result, expected); + } + + #[test] + fn test_read_dotenv_rsp_headers_file_with_comments_and_empty_lines() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join(".env.rsp_headers"); + fs::write( + &file_path, + "\ + #first line is rubbish + + KEY5=value5 + # another=comment_value + + KEY6=value6 + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + let result = injector + .read_dotenv_file(EnvArgFileType::RspHeaders) + .unwrap(); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY5"), SmolStr::new("value5")); + expected.insert(SmolStr::new("KEY6"), SmolStr::new("value6")); + + assert_eq!(result, expected); + } + + #[test] + fn test_merge_with_dotenv_variables_disabled() { + let temp_dir = tempdir().unwrap(); + let env_file_path = temp_dir.path().join(".env"); + fs::write( + &env_file_path, + "\ + KEY1=value1 + KEY2=value2 # this is a comment - ignore it + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + + let mut args_variables = HashMap::new(); + args_variables.insert(SmolStr::new("KEY2"), SmolStr::new("override_value2")); + args_variables.insert(SmolStr::new("KEY3"), SmolStr::new("value3")); + + let expected = args_variables.clone(); + + // Invoking merge_with_dotenv_variables dsiabled via "should_read_dotenv=false" + // Does nothing and only pass back the args_variables + let result = injector.merge_with_dotenv_variables(false, EnvArgType::Env, args_variables); + + assert_eq!(result, expected); + } + + #[test] + fn test_merge_with_dotenv_variables_with_no_values_or_file() { + let temp_dir = tempdir().unwrap(); + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + + let args_variables = HashMap::new(); + let result = injector.merge_with_dotenv_variables(true, EnvArgType::Env, args_variables); + let expected = HashMap::new(); + + assert_eq!(result, expected); + } + + #[test] + fn test_merge_with_dotenv_variables() { + let temp_dir = tempdir().unwrap(); + let env_file_path = temp_dir.path().join(".env"); + fs::write( + &env_file_path, + "\ + KEY1=value1 # This is a comment - ignore it + KEY2=value2 + KEY3=\"my own string value\" + # KEY4=value4 This is also a comment + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + + let mut args_variables = HashMap::new(); + args_variables.insert(SmolStr::new("KEY2"), SmolStr::new("override_value2")); + args_variables.insert(SmolStr::new("KEY3"), SmolStr::new("value3")); + + let result = injector.merge_with_dotenv_variables(true, EnvArgType::Env, args_variables); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY1"), SmolStr::new("value1")); + expected.insert(SmolStr::new("KEY2"), SmolStr::new("override_value2")); + expected.insert(SmolStr::new("KEY3"), SmolStr::new("value3")); + + assert_eq!(result, expected); + } + + #[test] + fn test_merge_with_dotenv_variables_and_dotenv_variables() { + let temp_dir = tempdir().unwrap(); + let env_file_path = temp_dir.path().join(".env"); + fs::write( + &env_file_path, + "\ + KEY1=value1 + KEY2=value2 + FASTEDGE_VAR_ENV_KEY3=value3 + FASTEDGE_VAR_SECRET_KEY6=secret26 # Ignore this line as it starts with FASTEDGE_VAR_SECRET_ prefix + ", + ) + .unwrap(); + + let variables_file_path = temp_dir.path().join(".env.variables"); + fs::write( + &variables_file_path, + "\ + KEY4=value4 + KEY5=value5 + FASTEDGE_VAR_ENV_KEY6=value6 # Ignore this line as it starts with FASTEDGE_VAR_ prefix ( only allowed in .env ) + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + + let mut args_variables = HashMap::new(); + args_variables.insert(SmolStr::new("KEY2"), SmolStr::new("override_value2")); + args_variables.insert(SmolStr::new("KEY4"), SmolStr::new("override_value4")); + + let result = injector.merge_with_dotenv_variables(true, EnvArgType::Env, args_variables); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY1"), SmolStr::new("value1")); + expected.insert(SmolStr::new("KEY2"), SmolStr::new("override_value2")); + expected.insert(SmolStr::new("KEY3"), SmolStr::new("value3")); + expected.insert(SmolStr::new("KEY4"), SmolStr::new("override_value4")); + expected.insert(SmolStr::new("KEY5"), SmolStr::new("value5")); + + assert_eq!(result, expected); + } + + #[test] + fn test_merge_with_dotenv_variables_and_dotenv_rsp_headers() { + let temp_dir = tempdir().unwrap(); + let env_file_path = temp_dir.path().join(".env"); + fs::write( + &env_file_path, + "\ + KEY1=value1 # Ignore this line as it is not a rsp_header + KEY2=value2 # Ignore this line as it is not a rsp_header + FASTEDGE_VAR_RSP_HEADER_KEY3=rsp_header3 + FASTEDGE_VAR_RSP_HEADER_KEY4=rsp_header4 + FASTEDGE_VAR_SECRET_KEY5=secret5 # Ignore this line as it is not a rsp_header + ", + ) + .unwrap(); + + let rsp_headers_file_path = temp_dir.path().join(".env.rsp_headers"); + fs::write( + &rsp_headers_file_path, + "\ + KEY6=rsp_header6 + KEY7=rsp_header7 + FASTEDGE_VAR_RSP_HEADER_KEY8=value8 # Ignore this line as it starts with FASTEDGE_VAR_ prefix ( only allowed in .env ) + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + + let mut headers_variables = HashMap::new(); //rename these across all tests + headers_variables.insert(SmolStr::new("KEY4"), SmolStr::new("override_rsp_header4")); + headers_variables.insert(SmolStr::new("KEY6"), SmolStr::new("override_rsp_header6")); + + let result = + injector.merge_with_dotenv_variables(true, EnvArgType::RspHeader, headers_variables); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY3"), SmolStr::new("rsp_header3")); + expected.insert(SmolStr::new("KEY4"), SmolStr::new("override_rsp_header4")); + expected.insert(SmolStr::new("KEY6"), SmolStr::new("override_rsp_header6")); + expected.insert(SmolStr::new("KEY7"), SmolStr::new("rsp_header7")); + + assert_eq!(result, expected); + } + + #[test] + fn test_merge_with_dotenv_variables_and_dotenv_secrets() { + let temp_dir = tempdir().unwrap(); + let env_file_path = temp_dir.path().join(".env"); + fs::write( + &env_file_path, + "\ + KEY1=value1 # Ignore this line as it is not a secret + KEY2=value2 # Ignore this line as it is not a secret + FASTEDGE_VAR_SECRET_KEY3=secret3 + FASTEDGE_VAR_SECRET_KEY4=secret4 + FASTEDGE_VAR_ENV_KEY5=value5 # Ignore this line as it is not a secret + ", + ) + .unwrap(); + + let secrets_file_path = temp_dir.path().join(".env.secrets"); + fs::write( + &secrets_file_path, + "\ + KEY6=secret6 + KEY7=secret7 + FASTEDGE_VAR_SECRET_KEY8=value8 # Ignore this line as it starts with FASTEDGE_VAR_ prefix ( only allowed in .env ) + FASTEDGE_VAR_RSP_HEADER_KEY9=value9 # Ignore this line as it starts with FASTEDGE_VAR_ prefix ( only allowed in .env ) + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + + let mut secret_variables = HashMap::new(); + secret_variables.insert(SmolStr::new("KEY4"), SmolStr::new("override_secret4")); + secret_variables.insert(SmolStr::new("KEY6"), SmolStr::new("override_secret6")); + secret_variables.insert(SmolStr::new("ARGSECRET_ONLY"), SmolStr::new("new_secret8")); + + let result = + injector.merge_with_dotenv_variables(true, EnvArgType::Secrets, secret_variables); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY3"), SmolStr::new("secret3")); + expected.insert(SmolStr::new("KEY4"), SmolStr::new("override_secret4")); + expected.insert(SmolStr::new("KEY6"), SmolStr::new("override_secret6")); + expected.insert(SmolStr::new("KEY7"), SmolStr::new("secret7")); + expected.insert(SmolStr::new("ARGSECRET_ONLY"), SmolStr::new("new_secret8")); + + assert_eq!(result, expected); + } + + #[test] + fn test_merge_with_dotenv_variables_and_dotenv_req_headers() { + let temp_dir = tempdir().unwrap(); + let env_file_path = temp_dir.path().join(".env"); + fs::write( + &env_file_path, + "\ + KEY1=value1 # Ignore this line as it is not a req_header + KEY2=value2 # Ignore this line as it is not a req_header + FASTEDGE_VAR_REQ_HEADER_KEY3=header3 + FASTEDGE_VAR_SECRET_KEY4=secret4 # Ignore this line as it is not a req_header + FASTEDGE_VAR_RSP_HEADER_KEY5=rsp_header5 # Ignore this line as it is not a req_header + FASTEDGE_VAR_ENV_KEY6=value6 # Ignore this line as it is not a req_header + FASTEDGE_VAR_REQ_HEADER_KEY7=header7 + ", + ) + .unwrap(); + + let secrets_file_path = temp_dir.path().join(".env.req_headers"); + fs::write( + &secrets_file_path, + "\ + KEY8=header8 + KEY9=header9 + FASTEDGE_VAR_REQ_HEADER_KEY10=req_header10 # Ignore this line as it starts with FASTEDGE_VAR_ prefix ( only allowed in .env ) + FASTEDGE_VAR_RSP_HEADER_KEY11=rsp_header11 # Ignore this line as it starts with FASTEDGE_VAR_ prefix ( only allowed in .env ) + ", + ) + .unwrap(); + + let injector = DotEnvInjector::new(Some(temp_dir.path().to_path_buf())); + + let mut req_header_variables = HashMap::new(); + req_header_variables.insert(SmolStr::new("KEY7"), SmolStr::new("override_header7")); + req_header_variables.insert(SmolStr::new("KEY8"), SmolStr::new("override_header8")); + req_header_variables.insert(SmolStr::new("ARGSECRET_ONLY"), SmolStr::new("header12")); + + let result = + injector.merge_with_dotenv_variables(true, EnvArgType::ReqHeader, req_header_variables); + + let mut expected = HashMap::new(); + expected.insert(SmolStr::new("KEY3"), SmolStr::new("header3")); + expected.insert(SmolStr::new("KEY7"), SmolStr::new("override_header7")); + expected.insert(SmolStr::new("KEY8"), SmolStr::new("override_header8")); + expected.insert(SmolStr::new("KEY9"), SmolStr::new("header9")); + expected.insert(SmolStr::new("ARGSECRET_ONLY"), SmolStr::new("header12")); + + assert_eq!(result, expected); + } +} diff --git a/src/main.rs b/src/main.rs index f67b93f..ed7282a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod context; +mod dotenv; mod executor; mod key_value; mod secret; @@ -6,6 +7,7 @@ mod secret; use bytesize::MB; use clap::{Args, Parser, Subcommand}; use context::Context; +use dotenv::{DotEnvInjector, EnvArgType}; use http_backend::{Backend, BackendStrategy}; use http_service::{HttpConfig, HttpService}; use hyper_tls::HttpsConnector; @@ -64,6 +66,12 @@ struct HttpRunArgs { /// Secret variable list #[arg(short, long, value_parser = parse_key_value::)] secret: Option>, + /// Dotenv file path + #[arg(long, num_args = 0..=1)] + dotenv: Option>, + /// Headers added to response + #[arg(long, value_parser = parse_key_value::< SmolStr, SmolStr >)] + rsp_headers: Option>, } #[tokio::main] @@ -91,25 +99,50 @@ async fn main() -> anyhow::Result<()> { ); let backend = builder.build(backend_connector); + + let has_dotenv_flag = run.dotenv.is_some(); + + let dotenv_injector = if let Some(dotenv_path) = run.dotenv.flatten() { + DotEnvInjector::new(Some(dotenv_path)) + } else { + DotEnvInjector::new(None) + }; + + let rsp_headers = dotenv_injector.merge_with_dotenv_variables( + has_dotenv_flag, + EnvArgType::RspHeader, + run.rsp_headers.unwrap_or_default().into_iter().collect(), + ); + + let env = dotenv_injector.merge_with_dotenv_variables( + has_dotenv_flag, + EnvArgType::Env, + run.env.unwrap_or_default().into_iter().collect(), + ); + + let secret_args = dotenv_injector.merge_with_dotenv_variables( + has_dotenv_flag, + EnvArgType::Secrets, + run.secret.unwrap_or_default().into_iter().collect(), + ); + let mut secrets = vec![]; - if let Some(secret) = run.secret { - for (name, s) in secret.into_iter() { - secrets.push(runtime::app::SecretOption { - name, - secret_values: vec![SecretValue { - effective_from: 0, - value: s.to_string(), - }], - }); - } + for (name, s) in secret_args { + secrets.push(runtime::app::SecretOption { + name, + secret_values: vec![SecretValue { + effective_from: 0, + value: s.to_string(), + }], + }); } let cli_app = App { binary_id: 0, max_duration: run.max_duration.map(|m| m / 10).unwrap_or(60000), mem_limit: run.mem_limit.unwrap_or((128 * MB) as usize), - env: run.env.unwrap_or_default().into_iter().collect(), - rsp_headers: Default::default(), + env, + rsp_headers, log: Default::default(), app_id: 0, client_id: 0, @@ -120,8 +153,11 @@ async fn main() -> anyhow::Result<()> { kv_stores: vec![], }; - let mut headers: HashMap = - run.headers.unwrap_or_default().into_iter().collect(); + let mut headers = dotenv_injector.merge_with_dotenv_variables( + has_dotenv_flag, + EnvArgType::ReqHeader, + run.headers.unwrap_or_default().into_iter().collect(), + ); append_headers(run.geo, &mut headers);