From 1374af60e05a5c28fcc7fe02b9101967766cb571 Mon Sep 17 00:00:00 2001 From: Wim Velzeboer Date: Mon, 7 Sep 2026 11:58:27 +0100 Subject: [PATCH] Add config setting for S3 and Salesforce --- rustfmt.toml | 2 + src/cli/mod.rs | 226 +++++++++++++++------------------------- src/cli/set/mod.rs | 62 +++++++++++ src/config.rs | 253 ++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 13 ++- 5 files changed, 406 insertions(+), 150 deletions(-) create mode 100644 rustfmt.toml create mode 100644 src/cli/set/mod.rs diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..86fe606 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +hard_tabs = true \ No newline at end of file diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 19339e5..5cb40b5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,8 @@ use anyhow::{Context, Result}; use clap::{Parser, ValueEnum}; use log::error; +mod set; + #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Cli { @@ -33,6 +35,31 @@ enum Commands { /// Change settings Set { + /// A comma separated list of fallback email address, + /// used when Salesforce cannot be reached to deliver log messages + #[arg(short = 'a', long, env = "IMOJEFS_ADMIN_EMAIL")] + admin_email: Option, + + /// AWS S3 bucket name + #[arg(short = 'b', long = "s3-bucket", env = "IMOJEFS_S3_BUCKET")] + s3_bucket: Option, + + /// AWS Access Key Id + #[arg(short = 'k', long, env = "IMOJEFS_S3_CLIENT_ID")] + s3_client_id: Option, + + /// AWS Secret Access Key + #[arg(short = 's', long, env = "IMOJEFS_S3_CLIENT_SECRET", hide_env_values = true)] + s3_client_secret: Option, + + /// AWS Endpoint URL, [default: AWS url] + #[arg(short = 'e', long, env = "IMOJEFS_S3_ENDPOINT")] + s3_endpoint: Option, + + /// AWS Region + #[arg(short = 'r', long, default_value = "us-east-1", env = "IMOJEFS_S3_REGION")] + s3_region: Option, + /// Salesforce Authentication Url #[arg( short = 'x', @@ -44,76 +71,6 @@ enum Commands { }, } -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -pub struct Args { - /// AWS Bucket name - #[arg(short = 'b', long, env = "IMOJEFS_S3_BUCKET")] - pub s3_bucket: String, - - /// AWS Access Key Id - #[arg(short = 'k', long, env = "IMOJEFS_S3_CLIENT_ID")] - pub s3_client_id: String, - - /// AWS Secret Access Key - #[arg( - short = 's', - long, - env = "IMOJEFS_S3_CLIENT_SECRET", - hide_env_values = true - )] - pub s3_client_secret: String, - - /// AWS Endpoint URL, [default: AWS url] - #[arg(short = 'e', long, env = "IMOJEFS_S3_ENDPOINT")] - pub s3_endpoint: Option, - - /// AWS Region - #[arg( - short = 'r', - long, - default_value = "us-east-1", - env = "IMOJEFS_S3_REGION" - )] - pub s3_region: String, - - /// Salesforce Authentication Url - #[arg( - short = 'x', - long, - env = "IMOJEFS_SFDX_AUTH_URL", - hide_env_values = true - )] - pub sfdx_auth_url: String, - - /// Retry failures after X runs - #[arg(long, default_value_t = 180, env = "IMOJEFS_RETRY")] - pub retry: u64, - - /// A comma separated list of fallback email address, - /// used when Salesforce cannot be reached to deliver log messages - #[arg(short = 'a', long, env = "IMOJEFS_ADMIN_EMAIL")] - pub admin_email: Option, -} - -impl Args { - pub(crate) fn s3_bucket(&self) -> &str { - &self.s3_bucket - } - pub(crate) fn s3_client_id(&self) -> &str { - &self.s3_client_id - } - pub(crate) fn s3_client_secret(&self) -> &str { - &self.s3_client_secret - } - pub(crate) fn s3_endpoint(&self) -> Option<&str> { - self.s3_endpoint.as_deref() - } - pub(crate) fn s3_region(&self) -> &str { - &self.s3_region - } -} - /// Represents the different levels of logging that can be used in an application. /// /// # Variants @@ -133,58 +90,31 @@ pub enum LogLevel { Trace, } -fn load_configuration_file(config_file: &str) -> Result<()> { - let app_config = - crate::config::load(config_file).context("Failed to load application configuration")?; - crate::APP_CONFIG - .set(app_config) - .map_err(|_| anyhow::anyhow!("Application configuration has already been initialized"))?; - Ok(()) -} - - -fn list_config_settings(config_file: &str) -> Result<()> { - let app_config = - crate::config::load(config_file).context("Failed to load application configuration")?; - - println!("Configuration: {config_file}"); - println!( - "sfdx_auth_url = {}", - app_config - .sfdx_auth_url - .as_deref() - .unwrap_or("") - ); - - Ok(()) -} - -fn set_sfdx_auth_url(config_file: &str, url: &str) -> Result<()> { - let mut app_config = - crate::config::load(config_file).context("Failed to load application configuration")?; - - app_config.sfdx_auth_url = Some(url.to_string()); - - crate::config::save(config_file, &app_config) - .context("Failed to save Salesforce authentication URL")?; - - Ok(()) -} - pub(crate) fn run() { let cli = Cli::parse(); match &cli.command { Some(command) => match command { - Commands::Set { sfdx_auth_url } => { - run_set_command(&cli, sfdx_auth_url); - }, - Commands::List => { - run_list_command(&cli); - }, - Commands::FileSync { .. } => { - run_file_sync(&cli); - } + Commands::Set { + admin_email, + s3_bucket, + s3_client_id, + s3_client_secret, + s3_endpoint, + s3_region, + sfdx_auth_url, + } => run_set_command( + &cli.config, + admin_email, + s3_bucket, + s3_client_id, + s3_client_secret, + s3_endpoint, + s3_region, + sfdx_auth_url, + ), + Commands::List => run_list_command(&cli), + Commands::FileSync { .. } => run_file_sync(&cli), }, None => { println!("Expected subcommand, none given. Use --help for more information"); @@ -193,6 +123,14 @@ pub(crate) fn run() { } } +fn load_configuration_file(config_file: &str) -> Result<()> { + let app_config = crate::config::load(config_file).context("Failed to load application configuration")?; + crate::APP_CONFIG + .set(app_config) + .map_err(|_| anyhow::anyhow!("Application configuration has already been initialized"))?; + Ok(()) +} + fn run_file_sync(cli: &Cli) { logger::init(&cli.log_level); @@ -208,7 +146,8 @@ fn run_file_sync(cli: &Cli) { } fn run_list_command(cli: &Cli) { - match list_config_settings(&cli.config) { + match crate::config::list(&cli.config) + .context("Failed to load application configuration") { Ok(()) => {} Err(e) => { error!("Error listing configuration settings: {:#}", e); @@ -217,29 +156,32 @@ fn run_list_command(cli: &Cli) { } } -fn run_set_command(cli: &Cli, sfdx_auth_url: &Option) { - let mut updated = false; - - updated = run_set_sfdx_auth_url(cli, sfdx_auth_url, &updated); - - if !updated { - println!("Expected at least one setting flag. Use --help for more information"); - std::process::exit(1); - } -} - -fn run_set_sfdx_auth_url(cli: &Cli, sfdx_auth_url: &Option, updated: &bool) -> bool { - if let Some(url) = sfdx_auth_url { - match set_sfdx_auth_url(&cli.config, url) { - Ok(()) => { - println!("Salesforce authentication URL saved successfully"); - return true; - } - Err(e) => { - error!("Error saving Salesforce authentication URL: {:#}", e); - std::process::exit(1); - } +fn run_set_command( + config_file: &str, + admin_email: &Option, + s3_bucket: &Option, + s3_client_id: &Option, + s3_client_secret: &Option, + s3_endpoint: &Option, + s3_region: &Option, + sfdx_auth_url: &Option, +) { + match set::run( + config_file, + admin_email, + s3_bucket, + s3_client_id, + s3_client_secret, + s3_endpoint, + s3_region, + sfdx_auth_url, + ) { + Ok(()) => { + println!("Configuration settings updated successfully"); + } + Err(e) => { + error!("Error setting configuration: {:#}", e); + std::process::exit(1); } } - *updated -} +} \ No newline at end of file diff --git a/src/cli/set/mod.rs b/src/cli/set/mod.rs new file mode 100644 index 0000000..3338dcc --- /dev/null +++ b/src/cli/set/mod.rs @@ -0,0 +1,62 @@ +use anyhow::{Context, Result}; + +pub(crate) fn run(config_file: &str, + admin_email: &Option, + s3_bucket: &Option, + s3_client_id: &Option, + s3_client_secret: &Option, + s3_endpoint: &Option, + s3_region: &Option, + sfdx_auth_url: &Option,) -> Result<()> { + + let mut updated = false; + + let mut app_config = crate::config::load(config_file) + .context("Failed to load application configuration")?; + + + if let Some(admin_email) = admin_email { + app_config.admin_email = Some(admin_email.to_string()); + updated = true; + } + + if let Some(bucket) = s3_bucket { + app_config.s3_bucket = Some(bucket.to_string()); + updated = true; + } + + if let Some(s3_client_id) = s3_client_id { + app_config.s3_client_id = Some(s3_client_id.to_string()); + updated = true; + } + + if let Some(s3_client_secret) = s3_client_secret { + app_config.s3_client_secret = Some(s3_client_secret.to_string()); + updated = true; + } + + if let Some(s3_endpoint) = s3_endpoint { + app_config.s3_endpoint = Some(s3_endpoint.to_string()); + updated = true; + } + + if let Some(s3_region) = s3_region { + app_config.s3_region = Some(s3_region.to_string()); + updated = true; + } + + if let Some(url) = sfdx_auth_url { + app_config.sfdx_auth_url = Some(url.to_string()); + updated = true; + } + + crate::config::save(config_file, &app_config) + .context("Failed to save configuration")?; + + if !updated { + println!("Expected at least one settings flag. Use --help for more information"); + std::process::exit(1); + }; + + Ok(()) +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 56dc024..b1f689a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,19 +13,79 @@ const CONFIG_KEY_ENV: &str = "IMOJE_CONFIG_KEY"; const CONFIG_DIR: &str = "config/imoje"; const NONCE_LEN: usize = 12; +const NOT_SET: &'static str = ""; +const SECRET_HIDDEN: &'static str = "***** secret hidden *****"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { + pub admin_email: Option, + pub s3_bucket: Option, + pub s3_client_id: Option, + pub s3_client_secret: Option, + pub s3_endpoint: Option, + pub s3_region: Option, pub sfdx_auth_url: Option, } impl Default for AppConfig { fn default() -> Self { Self { + admin_email: None, + s3_bucket: None, + s3_client_id: None, + s3_client_secret: None, + s3_endpoint: None, + s3_region: None, sfdx_auth_url: None, } } } +pub fn list(config_file: &str) -> Result<()> { + let app_config = load(config_file).context("Failed to load application configuration")?; + + println!("Configuration: {}", config_file); + println!("----------------------------------------------------"); + println!( + "admin_email = {}", + app_config.admin_email.as_deref().unwrap_or(NOT_SET) + ); + println!( + "s3_bucket = {}", + app_config.s3_bucket.as_deref().unwrap_or(NOT_SET) + ); + println!( + "s3_client_id = {}", + app_config.s3_client_id.as_deref().unwrap_or(NOT_SET) + ); + println!( + "s3_client_secret = {}", + match app_config.s3_client_secret { + Some(_) => SECRET_HIDDEN, + None => NOT_SET, + } + ); + println!( + "s3_endpoint = {}", + app_config.s3_endpoint.as_deref().unwrap_or(NOT_SET) + ); + + println!( + "s3_region = {}", + app_config.s3_region.as_deref().unwrap_or(NOT_SET) + ); + println!( + "sfdx_auth_url = {}", + match app_config.sfdx_auth_url { + Some(_) => SECRET_HIDDEN, + None => NOT_SET, + } + ); + + Ok(()) + +} + pub fn load(config_file: &str) -> Result { let config_path = config_path(config_file)?; @@ -122,7 +182,7 @@ fn decrypt_config(encrypted_config: &str) -> Result { fn config_key() -> Result<[u8; 32]> { let encoded_key = std::env::var(CONFIG_KEY_ENV) .unwrap_or("ihDlaCqJqUGJ1am4dT9k/BHRG2UT+iK9IfYcPe0Db/I=".to_string()); - // .with_context(|| format!("Missing required environment variable {CONFIG_KEY_ENV}"))?; + // .with_context(|| format!("Missing required environment variable {CONFIG_KEY_ENV}"))?; let key = BASE64 .decode(encoded_key.trim()) @@ -130,4 +190,195 @@ fn config_key() -> Result<[u8; 32]> { key.try_into() .map_err(|_| anyhow!("{CONFIG_KEY_ENV} must decode to exactly 32 bytes")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use std::sync::{Mutex, OnceLock}; + + const TEST_KEY: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + + static ENV_LOCK: OnceLock> = OnceLock::new(); + + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("environment lock should not be poisoned") + } + + fn unique_temp_home() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "imojefs-config-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after UNIX_EPOCH") + .as_nanos() + )); + path + } + + fn set_test_key() { + unsafe { + std::env::set_var(CONFIG_KEY_ENV, TEST_KEY); + } + } + + fn set_home_dir(path: &Path) { + unsafe { + std::env::set_var("HOME", path); + std::env::set_var("USERPROFILE", path); + } + } + + #[test] + fn app_config_default_has_no_values() { + let config = AppConfig::default(); + + assert_eq!(config.sfdx_auth_url, None); + assert_eq!(config.s3_bucket, None); + } + + #[test] + fn config_key_accepts_base64_encoded_32_byte_key() { + let _lock = env_lock(); + set_test_key(); + + let key = config_key().expect("test key should be valid"); + + assert_eq!(key, *b"0123456789abcdef0123456789abcdef"); + } + + #[test] + fn config_key_rejects_invalid_base64() { + let _lock = env_lock(); + unsafe { + std::env::set_var(CONFIG_KEY_ENV, "not valid base64"); + } + + let error = config_key().expect_err("invalid base64 should fail"); + + assert!( + error + .to_string() + .contains("IMOJE_CONFIG_KEY must be a base64-encoded 32-byte key") + ); + } + + #[test] + fn config_key_rejects_wrong_key_length() { + let _lock = env_lock(); + unsafe { + std::env::set_var(CONFIG_KEY_ENV, BASE64.encode(b"too short")); + } + + let error = config_key().expect_err("short key should fail"); + + assert!( + error + .to_string() + .contains("IMOJE_CONFIG_KEY must decode to exactly 32 bytes") + ); + } + + #[test] + fn encrypt_config_produces_base64_payload_with_nonce_and_ciphertext() { + let _lock = env_lock(); + set_test_key(); + + let encrypted = encrypt_config("sfdx_auth_url = 'auth-url'") + .expect("configuration should encrypt"); + let encrypted_payload = BASE64 + .decode(encrypted) + .expect("encrypted configuration should be base64"); + + assert!(encrypted_payload.len() > NONCE_LEN); + } + + #[test] + fn decrypt_config_round_trips_encrypted_configuration() { + let _lock = env_lock(); + set_test_key(); + + let encrypted = encrypt_config( + r#" +sfdx_auth_url = "force://example" +s3_bucket = "example-bucket" +"#, + ) + .expect("configuration should encrypt"); + + let decrypted = decrypt_config(&encrypted).expect("configuration should decrypt"); + + assert_eq!(decrypted.sfdx_auth_url.as_deref(), Some("force://example")); + assert_eq!(decrypted.s3_bucket.as_deref(), Some("example-bucket")); + } + + #[test] + fn decrypt_config_rejects_non_base64_input() { + let _lock = env_lock(); + set_test_key(); + + let error = decrypt_config("not valid base64").expect_err("invalid base64 should fail"); + + assert!(error.to_string().contains("Configuration file is not valid base64")); + } + + #[test] + fn decrypt_config_rejects_payload_without_ciphertext() { + let _lock = env_lock(); + set_test_key(); + let encrypted = BASE64.encode([0_u8; NONCE_LEN]); + + let error = decrypt_config(&encrypted).expect_err("short payload should fail"); + + assert!(error.to_string().contains("Configuration file is too short")); + } + + #[test] + fn save_and_load_round_trip_configuration_file() { + let _lock = env_lock(); + set_test_key(); + let temp_home = unique_temp_home(); + set_home_dir(&temp_home); + + let config = AppConfig { + admin_email: None, + sfdx_auth_url: Some("force://saved".to_string()), + s3_bucket: Some("saved-bucket".to_string()), + s3_client_id: None, + s3_client_secret: None, + s3_endpoint: None, + s3_region: None, + }; + + save("round-trip", &config).expect("configuration should save"); + let loaded = load("round-trip").expect("configuration should load"); + + assert_eq!(loaded.sfdx_auth_url, config.sfdx_auth_url); + assert_eq!(loaded.s3_bucket, config.s3_bucket); + + fs::remove_dir_all(temp_home).ok(); + } + + #[test] + fn load_creates_default_configuration_when_file_is_missing() { + let _lock = env_lock(); + set_test_key(); + let temp_home = unique_temp_home(); + set_home_dir(&temp_home); + + let loaded = load("missing").expect("default configuration should load"); + let expected_path = temp_home.join(CONFIG_DIR).join("missing.conf"); + + assert_eq!(loaded.sfdx_auth_url, None); + assert_eq!(loaded.s3_bucket, None); + assert!(expected_path.exists()); + + fs::remove_dir_all(temp_home).ok(); + } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 72312dd..95dfd35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ -//! # IMoje File Sync -//! -//! Syncs the contents of a Salesforce File to S3 - -use anyhow::Result; -use clap::Parser; +//! # IMoje CLI +//! +//! CLI features; +//! - **file-sync** Syncs the contents of a Salesforce File to S3 +//! - **set** Sets the value of a configuration +//! - **list** Lists the contents of a configuration file` use std::sync::OnceLock; @@ -19,7 +19,6 @@ mod file_sync; pub static APP_CONFIG: OnceLock = OnceLock::new(); - #[::tokio::main] async fn main() { cli::run();