use std::env;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
use base64::Engine;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Protocol {
Http1,
#[cfg(feature = "http3")]
Http3,
}
fn default_protocol() -> Protocol {
Protocol::Http1
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HessraConfig {
pub base_url: String,
pub port: Option<u16>,
#[serde(default)]
pub mtls_cert: Option<String>,
#[serde(default)]
pub mtls_key: Option<String>,
pub server_ca: String,
#[serde(default = "default_protocol")]
pub protocol: Protocol,
#[serde(default)]
pub public_key: Option<String>,
#[serde(default)]
pub personal_keypair: Option<String>,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Base URL is required but was not provided")]
MissingBaseUrl,
#[error("Invalid port number. Port must be a valid number between 1-65535")]
InvalidPort,
#[error("mTLS certificate is required but was not provided")]
MissingCertificate,
#[error("mTLS key is required but was not provided")]
MissingKey,
#[error("Server CA certificate is required but was not provided")]
MissingServerCA,
#[error("Invalid certificate format: {0}")]
InvalidCertificate(String),
#[error("I/O error occurred while reading configuration: {0}")]
IOError(String),
#[error("Failed to parse configuration data: {0}")]
ParseError(String),
#[error("Global configuration has already been initialized")]
AlreadyInitialized,
#[error("Environment variable error: {0}")]
EnvVarError(String),
}
impl From<std::io::Error> for ConfigError {
fn from(error: std::io::Error) -> Self {
ConfigError::IOError(error.to_string())
}
}
impl From<serde_json::Error> for ConfigError {
fn from(error: serde_json::Error) -> Self {
ConfigError::ParseError(error.to_string())
}
}
#[cfg(feature = "toml")]
impl From<toml::de::Error> for ConfigError {
fn from(error: toml::de::Error) -> Self {
ConfigError::ParseError(error.to_string())
}
}
impl From<std::env::VarError> for ConfigError {
fn from(error: std::env::VarError) -> Self {
ConfigError::EnvVarError(error.to_string())
}
}
#[derive(Default, Debug)]
pub struct HessraConfigBuilder {
base_url: Option<String>,
port: Option<u16>,
mtls_cert: Option<String>,
mtls_key: Option<String>,
server_ca: Option<String>,
protocol: Option<Protocol>,
public_key: Option<String>,
personal_keypair: Option<String>,
}
impl HessraConfigBuilder {
pub fn new() -> Self {
Self {
base_url: None,
port: None,
mtls_cert: None,
mtls_key: None,
server_ca: None,
protocol: None,
public_key: None,
personal_keypair: None,
}
}
pub fn from_config(config: &HessraConfig) -> Self {
Self {
base_url: Some(config.base_url.clone()),
port: config.port,
mtls_cert: config.mtls_cert.clone(),
mtls_key: config.mtls_key.clone(),
server_ca: Some(config.server_ca.clone()),
protocol: Some(config.protocol.clone()),
public_key: config.public_key.clone(),
personal_keypair: config.personal_keypair.clone(),
}
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn mtls_cert(mut self, cert: impl Into<String>) -> Self {
self.mtls_cert = Some(cert.into());
self
}
pub fn mtls_key(mut self, key: impl Into<String>) -> Self {
self.mtls_key = Some(key.into());
self
}
pub fn server_ca(mut self, ca: impl Into<String>) -> Self {
self.server_ca = Some(ca.into());
self
}
pub fn protocol(mut self, protocol: Protocol) -> Self {
self.protocol = Some(protocol);
self
}
pub fn public_key(mut self, public_key: impl Into<String>) -> Self {
self.public_key = Some(public_key.into());
self
}
pub fn personal_keypair(mut self, personal_keypair: impl Into<String>) -> Self {
self.personal_keypair = Some(personal_keypair.into());
self
}
pub fn build(self) -> Result<HessraConfig, ConfigError> {
let base_url = self.base_url.ok_or(ConfigError::MissingBaseUrl)?;
let server_ca = self.server_ca.ok_or(ConfigError::MissingServerCA)?;
match (&self.mtls_cert, &self.mtls_key) {
(Some(_), None) => return Err(ConfigError::MissingKey),
(None, Some(_)) => return Err(ConfigError::MissingCertificate),
_ => {}
}
let config = HessraConfig {
base_url,
port: self.port,
protocol: self.protocol.unwrap_or_else(default_protocol),
mtls_cert: self.mtls_cert,
mtls_key: self.mtls_key,
server_ca,
public_key: self.public_key,
personal_keypair: self.personal_keypair,
};
config.validate()?;
Ok(config)
}
}
static GLOBAL_CONFIG: OnceLock<HessraConfig> = OnceLock::new();
impl HessraConfig {
pub fn new(
base_url: impl Into<String>,
port: Option<u16>,
protocol: Protocol,
mtls_cert: impl Into<String>,
mtls_key: impl Into<String>,
server_ca: impl Into<String>,
) -> Self {
Self {
base_url: base_url.into(),
port,
protocol,
mtls_cert: Some(mtls_cert.into()),
mtls_key: Some(mtls_key.into()),
server_ca: server_ca.into(),
public_key: None,
personal_keypair: None,
}
}
pub fn new_tls_only(
base_url: impl Into<String>,
port: Option<u16>,
protocol: Protocol,
server_ca: impl Into<String>,
) -> Self {
Self {
base_url: base_url.into(),
port,
protocol,
mtls_cert: None,
mtls_key: None,
server_ca: server_ca.into(),
public_key: None,
personal_keypair: None,
}
}
pub fn builder() -> HessraConfigBuilder {
HessraConfigBuilder::new()
}
pub fn to_builder(&self) -> HessraConfigBuilder {
HessraConfigBuilder::from_config(self)
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path)?;
let config: HessraConfig = serde_json::from_str(&content)?;
config.validate()?;
Ok(config)
}
#[cfg(feature = "toml")]
pub fn from_toml(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path)?;
let config: HessraConfig = toml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn from_env(prefix: &str) -> Result<Self, ConfigError> {
let mut builder = HessraConfigBuilder::new();
if let Ok(base_url) = env::var(format!("{prefix}_BASE_URL")) {
builder = builder.base_url(base_url);
}
if let Ok(port_str) = env::var(format!("{prefix}_PORT")) {
if let Ok(port) = port_str.parse::<u16>() {
builder = builder.port(port);
} else {
return Err(ConfigError::InvalidPort);
}
}
if let Ok(protocol_str) = env::var(format!("{prefix}_PROTOCOL")) {
let protocol = match protocol_str.to_lowercase().as_str() {
"http1" => Protocol::Http1,
#[cfg(feature = "http3")]
"http3" => Protocol::Http3,
_ => {
return Err(ConfigError::ParseError(format!(
"Invalid protocol: {protocol_str}"
)))
}
};
builder = builder.protocol(protocol);
}
fn decode_base64_pem(value: &str) -> Result<String, ConfigError> {
base64::engine::general_purpose::STANDARD
.decode(value)
.map_err(|e| ConfigError::ParseError(format!("Invalid base64 encoding: {e}")))
.and_then(|decoded| {
String::from_utf8(decoded)
.map_err(|e| ConfigError::ParseError(format!("Invalid UTF-8: {e}")))
})
}
if let Ok(mtls_cert) = env::var(format!("{prefix}_MTLS_CERT")) {
let decoded_cert = decode_base64_pem(&mtls_cert)?;
builder = builder.mtls_cert(decoded_cert);
} else if let Ok(mtls_cert_file) = env::var(format!("{prefix}_MTLS_CERT_FILE")) {
let cert_content = fs::read_to_string(mtls_cert_file)?;
builder = builder.mtls_cert(cert_content);
}
if let Ok(mtls_key) = env::var(format!("{prefix}_MTLS_KEY")) {
let decoded_key = decode_base64_pem(&mtls_key)?;
builder = builder.mtls_key(decoded_key);
} else if let Ok(mtls_key_file) = env::var(format!("{prefix}_MTLS_KEY_FILE")) {
let key_content = fs::read_to_string(mtls_key_file)?;
builder = builder.mtls_key(key_content);
}
if let Ok(server_ca) = env::var(format!("{prefix}_SERVER_CA")) {
let decoded_ca = decode_base64_pem(&server_ca)?;
builder = builder.server_ca(decoded_ca);
} else if let Ok(server_ca_file) = env::var(format!("{prefix}_SERVER_CA_FILE")) {
let ca_content = fs::read_to_string(server_ca_file)?;
builder = builder.server_ca(ca_content);
}
if let Ok(public_key) = env::var(format!("{prefix}_PUBLIC_KEY")) {
let decoded_key = decode_base64_pem(&public_key)?;
builder = builder.public_key(decoded_key);
} else if let Ok(public_key_file) = env::var(format!("{prefix}_PUBLIC_KEY_FILE")) {
let key_content = fs::read_to_string(public_key_file)?;
builder = builder.public_key(key_content);
}
if let Ok(personal_keypair) = env::var(format!("{prefix}_PERSONAL_KEYPAIR")) {
let decoded_keypair = decode_base64_pem(&personal_keypair)?;
builder = builder.personal_keypair(decoded_keypair);
} else if let Ok(personal_keypair_file) =
env::var(format!("{prefix}_PERSONAL_KEYPAIR_FILE"))
{
let keypair_content = fs::read_to_string(personal_keypair_file)?;
builder = builder.personal_keypair(keypair_content);
}
builder.build()
}
pub fn from_env_or_file(prefix: &str) -> Result<Self, ConfigError> {
match Self::from_env(prefix) {
Ok(config) => return Ok(config),
Err(e) => {
if !matches!(
e,
ConfigError::MissingBaseUrl
| ConfigError::MissingCertificate
| ConfigError::MissingKey
| ConfigError::MissingServerCA
) {
return Err(e);
}
}
}
if let Ok(config_file) = env::var(format!("{prefix}_CONFIG_FILE")) {
return Self::load_from_file(&config_file);
}
if let Ok(config) = Self::load_from_file("./hessra.json") {
return Ok(config);
}
if let Some(mut config_dir) = dirs::config_dir() {
config_dir.push("hessra");
config_dir.push("config.json");
if let Ok(config) = Self::load_from_file(config_dir) {
return Ok(config);
}
}
Err(ConfigError::MissingBaseUrl)
}
fn load_from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
if let Some(ext) = path.extension() {
match ext.to_str() {
Some("json") => Self::from_file(path),
#[cfg(feature = "toml")]
Some("toml") | Some("tml") => Self::from_toml(path),
_ => Self::from_file(path), }
} else {
Self::from_file(path)
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.base_url.is_empty() {
return Err(ConfigError::MissingBaseUrl);
}
match (&self.mtls_cert, &self.mtls_key) {
(Some(cert), Some(key)) => {
if cert.is_empty() {
return Err(ConfigError::MissingCertificate);
}
if key.is_empty() {
return Err(ConfigError::MissingKey);
}
}
(Some(_), None) => return Err(ConfigError::MissingKey),
(None, Some(_)) => return Err(ConfigError::MissingCertificate),
(None, None) => {} }
if self.server_ca.is_empty() {
return Err(ConfigError::MissingServerCA);
}
if let Some(cert) = &self.mtls_cert {
if !cert.contains("-----BEGIN CERTIFICATE-----") {
return Err(ConfigError::InvalidCertificate(
"Client certificate does not appear to be in PEM format".into(),
));
}
}
if let Some(key) = &self.mtls_key {
if !key.contains("-----BEGIN") {
return Err(ConfigError::InvalidCertificate(
"Client key does not appear to be in PEM format".into(),
));
}
}
if !self.server_ca.contains("-----BEGIN CERTIFICATE-----") {
return Err(ConfigError::InvalidCertificate(
"Server CA certificate does not appear to be in PEM format".into(),
));
}
if let Some(public_key) = &self.public_key {
if !public_key.contains("-----BEGIN PUBLIC KEY-----") {
return Err(ConfigError::InvalidCertificate(
"Server public key does not appear to be in PEM format".into(),
));
}
}
if let Some(keypair) = &self.personal_keypair {
if !keypair.contains("-----BEGIN") {
return Err(ConfigError::InvalidCertificate(
"Personal keypair does not appear to be in PEM format".into(),
));
}
}
Ok(())
}
}
pub fn set_default_config(config: HessraConfig) -> Result<(), ConfigError> {
if GLOBAL_CONFIG.set(config).is_err() {
return Err(ConfigError::AlreadyInitialized);
}
Ok(())
}
pub fn get_default_config() -> Option<&'static HessraConfig> {
GLOBAL_CONFIG.get()
}
pub fn try_load_default_config() -> Option<HessraConfig> {
HessraConfig::from_env_or_file("HESSRA").ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
const VALID_CERT: &str = r#"-----BEGIN CERTIFICATE-----
MIICZjCCAc+gAwIBAgIUJlq+zz4mN3zoNfbMkKqLQ9BS79UwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzA0MTUwMDAwMDBaFw0yNDA0
MTUwMDAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwgZ8wDQYJKoZIhvcNAQEB
BQADgY0AMIGJAoGBAONH4+1QZPmY3zWP/Yjt5UJeuR0IGF5q8TYHTGw2kzbTPLTa
XMo/JohB/duKFRYvZEbGlmK0xQtLrLhBF8MUoN+kUxG9UkbQHk5xNL0eLmDOy4bm
OLtIfCIoQZyKMJFIRAgLcNv6Z9q1l+mfBCz9ZIPzVZRyCv/YsHEJUkJfrfg9AgMB
AAGjUzBRMB0GA1UdDgQWBBQCQ7Ui9CeMRzZzLeTHzYJbPT9rkjAfBgNVHSMEGDAW
gBQCQ7Ui9CeMRzZzLeTHzYJbPT9rkjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4GBADbM7a5bQjQK7JKaFaXqiueiv4qM7fhZ1O3icLzLYBzrO8vGRQo9
FM9zgPOiqpjzLGfDhbUJvN3hbjPZmzJzyyRM9XHdKKwYH/ErY6vRbciuO7qbD6Hx
CKZ0ORbMdmc0TRF6+5s6p3bhDvZ2ZpUVsXzMz5ZxMnpQMpfh3AbEV2Yw
-----END CERTIFICATE-----"#;
const VALID_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAONH4+1QZPmY3zWP
/Yjt5UJeuR0IGF5q8TYHTGw2kzbTPLTaXMo/JohB/duKFRYvZEbGlmK0xQtLrLhB
F8MUoN+kUxG9UkbQHk5xNL0eLmDOy4bmOLtIfCIoQZyKMJFIRAgLcNv6Z9q1l+mf
BCz9ZIPzVZRyCv/YsHEJUkJfrfg9AgMBAAECgYEAw5tgq6t1QRUDNaZsNQ4QYkgI
CjVekg0XMR/WK6NmmKUkOI2aTaA+CwU0ZYERLvGZMLOVPHJKQZdLLbsl8CvhDtT1
HXpxKR1EJ8vuCPlfZ3LVdUVQeV3QcUpBQGvzWGHl2R3LM/RrW4cS4eP4SMNdVVF4
jdmGpMDvPm/0VoUtRwECQQD/EY/RTGlU9oXnSwEUfK9Gg0OBXDVEPGrNJ4JAoMHn
MJQywP0IZxHfr8A9uk9U8L+5LCFgXcFZ8fgYOFvrElxBAkEA5Ca+Tq5k4IEpyW+Q
wPrKu77SmAKiT3JlIslGUO0OXUHYqZbRHWCAQTZWZuaOJG8I82I5EWyLrVaJbA4X
OgbebQJABHBmZA3TF1zRci73OWUc7pK2K8PSx38tPPAIg5dP5y8SGpiKpf+8HijD
EjKvY+0K1Py1Q7nHU4GbqE9juOS0AQJAFDIYzaGuJwdNZRIAS2h5uqZmIpKieLfc
5c3JVVzkBFXfKQME6KsAdIrlpwCmzU5vUEQzGWNXCes2uBGp2XpXxQJAGvf5IVWF
+ZkVB5GKbj0DGOw3rH7QYhbJVAeCJbzBqI+euvtVK4xrDdWZsK8IGy6NCxMA//Qf
Tz0nftszeCrCGw==
-----END PRIVATE KEY-----"#;
#[test]
fn test_config_builder() {
let config = HessraConfigBuilder::new()
.base_url("https://test.hessra.net")
.port(443)
.protocol(Protocol::Http1)
.mtls_cert(VALID_CERT)
.mtls_key(VALID_KEY)
.server_ca(VALID_CERT)
.build()
.unwrap();
assert_eq!(config.base_url, "https://test.hessra.net");
assert_eq!(config.port, Some(443));
assert!(matches!(config.protocol, Protocol::Http1));
}
#[test]
fn test_config_validation() {
let result = HessraConfigBuilder::new()
.mtls_cert(VALID_CERT)
.mtls_key(VALID_KEY)
.server_ca(VALID_CERT)
.build();
assert!(matches!(result, Err(ConfigError::MissingBaseUrl)));
let result = HessraConfigBuilder::new()
.base_url("https://test.hessra.net")
.mtls_key(VALID_KEY)
.server_ca(VALID_CERT)
.build();
assert!(matches!(result, Err(ConfigError::MissingCertificate)));
let result = HessraConfigBuilder::new()
.base_url("https://test.hessra.net")
.mtls_cert("not-a-cert")
.mtls_key(VALID_KEY)
.server_ca(VALID_CERT)
.build();
assert!(matches!(result, Err(ConfigError::InvalidCertificate(_))));
}
#[test]
fn test_load_from_json() {
let config_json = r#"{
"base_url": "https://test.hessra.net",
"port": 443,
"mtls_cert": "-----BEGIN CERTIFICATE-----\nMIICZjCCAc+gAwIBAgIUJlq+zz4mN3zoNfbMkKqLQ9BS79UwDQYJKoZIhvcNAQEL\n-----END CERTIFICATE-----",
"mtls_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAONH4+1QZPmY3zWP\n-----END PRIVATE KEY-----",
"server_ca": "-----BEGIN CERTIFICATE-----\nMIICZjCCAc+gAwIBAgIUJlq+zz4mN3zoNfbMkKqLQ9BS79UwDQYJKoZIhvcNAQEL\n-----END CERTIFICATE-----"
}"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(config_json.as_bytes()).unwrap();
let config = HessraConfig::from_file(temp_file.path()).unwrap();
assert_eq!(config.base_url, "https://test.hessra.net");
assert_eq!(config.port, Some(443));
}
#[test]
fn test_from_env() {
let original = HessraConfig::new(
"https://test.hessra.net",
Some(443),
Protocol::Http1,
VALID_CERT,
VALID_KEY,
VALID_CERT,
);
let builder = original.to_builder();
let new_config = builder.port(8443).build().unwrap();
assert_eq!(new_config.base_url, original.base_url);
assert_eq!(new_config.port, Some(8443));
}
#[test]
fn test_global_config() {
let config = HessraConfig::new(
"https://test.hessra.net",
Some(443),
Protocol::Http1,
VALID_CERT,
VALID_KEY,
VALID_CERT,
);
assert!(set_default_config(config.clone()).is_ok());
assert!(matches!(
set_default_config(config.clone()),
Err(ConfigError::AlreadyInitialized)
));
let global = get_default_config().unwrap();
assert_eq!(global.base_url, "https://test.hessra.net");
}
}