use serde::Deserialize;
use std::{fmt, str::FromStr};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Environment {
#[default]
Development,
Test,
Staging,
Production,
}
impl Environment {
pub fn resolve(
rullst_env: Option<&str>,
app_env: Option<&str>,
configured_env: Option<&str>,
) -> Result<Self, ConfigError> {
rullst_env
.or(app_env)
.or(configured_env)
.map(Self::from_str)
.transpose()
.map(|environment| environment.unwrap_or_default())
}
pub fn detect(configured_env: Option<&str>) -> Result<Self, ConfigError> {
let rullst_env = read_environment_variable("RULLST_ENV")?;
let app_env = read_environment_variable("APP_ENV")?;
Self::resolve(rullst_env.as_deref(), app_env.as_deref(), configured_env)
}
pub const fn allows_development_tools(self) -> bool {
matches!(self, Self::Development)
}
pub const fn requires_secure_defaults(self) -> bool {
matches!(self, Self::Staging | Self::Production)
}
}
impl FromStr for Environment {
type Err = ConfigError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"development" | "dev" | "local" => Ok(Self::Development),
"test" | "testing" => Ok(Self::Test),
"staging" | "stage" => Ok(Self::Staging),
"production" | "prod" => Ok(Self::Production),
_ => Err(ConfigError::InvalidEnvironment(value.to_string())),
}
}
}
impl fmt::Display for Environment {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Development => "development",
Self::Test => "test",
Self::Staging => "staging",
Self::Production => "production",
};
formatter.write_str(value)
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConfigError {
#[error("invalid Rullst environment `{0}`")]
InvalidEnvironment(String),
#[error("environment variable `{0}` is not valid Unicode")]
NonUnicodeEnvironmentVariable(String),
#[error("failed to read Rullst configuration: {0}")]
Read(String),
#[error("failed to parse Rullst configuration: {0}")]
Parse(String),
#[error("invalid security configuration: {0}")]
InvalidSecurityConfiguration(String),
}
fn read_environment_variable(name: &str) -> Result<Option<String>, ConfigError> {
match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
Err(ConfigError::NonUnicodeEnvironmentVariable(name.to_string()))
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[non_exhaustive]
pub struct RullstConfig {
#[serde(default)]
pub app: AppConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub storage: StorageConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[non_exhaustive]
pub struct StorageConfig {
pub root: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[non_exhaustive]
pub struct AppConfig {
pub env: Option<String>,
pub port: Option<u16>,
}
#[derive(Clone, Deserialize, Default)]
#[non_exhaustive]
pub struct DatabaseConfig {
pub url: Option<String>,
}
impl fmt::Debug for DatabaseConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DatabaseConfig")
.field("url", &self.url.as_deref().map(redacted_url))
.finish()
}
}
pub(crate) fn redacted_url(url: &str) -> String {
match url.split_once("://") {
Some((scheme, _))
if (1..=32).contains(&scheme.len())
&& scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) =>
{
format!("{scheme}://<redacted>")
}
_ => "<redacted>".to_string(),
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct SecurityConfig {
#[serde(default = "default_same_site")]
pub csrf_same_site: String,
#[serde(default)]
pub cors_allow_origins: Vec<String>,
#[serde(default = "default_false")]
pub cors_allow_credentials: bool,
#[serde(default = "default_csp")]
pub csp: String,
#[serde(default = "default_coep")]
pub coep: String,
#[serde(default = "default_user_agent_blocklist")]
pub user_agent_blocklist: Vec<String>,
#[serde(default = "default_false")]
pub enable_pii_masking: bool,
#[serde(default)]
pub csrf_signed_webhook_paths: Vec<String>,
}
pub const DEFAULT_CSP_TEMPLATE: &str = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'nonce-{NONCE}'; style-src 'self' 'nonce-{NONCE}'; img-src 'self' data:; connect-src 'self'; font-src 'self'; worker-src 'self' blob:";
fn default_csp() -> String {
DEFAULT_CSP_TEMPLATE.to_string()
}
fn default_coep() -> String {
"require-corp".to_string()
}
fn default_user_agent_blocklist() -> Vec<String> {
vec![
"gptbot".to_string(),
"chatgpt-user".to_string(),
"google-extended".to_string(),
"anthropic-ai".to_string(),
"claude-web".to_string(),
"cohere-ai".to_string(),
"bytespider".to_string(),
"mj12bot".to_string(),
]
}
fn default_same_site() -> String {
"Lax".to_string()
}
fn default_false() -> bool {
false
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
csrf_same_site: default_same_site(),
cors_allow_origins: vec![],
cors_allow_credentials: false,
csp: default_csp(),
coep: default_coep(),
user_agent_blocklist: default_user_agent_blocklist(),
enable_pii_masking: false,
csrf_signed_webhook_paths: Vec::new(),
}
}
}
impl SecurityConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if !matches!(self.csrf_same_site.as_str(), "Lax" | "Strict" | "None") {
return Err(ConfigError::InvalidSecurityConfiguration(format!(
"CSRF SameSite policy `{}` must be Lax, Strict, or None",
self.csrf_same_site
)));
}
let rendered_csp = self.csp.replace("{NONCE}", "validation-nonce");
if self.csp.trim().is_empty() || rendered_csp.parse::<http::HeaderValue>().is_err() {
return Err(ConfigError::InvalidSecurityConfiguration(
"CSP must be a non-empty valid HTTP header value".to_string(),
));
}
if !matches!(
self.coep.as_str(),
"require-corp" | "credentialless" | "unsafe-none"
) {
return Err(ConfigError::InvalidSecurityConfiguration(format!(
"COEP policy `{}` must be require-corp, credentialless, or unsafe-none",
self.coep
)));
}
let mut unique_origins = std::collections::HashSet::new();
for origin in &self.cors_allow_origins {
let uri = origin.parse::<http::Uri>().map_err(|_| {
ConfigError::InvalidSecurityConfiguration(format!(
"CORS origin `{origin}` must be an exact HTTP(S) origin"
))
})?;
let valid_scheme = uri
.scheme_str()
.is_some_and(|scheme| matches!(scheme, "http" | "https"));
let valid_origin = valid_scheme
&& uri.authority().is_some()
&& uri.path() == "/"
&& uri.query().is_none()
&& !origin.ends_with('/')
&& !origin.contains(['@', '#', '*'])
&& origin.parse::<http::HeaderValue>().is_ok();
if !valid_origin {
return Err(ConfigError::InvalidSecurityConfiguration(format!(
"CORS origin `{origin}` must be an exact HTTP(S) origin without path, query, credentials, wildcard, or trailing slash"
)));
}
if !unique_origins.insert(origin) {
return Err(ConfigError::InvalidSecurityConfiguration(format!(
"duplicate CORS origin `{origin}`"
)));
}
}
let mut unique_paths = std::collections::HashSet::new();
for path in &self.csrf_signed_webhook_paths {
let is_exact_absolute_path = path.starts_with('/')
&& path.len() > 1
&& !path.contains(['?', '#', '*', '{', '}', ':'])
&& !path
.split('/')
.any(|segment| segment == ".." || segment == ".");
if !is_exact_absolute_path {
return Err(ConfigError::InvalidSecurityConfiguration(format!(
"CSRF signed-webhook exemption `{path}` must be an exact absolute path"
)));
}
if !unique_paths.insert(path) {
return Err(ConfigError::InvalidSecurityConfiguration(format!(
"duplicate CSRF signed-webhook exemption `{path}`"
)));
}
}
Ok(())
}
}
static GLOBAL_CONFIG: std::sync::OnceLock<RullstConfig> = std::sync::OnceLock::new();
impl RullstConfig {
pub fn global() -> &'static RullstConfig {
GLOBAL_CONFIG.get_or_init(Self::default)
}
#[allow(clippy::result_large_err)]
pub fn set_global(config: Self) -> Result<(), Self> {
GLOBAL_CONFIG.set(config)
}
pub fn new() -> Self {
Self::default()
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.security.validate()
}
pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
toml::from_str(content).map_err(|error: toml::de::Error| {
ConfigError::Parse(match error.span() {
Some(span) => {
let before = content.get(..span.start).unwrap_or(content);
let line = before.matches('\n').count() + 1;
let column = before
.rsplit('\n')
.next()
.map_or(0, |text| text.chars().count())
+ 1;
format!("invalid configuration at line {line}, column {column}")
}
None => "invalid configuration".to_string(),
})
})
}
pub fn environment(&self) -> Result<Environment, ConfigError> {
Environment::detect(self.app.env.as_deref())
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn load_from_file(path: &str) -> Result<Self, ConfigError> {
let content = tokio::fs::read_to_string(path)
.await
.map_err(|error| ConfigError::Read(error.to_string()))?;
Self::from_toml(&content)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::field_reassign_with_default
)]
mod tests {
use super::*;
#[tokio::test]
async fn test_global_config_access() {
let config1 = RullstConfig::global();
let config2 = RullstConfig::global();
assert!(
std::ptr::eq(config1, config2),
"global() should return the same instance"
);
assert_eq!(config1.security.csrf_same_site, "Lax");
}
#[test]
fn parse_errors_report_position_without_configuration_content() {
let canary = "sk_live_toml_redaction_canary";
for (content, line) in [
(
format!(
"[app]\nenv = \"production\"\n[database]\nurl = \"postgres://owner:{canary}@db\n"
),
4,
),
(format!("[app]\nport = \"{canary}\"\n"), 2),
(format!("app_key = \"{canary}\"\n[app\n"), 2),
] {
let error = RullstConfig::from_toml(&content).unwrap_err();
let rendered = format!("{error} {error:?}");
assert!(!rendered.contains(canary), "{rendered}");
assert!(!rendered.contains("postgres://"), "{rendered}");
assert!(
matches!(&error, ConfigError::Parse(message) if message.contains(&format!("line {line},"))),
"{rendered}"
);
}
}
#[tokio::test]
async fn test_load_config_from_file() {
let temp_dir = "test_config_dir";
let _ = std::fs::create_dir_all(temp_dir);
let path = format!("{}/Rullst.toml", temp_dir);
let toml_content = r#"
[app]
env = "production"
port = 8080
[database]
url = "sqlite::memory:"
[security]
csrf_same_site = "Strict"
cors_allow_origins = ["https://example.com"]
"#;
tokio::fs::write(&path, toml_content).await.unwrap();
let config = RullstConfig::load_from_file(&path).await.unwrap();
assert_eq!(config.app.env.unwrap(), "production");
assert_eq!(config.app.port.unwrap(), 8080);
assert_eq!(config.database.url.unwrap(), "sqlite::memory:");
assert_eq!(config.security.csrf_same_site, "Strict");
assert_eq!(config.security.cors_allow_origins.len(), 1);
assert_eq!(config.security.cors_allow_origins[0], "https://example.com");
assert!(!config.security.cors_allow_credentials);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn test_default_security_config() {
let config = SecurityConfig::default();
assert_eq!(config.csrf_same_site, "Lax");
assert_eq!(config.coep, "require-corp");
assert!(config.csp.contains("default-src"));
assert!(!config.csp.contains("unsafe-inline"));
assert!(!config.csp.contains("unsafe-eval"));
assert!(config.user_agent_blocklist.contains(&"gptbot".to_string()));
assert!(config.csrf_signed_webhook_paths.is_empty());
}
#[test]
fn test_set_global_config() {
let mut config = RullstConfig::new();
config.app.env = Some("test_env".to_string());
let result = RullstConfig::set_global(config);
match result {
Ok(_) => assert_eq!(RullstConfig::global().app.env.as_deref(), Some("test_env")),
Err(c) => assert_eq!(c.app.env.as_deref(), Some("test_env")),
}
}
#[test]
fn database_url_debug_output_is_redacted() {
let mut config = RullstConfig::default();
config.database.url = Some("postgres://app:S3cr3t@db.internal/prod?sslmode=require".into());
let debug = format!("{config:?} {:?}", config.database);
assert!(
!debug.contains("S3cr3t"),
"Debug must redact the URL password"
);
assert!(
!debug.contains("db.internal"),
"Debug must redact the URL host"
);
assert!(
debug.contains("postgres://<redacted>"),
"Debug must keep the URL scheme"
);
assert_eq!(redacted_url("app:S3cr3t@db/prod"), "<redacted>");
assert_eq!(redacted_url("sqlite://rullst.db"), "sqlite://<redacted>");
assert_eq!(redacted_url("a b://x"), "<redacted>");
assert!(format!("{:?}", DatabaseConfig::default()).contains("url: None"));
}
#[test]
fn test_deserialize_security_config_defaults() {
let config: SecurityConfig = toml::from_str("").unwrap();
assert!(!config.enable_pii_masking);
assert_eq!(config.coep, "require-corp");
}
#[test]
fn environment_resolution_has_one_precedence_and_validated_aliases() {
assert_eq!(
Environment::resolve(Some("prod"), Some("test"), Some("development")).unwrap(),
Environment::Production
);
assert_eq!(
Environment::resolve(None, Some("STAGE"), Some("development")).unwrap(),
Environment::Staging
);
assert_eq!(
Environment::resolve(None, None, Some("testing")).unwrap(),
Environment::Test
);
assert_eq!(
Environment::resolve(None, None, None).unwrap(),
Environment::Development
);
assert!(Environment::resolve(Some("unknown"), None, None).is_err());
}
#[test]
fn only_development_exposes_developer_tools() {
assert!(Environment::Development.allows_development_tools());
assert!(!Environment::Test.allows_development_tools());
assert!(Environment::Staging.requires_secure_defaults());
assert!(Environment::Production.requires_secure_defaults());
}
#[test]
fn signed_webhook_csrf_exemptions_must_be_exact_paths() {
let mut config = SecurityConfig::default();
config.csrf_signed_webhook_paths = vec!["/billing/webhook".to_owned()];
assert!(config.validate().is_ok());
for invalid in [
"billing/webhook",
"/billing/:provider",
"/billing/{provider}",
"/billing/*path",
"/billing/../admin",
"/billing/webhook?provider=x",
] {
config.csrf_signed_webhook_paths = vec![invalid.to_owned()];
assert!(config.validate().is_err(), "{invalid} must be rejected");
}
config.csrf_signed_webhook_paths =
vec!["/billing/webhook".to_owned(), "/billing/webhook".to_owned()];
assert!(config.validate().is_err());
}
#[test]
fn browser_security_configuration_is_strict_and_exact() {
let mut config = SecurityConfig::default();
config.cors_allow_origins = vec![
"https://academy.example".to_string(),
"http://localhost:3000".to_string(),
];
assert!(config.validate().is_ok());
for invalid in [
"*",
"academy.example",
"ftp://academy.example",
"https://academy.example/",
"https://academy.example/path",
"https://user@academy.example",
"https://academy.example?x=1",
] {
config.cors_allow_origins = vec![invalid.to_string()];
assert!(config.validate().is_err(), "{invalid} must be rejected");
}
config.cors_allow_origins = vec![
"https://academy.example".to_string(),
"https://academy.example".to_string(),
];
assert!(config.validate().is_err());
config.cors_allow_origins.clear();
config.csrf_same_site = "relaxed".to_string();
assert!(config.validate().is_err());
config.csrf_same_site = "Lax".to_string();
config.csp = "default-src 'self'\r\nx-injected: yes".to_string();
assert!(config.validate().is_err());
}
#[test]
fn coep_policy_is_explicit_and_closed() {
let mut config = SecurityConfig::default();
for policy in ["require-corp", "credentialless", "unsafe-none"] {
config.coep = policy.to_string();
assert!(config.validate().is_ok(), "{policy} must be accepted");
}
for invalid in ["", "off", "cross-origin", "require-corp\nunsafe-none"] {
config.coep = invalid.to_string();
assert!(config.validate().is_err(), "{invalid:?} must be rejected");
}
}
}