Add config setting for S3 and Salesforce

This commit is contained in:
Wim Velzeboer
2026-09-07 11:58:27 +01:00
parent f14e45c30f
commit 1374af60e0
5 changed files with 406 additions and 150 deletions
+2
View File
@@ -0,0 +1,2 @@
max_width = 120
hard_tabs = true
+79 -137
View File
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use log::error; use log::error;
mod set;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
struct Cli { struct Cli {
@@ -33,6 +35,31 @@ enum Commands {
/// Change settings /// Change settings
Set { 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<String>,
/// AWS S3 bucket name
#[arg(short = 'b', long = "s3-bucket", env = "IMOJEFS_S3_BUCKET")]
s3_bucket: Option<String>,
/// AWS Access Key Id
#[arg(short = 'k', long, env = "IMOJEFS_S3_CLIENT_ID")]
s3_client_id: Option<String>,
/// AWS Secret Access Key
#[arg(short = 's', long, env = "IMOJEFS_S3_CLIENT_SECRET", hide_env_values = true)]
s3_client_secret: Option<String>,
/// AWS Endpoint URL, [default: AWS url]
#[arg(short = 'e', long, env = "IMOJEFS_S3_ENDPOINT")]
s3_endpoint: Option<String>,
/// AWS Region
#[arg(short = 'r', long, default_value = "us-east-1", env = "IMOJEFS_S3_REGION")]
s3_region: Option<String>,
/// Salesforce Authentication Url /// Salesforce Authentication Url
#[arg( #[arg(
short = 'x', 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<String>,
/// 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<String>,
}
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. /// Represents the different levels of logging that can be used in an application.
/// ///
/// # Variants /// # Variants
@@ -133,58 +90,31 @@ pub enum LogLevel {
Trace, 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("<not set>")
);
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() { pub(crate) fn run() {
let cli = Cli::parse(); let cli = Cli::parse();
match &cli.command { match &cli.command {
Some(command) => match command { Some(command) => match command {
Commands::Set { sfdx_auth_url } => { Commands::Set {
run_set_command(&cli, sfdx_auth_url); admin_email,
}, s3_bucket,
Commands::List => { s3_client_id,
run_list_command(&cli); s3_client_secret,
}, s3_endpoint,
Commands::FileSync { .. } => { s3_region,
run_file_sync(&cli); 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 => { None => {
println!("Expected subcommand, none given. Use --help for more information"); 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) { fn run_file_sync(cli: &Cli) {
logger::init(&cli.log_level); logger::init(&cli.log_level);
@@ -208,7 +146,8 @@ fn run_file_sync(cli: &Cli) {
} }
fn run_list_command(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(()) => {} Ok(()) => {}
Err(e) => { Err(e) => {
error!("Error listing configuration settings: {:#}", 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<String>) { fn run_set_command(
let mut updated = false; config_file: &str,
admin_email: &Option<String>,
updated = run_set_sfdx_auth_url(cli, sfdx_auth_url, &updated); s3_bucket: &Option<String>,
s3_client_id: &Option<String>,
if !updated { s3_client_secret: &Option<String>,
println!("Expected at least one setting flag. Use --help for more information"); s3_endpoint: &Option<String>,
std::process::exit(1); s3_region: &Option<String>,
} sfdx_auth_url: &Option<String>,
} ) {
match set::run(
fn run_set_sfdx_auth_url(cli: &Cli, sfdx_auth_url: &Option<String>, updated: &bool) -> bool { config_file,
if let Some(url) = sfdx_auth_url { admin_email,
match set_sfdx_auth_url(&cli.config, url) { s3_bucket,
s3_client_id,
s3_client_secret,
s3_endpoint,
s3_region,
sfdx_auth_url,
) {
Ok(()) => { Ok(()) => {
println!("Salesforce authentication URL saved successfully"); println!("Configuration settings updated successfully");
return true;
} }
Err(e) => { Err(e) => {
error!("Error saving Salesforce authentication URL: {:#}", e); error!("Error setting configuration: {:#}", e);
std::process::exit(1); std::process::exit(1);
} }
} }
}
*updated
} }
+62
View File
@@ -0,0 +1,62 @@
use anyhow::{Context, Result};
pub(crate) fn run(config_file: &str,
admin_email: &Option<String>,
s3_bucket: &Option<String>,
s3_client_id: &Option<String>,
s3_client_secret: &Option<String>,
s3_endpoint: &Option<String>,
s3_region: &Option<String>,
sfdx_auth_url: &Option<String>,) -> 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(())
}
+251
View File
@@ -13,19 +13,79 @@ const CONFIG_KEY_ENV: &str = "IMOJE_CONFIG_KEY";
const CONFIG_DIR: &str = "config/imoje"; const CONFIG_DIR: &str = "config/imoje";
const NONCE_LEN: usize = 12; const NONCE_LEN: usize = 12;
const NOT_SET: &'static str = "<not set>";
const SECRET_HIDDEN: &'static str = "***** secret hidden *****";
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub admin_email: Option<String>,
pub s3_bucket: Option<String>,
pub s3_client_id: Option<String>,
pub s3_client_secret: Option<String>,
pub s3_endpoint: Option<String>,
pub s3_region: Option<String>,
pub sfdx_auth_url: Option<String>, pub sfdx_auth_url: Option<String>,
} }
impl Default for AppConfig { impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
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, 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<AppConfig> { pub fn load(config_file: &str) -> Result<AppConfig> {
let config_path = config_path(config_file)?; let config_path = config_path(config_file)?;
@@ -131,3 +191,194 @@ fn config_key() -> Result<[u8; 32]> {
key.try_into() key.try_into()
.map_err(|_| anyhow!("{CONFIG_KEY_ENV} must decode to exactly 32 bytes")) .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<Mutex<()>> = 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();
}
}
+5 -6
View File
@@ -1,9 +1,9 @@
//! # IMoje File Sync //! # IMoje CLI
//! //!
//! Syncs the contents of a Salesforce File to S3 //! CLI features;
//! - **file-sync** Syncs the contents of a Salesforce File to S3
use anyhow::Result; //! - **set** Sets the value of a configuration
use clap::Parser; //! - **list** Lists the contents of a configuration file`
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -19,7 +19,6 @@ mod file_sync;
pub static APP_CONFIG: OnceLock<config::AppConfig> = OnceLock::new(); pub static APP_CONFIG: OnceLock<config::AppConfig> = OnceLock::new();
#[::tokio::main] #[::tokio::main]
async fn main() { async fn main() {
cli::run(); cli::run();