use super::error::AppError;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FeaturesConfig {
#[serde(default)]
pub didcomm: bool,
#[serde(default)]
pub tsp: bool,
#[serde(default)]
pub rest_api: bool,
#[serde(default = "default_true")]
pub agent_names: bool,
#[serde(default = "default_deployment_mode")]
pub deployment_mode: String,
}
fn default_deployment_mode() -> String {
"standalone".to_string()
}
fn default_true() -> bool {
true
}
impl Default for FeaturesConfig {
fn default() -> Self {
Self {
didcomm: false,
tsp: false,
rest_api: false,
agent_names: true,
deployment_mode: default_deployment_mode(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportSelection {
Didcomm,
Tsp,
Both,
}
impl TransportSelection {
pub fn as_flags(self) -> (bool, bool) {
match self {
Self::Didcomm => (true, false),
Self::Tsp => (false, true),
Self::Both => (true, true),
}
}
pub fn from_flags(didcomm: bool, tsp: bool) -> Option<Self> {
match (didcomm, tsp) {
(true, true) => Some(Self::Both),
(true, false) => Some(Self::Didcomm),
(false, true) => Some(Self::Tsp),
(false, false) => None,
}
}
pub fn parse(s: &str) -> Result<Self, AppError> {
match s.trim().to_ascii_lowercase().as_str() {
"didcomm" => Ok(Self::Didcomm),
"tsp" => Ok(Self::Tsp),
"both" | "didcomm+tsp" | "tsp+didcomm" => Ok(Self::Both),
other => Err(AppError::Config(format!(
"invalid transport '{other}' (expected 'didcomm', 'tsp', or 'both')"
))),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Didcomm => "didcomm",
Self::Tsp => "tsp",
Self::Both => "both",
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub trusted_proxies: Vec<String>,
#[serde(default)]
pub trusted_proxy_cidrs: Vec<String>,
#[serde(default)]
pub resolve_cache: ResolveCacheConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ResolveCacheConfig {
#[serde(default = "default_resolve_max_age_secs")]
pub max_age_secs: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared_max_age_secs: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stale_while_revalidate_secs: Option<u32>,
}
fn default_resolve_max_age_secs() -> u32 {
300
}
const RESOLVE_CACHE_MAX_SECS: u32 = 31_536_000;
impl Default for ResolveCacheConfig {
fn default() -> Self {
Self {
max_age_secs: default_resolve_max_age_secs(),
shared_max_age_secs: None,
stale_while_revalidate_secs: None,
}
}
}
impl ResolveCacheConfig {
pub fn header_value(&self) -> String {
if self.max_age_secs == 0 && self.shared_max_age_secs.is_none() {
return "no-cache".to_string();
}
let mut value = format!("public, max-age={}", self.max_age_secs);
if let Some(s) = self.shared_max_age_secs {
value.push_str(&format!(", s-maxage={s}"));
}
if let Some(s) = self.stale_while_revalidate_secs {
value.push_str(&format!(", stale-while-revalidate={s}"));
}
value
}
pub fn validate(&self) -> Result<(), AppError> {
let fields = [
("max_age_secs", Some(self.max_age_secs)),
("shared_max_age_secs", self.shared_max_age_secs),
(
"stale_while_revalidate_secs",
self.stale_while_revalidate_secs,
),
];
for (name, value) in fields {
if let Some(v) = value
&& v > RESOLVE_CACHE_MAX_SECS
{
return Err(AppError::Config(format!(
"server.resolve_cache.{name} ({v}) must be at most \
{RESOLVE_CACHE_MAX_SECS} (one year)"
)));
}
}
Ok(())
}
pub fn apply_env_overrides(&mut self, prefix: &str) -> Result<(), AppError> {
fn parse(var: &str, v: &str) -> Result<u32, AppError> {
v.trim()
.parse()
.map_err(|e| AppError::Config(format!("invalid {var}: {e}")))
}
let var = format!("{prefix}_SERVER_RESOLVE_CACHE_MAX_AGE_SECS");
if let Ok(v) = std::env::var(&var) {
self.max_age_secs = parse(&var, &v)?;
}
for (suffix, field) in [
("SHARED_MAX_AGE_SECS", &mut self.shared_max_age_secs),
(
"STALE_WHILE_REVALIDATE_SECS",
&mut self.stale_while_revalidate_secs,
),
] {
let var = format!("{prefix}_SERVER_RESOLVE_CACHE_{suffix}");
if let Ok(v) = std::env::var(&var) {
*field = if v.trim().is_empty() {
None
} else {
Some(parse(&var, &v)?)
};
}
}
self.validate()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub format: LogFormat,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct StoreConfig {
#[serde(default = "default_data_dir")]
pub data_dir: PathBuf,
pub redis_url: Option<String>,
pub dynamodb_table_prefix: Option<String>,
pub dynamodb_region: Option<String>,
pub firestore_project: Option<String>,
pub firestore_database: Option<String>,
pub cosmosdb_connection_string: Option<String>,
pub cosmosdb_database: Option<String>,
pub cosmosdb_region: Option<String>,
}
impl std::fmt::Debug for StoreConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StoreConfig")
.field("data_dir", &self.data_dir)
.field("redis_url", &self.redis_url.as_ref().map(|_| "<redacted>"))
.field("dynamodb_table_prefix", &self.dynamodb_table_prefix)
.field("dynamodb_region", &self.dynamodb_region)
.field("firestore_project", &self.firestore_project)
.field("firestore_database", &self.firestore_database)
.field(
"cosmosdb_connection_string",
&self
.cosmosdb_connection_string
.as_ref()
.map(|_| "<redacted>"),
)
.field("cosmosdb_database", &self.cosmosdb_database)
.field("cosmosdb_region", &self.cosmosdb_region)
.finish()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FjallTuning {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_block_cache"
)]
pub block_cache: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_write_buffer"
)]
pub write_buffer: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_max_journal"
)]
pub max_journal: Option<u64>,
}
pub const MIN_BLOCK_CACHE_BYTES: u64 = 1024 * 1024;
pub const MIN_WRITE_BUFFER_BYTES: u64 = 1024 * 1024;
pub const MIN_MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
const BYTE_SIZE_SUFFIXES: &[(&str, u64)] = &[
("TiB", 1024 * 1024 * 1024 * 1024),
("GiB", 1024 * 1024 * 1024),
("MiB", 1024 * 1024),
("KiB", 1024),
("TB", 1_000_000_000_000),
("GB", 1_000_000_000),
("MB", 1_000_000),
("KB", 1_000),
("B", 1),
];
pub fn parse_byte_size(raw: &str) -> Result<u64, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("value is empty".to_string());
}
if trimmed.starts_with('-') {
return Err(format!(
"{trimmed:?} is negative; a byte size cannot be negative"
));
}
for (suffix, multiplier) in BYTE_SIZE_SUFFIXES {
if trimmed.len() > suffix.len()
&& trimmed[trimmed.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
{
let number_part = trimmed[..trimmed.len() - suffix.len()].trim();
let value: f64 = number_part.parse().map_err(|_| {
format!(
"{trimmed:?} is not a valid byte size (expected a number before the {suffix:?} suffix)"
)
})?;
if !value.is_finite() || value < 0.0 {
return Err(format!("{trimmed:?} is not a valid byte size"));
}
return Ok((value * (*multiplier as f64)).round() as u64);
}
}
trimmed.parse::<u64>().map_err(|_| {
format!(
"{trimmed:?} is not a valid byte size (expected a plain byte count, or a number with \
a B/KB/MB/GB/TB or KiB/MiB/GiB/TiB suffix)"
)
})
}
pub fn human_bytes(bytes: u64) -> String {
const UNITS: &[(&str, u64)] = &[
("TiB", 1024 * 1024 * 1024 * 1024),
("GiB", 1024 * 1024 * 1024),
("MiB", 1024 * 1024),
("KiB", 1024),
];
for (unit, size) in UNITS {
if bytes >= *size {
return format!("{:.2} {unit}", bytes as f64 / *size as f64);
}
}
format!("{bytes} B")
}
pub fn parse_and_validate_fjall_bytes(field: &str, raw: &str, minimum: u64) -> Result<u64, String> {
let bytes = parse_byte_size(raw).map_err(|e| format!("invalid {field} value {raw:?}: {e}"))?;
validate_fjall_bytes(field, bytes, minimum, raw)
}
fn validate_fjall_bytes(
field: &str,
bytes: u64,
minimum: u64,
raw_display: &str,
) -> Result<u64, String> {
if bytes == 0 {
return Err(format!(
"{field} must not be zero (got {raw_display:?}); fjall needs a positive size here"
));
}
if bytes < minimum {
return Err(format!(
"{field} value {raw_display:?} ({bytes} bytes) is too small; must be at least \
{minimum} bytes ({})",
human_bytes(minimum)
));
}
Ok(bytes)
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawByteSize {
Number(u64),
Text(String),
}
fn deserialize_optional_fjall_byte_size<'de, D>(
deserializer: D,
field: &str,
minimum: u64,
) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
match Option::<RawByteSize>::deserialize(deserializer)? {
None => Ok(None),
Some(RawByteSize::Number(n)) => validate_fjall_bytes(field, n, minimum, &n.to_string())
.map(Some)
.map_err(serde::de::Error::custom),
Some(RawByteSize::Text(s)) => {
let bytes = parse_byte_size(&s).map_err(|e| {
serde::de::Error::custom(format!("invalid {field} value {s:?}: {e}"))
})?;
validate_fjall_bytes(field, bytes, minimum, &s)
.map(Some)
.map_err(serde::de::Error::custom)
}
}
}
fn deserialize_block_cache<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_optional_fjall_byte_size(deserializer, "fjall.block_cache", MIN_BLOCK_CACHE_BYTES)
}
fn deserialize_write_buffer<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_optional_fjall_byte_size(deserializer, "fjall.write_buffer", MIN_WRITE_BUFFER_BYTES)
}
fn deserialize_max_journal<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_optional_fjall_byte_size(deserializer, "fjall.max_journal", MIN_MAX_JOURNAL_BYTES)
}
impl FjallTuning {
pub fn validate(&self) -> Result<(), String> {
if let Some(bytes) = self.block_cache {
validate_fjall_bytes(
"fjall.block_cache",
bytes,
MIN_BLOCK_CACHE_BYTES,
&bytes.to_string(),
)?;
}
if let Some(bytes) = self.write_buffer {
validate_fjall_bytes(
"fjall.write_buffer",
bytes,
MIN_WRITE_BUFFER_BYTES,
&bytes.to_string(),
)?;
}
if let Some(bytes) = self.max_journal {
validate_fjall_bytes(
"fjall.max_journal",
bytes,
MIN_MAX_JOURNAL_BYTES,
&bytes.to_string(),
)?;
}
Ok(())
}
}
pub fn apply_fjall_env_overrides(config: &mut FjallTuning) -> Result<(), AppError> {
if let Ok(raw) = std::env::var("STORAGE_FJALL_BLOCK_CACHE") {
config.block_cache = Some(
parse_and_validate_fjall_bytes(
"STORAGE_FJALL_BLOCK_CACHE",
&raw,
MIN_BLOCK_CACHE_BYTES,
)
.map_err(AppError::Config)?,
);
}
if let Ok(raw) = std::env::var("STORAGE_FJALL_WRITE_BUFFER") {
config.write_buffer = Some(
parse_and_validate_fjall_bytes(
"STORAGE_FJALL_WRITE_BUFFER",
&raw,
MIN_WRITE_BUFFER_BYTES,
)
.map_err(AppError::Config)?,
);
}
if let Ok(raw) = std::env::var("STORAGE_FJALL_MAX_JOURNAL") {
config.max_journal = Some(
parse_and_validate_fjall_bytes(
"STORAGE_FJALL_MAX_JOURNAL",
&raw,
MIN_MAX_JOURNAL_BYTES,
)
.map_err(AppError::Config)?,
);
}
Ok(())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthConfig {
#[serde(default = "default_access_token_expiry")]
pub access_token_expiry: u64,
#[serde(default = "default_refresh_token_expiry")]
pub refresh_token_expiry: u64,
#[serde(default = "default_challenge_ttl")]
pub challenge_ttl: u64,
#[serde(default = "default_session_cleanup_interval")]
pub session_cleanup_interval: u64,
#[serde(default = "default_admin_idle_timeout")]
pub admin_idle_timeout: u64,
#[serde(default = "default_passkey_enrollment_ttl")]
pub passkey_enrollment_ttl: u64,
#[serde(default = "default_cleanup_ttl_minutes")]
pub cleanup_ttl_minutes: u64,
#[serde(default = "default_max_global_pending_challenges")]
pub max_global_pending_challenges: usize,
#[serde(default = "default_max_pending_challenges_per_did")]
pub max_pending_challenges_per_did: usize,
#[serde(default = "default_absolute_session_lifetime")]
pub absolute_session_lifetime: u64,
}
fn default_admin_idle_timeout() -> u64 {
900
}
fn default_access_token_expiry() -> u64 {
900
}
fn default_refresh_token_expiry() -> u64 {
86400
}
fn default_challenge_ttl() -> u64 {
30
}
fn default_session_cleanup_interval() -> u64 {
600
}
fn default_passkey_enrollment_ttl() -> u64 {
86400
}
fn default_cleanup_ttl_minutes() -> u64 {
60
}
fn default_max_global_pending_challenges() -> usize {
10_000
}
fn default_max_pending_challenges_per_did() -> usize {
10
}
fn default_absolute_session_lifetime() -> u64 {
30 * 24 * 60 * 60
}
impl AuthConfig {
pub fn validate(&self) -> Result<(), AppError> {
if self.challenge_ttl < 10 {
return Err(AppError::Config(
"challenge_ttl must be at least 10 seconds".into(),
));
}
if self.session_cleanup_interval < 10 {
return Err(AppError::Config(
"session_cleanup_interval must be at least 10 seconds".into(),
));
}
if self.admin_idle_timeout < 60 {
return Err(AppError::Config(
"admin_idle_timeout must be at least 60 seconds".into(),
));
}
if self.access_token_expiry < 30 {
return Err(AppError::Config(
"access_token_expiry must be at least 30 seconds".into(),
));
}
if self.max_global_pending_challenges == 0 {
return Err(AppError::Config(
"max_global_pending_challenges must be at least 1".into(),
));
}
if self.max_pending_challenges_per_did == 0 {
return Err(AppError::Config(
"max_pending_challenges_per_did must be at least 1".into(),
));
}
if self.absolute_session_lifetime < self.refresh_token_expiry {
return Err(AppError::Config(
"absolute_session_lifetime must be at least refresh_token_expiry".into(),
));
}
Ok(())
}
pub fn pending_challenge_retry_after_secs(&self) -> u64 {
self.challenge_ttl
.saturating_add(self.session_cleanup_interval)
}
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
access_token_expiry: default_access_token_expiry(),
refresh_token_expiry: default_refresh_token_expiry(),
challenge_ttl: default_challenge_ttl(),
session_cleanup_interval: default_session_cleanup_interval(),
admin_idle_timeout: default_admin_idle_timeout(),
passkey_enrollment_ttl: default_passkey_enrollment_ttl(),
cleanup_ttl_minutes: default_cleanup_ttl_minutes(),
max_global_pending_challenges: default_max_global_pending_challenges(),
max_pending_challenges_per_did: default_max_pending_challenges_per_did(),
absolute_session_lifetime: default_absolute_session_lifetime(),
}
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Text,
Json,
}
fn default_host() -> String {
"0.0.0.0".to_string()
}
fn default_port() -> u16 {
8530
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_data_dir() -> PathBuf {
PathBuf::from("data/did-hosting-server")
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
trusted_proxies: Vec::new(),
trusted_proxy_cidrs: Vec::new(),
resolve_cache: Default::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct HostingConfig {
#[serde(default)]
pub bootstrap_domains: Vec<String>,
#[serde(default = "default_unassigned_purge_grace")]
pub unassigned_purge_grace: String,
#[serde(default = "default_disable_purge_grace")]
pub disable_purge_grace: String,
}
fn default_unassigned_purge_grace() -> String {
"2h".to_string()
}
fn default_disable_purge_grace() -> String {
"30d".to_string()
}
impl Default for HostingConfig {
fn default() -> Self {
Self {
bootstrap_domains: Vec::new(),
unassigned_purge_grace: default_unassigned_purge_grace(),
disable_purge_grace: default_disable_purge_grace(),
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: LogFormat::default(),
}
}
}
impl Default for StoreConfig {
fn default() -> Self {
Self {
data_dir: default_data_dir(),
redis_url: None,
dynamodb_table_prefix: None,
dynamodb_region: None,
firestore_project: None,
firestore_database: None,
cosmosdb_connection_string: None,
cosmosdb_database: None,
cosmosdb_region: None,
}
}
}
#[derive(Clone, Deserialize, Serialize)]
pub struct SecretsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<crate::server::secret_store::SecretBackend>,
pub aws_secret_name: Option<String>,
pub aws_region: Option<String>,
pub gcp_project: Option<String>,
pub gcp_secret_name: Option<String>,
pub azure_vault_url: Option<String>,
pub azure_secret_name: Option<String>,
#[serde(default = "default_keyring_service")]
pub keyring_service: String,
pub vault_addr: Option<String>,
#[serde(default = "default_vault_kv_mount")]
pub vault_kv_mount: String,
pub vault_secret_path: Option<String>,
#[serde(default = "default_vault_secret_key")]
pub vault_secret_key: String,
pub vault_namespace: Option<String>,
#[serde(default = "default_vault_auth_method")]
pub vault_auth_method: String,
pub vault_k8s_role: Option<String>,
#[serde(default = "default_vault_k8s_mount")]
pub vault_k8s_mount: String,
#[serde(default = "default_vault_k8s_jwt_path")]
pub vault_k8s_jwt_path: String,
pub vault_token: Option<String>,
pub vault_approle_role_id: Option<String>,
pub vault_approle_secret_id: Option<String>,
#[serde(default = "default_vault_approle_mount")]
pub vault_approle_mount: String,
#[serde(default, skip_serializing_if = "is_false")]
pub vault_skip_verify: bool,
pub k8s_secret_name: Option<String>,
pub k8s_namespace: Option<String>,
#[serde(default = "default_k8s_secret_key")]
pub k8s_secret_key: String,
#[serde(default, skip_serializing_if = "is_false")]
pub confirm_plaintext: bool,
}
impl std::fmt::Debug for SecretsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretsConfig")
.field("backend", &self.backend)
.field("aws_secret_name", &self.aws_secret_name)
.field("aws_region", &self.aws_region)
.field("gcp_project", &self.gcp_project)
.field("gcp_secret_name", &self.gcp_secret_name)
.field("azure_vault_url", &self.azure_vault_url)
.field("azure_secret_name", &self.azure_secret_name)
.field("keyring_service", &self.keyring_service)
.field("vault_addr", &self.vault_addr)
.field("vault_kv_mount", &self.vault_kv_mount)
.field("vault_secret_path", &self.vault_secret_path)
.field("vault_secret_key", &self.vault_secret_key)
.field("vault_namespace", &self.vault_namespace)
.field("vault_auth_method", &self.vault_auth_method)
.field("vault_k8s_role", &self.vault_k8s_role)
.field("vault_k8s_mount", &self.vault_k8s_mount)
.field("vault_k8s_jwt_path", &self.vault_k8s_jwt_path)
.field(
"vault_token",
&self.vault_token.as_ref().map(|_| "<redacted>"),
)
.field("vault_approle_role_id", &self.vault_approle_role_id)
.field(
"vault_approle_secret_id",
&self.vault_approle_secret_id.as_ref().map(|_| "<redacted>"),
)
.field("vault_approle_mount", &self.vault_approle_mount)
.field("vault_skip_verify", &self.vault_skip_verify)
.field("k8s_secret_name", &self.k8s_secret_name)
.field("k8s_namespace", &self.k8s_namespace)
.field("k8s_secret_key", &self.k8s_secret_key)
.field("confirm_plaintext", &self.confirm_plaintext)
.finish()
}
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct VtaConfig {
pub url: Option<String>,
pub did: Option<String>,
pub context_id: Option<String>,
}
#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum IdentityMode {
#[default]
Vta,
SelfManaged,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct IdentityConfig {
#[serde(default)]
pub mode: IdentityMode,
#[serde(default = "default_rotation_grace_period")]
pub rotation_grace_period: String,
}
fn default_rotation_grace_period() -> String {
"1h".to_string()
}
impl Default for IdentityConfig {
fn default() -> Self {
Self {
mode: IdentityMode::default(),
rotation_grace_period: default_rotation_grace_period(),
}
}
}
impl IdentityConfig {
pub fn rotation_grace_secs(&self) -> u64 {
match crate::server::pending_purge::parse_grace_string(&self.rotation_grace_period) {
Ok(secs) => secs,
Err(e) => {
tracing::warn!(
value = %self.rotation_grace_period,
"invalid identity.rotation_grace_period ({e}) — falling back to 1h"
);
3600
}
}
}
}
fn default_keyring_service() -> String {
"webvh".to_string()
}
fn default_vault_kv_mount() -> String {
"secret".to_string()
}
fn default_vault_secret_key() -> String {
"seed".to_string()
}
fn default_vault_auth_method() -> String {
"kubernetes".to_string()
}
fn default_vault_k8s_mount() -> String {
"kubernetes".to_string()
}
fn default_vault_k8s_jwt_path() -> String {
"/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
}
fn default_vault_approle_mount() -> String {
"approle".to_string()
}
fn default_k8s_secret_key() -> String {
"seed".to_string()
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
backend: None,
aws_secret_name: None,
aws_region: None,
gcp_project: None,
gcp_secret_name: None,
azure_vault_url: None,
azure_secret_name: None,
keyring_service: default_keyring_service(),
vault_addr: None,
vault_kv_mount: default_vault_kv_mount(),
vault_secret_path: None,
vault_secret_key: default_vault_secret_key(),
vault_namespace: None,
vault_auth_method: default_vault_auth_method(),
vault_k8s_role: None,
vault_k8s_mount: default_vault_k8s_mount(),
vault_k8s_jwt_path: default_vault_k8s_jwt_path(),
vault_token: None,
vault_approle_role_id: None,
vault_approle_secret_id: None,
vault_approle_mount: default_vault_approle_mount(),
vault_skip_verify: false,
k8s_secret_name: None,
k8s_namespace: None,
k8s_secret_key: default_k8s_secret_key(),
confirm_plaintext: false,
}
}
}
fn is_false(b: &bool) -> bool {
!*b
}
pub fn apply_env_overrides(
prefix: &str,
features: &mut FeaturesConfig,
server: &mut ServerConfig,
log: &mut LogConfig,
store: &mut StoreConfig,
auth: &mut AuthConfig,
secrets: &mut SecretsConfig,
) -> Result<(), AppError> {
macro_rules! env_str {
($var:expr, $field:expr) => {
if let Ok(v) = std::env::var($var) {
$field = v;
}
};
}
macro_rules! env_opt {
($var:expr, $field:expr) => {
if let Ok(v) = std::env::var($var) {
$field = Some(v);
}
};
}
macro_rules! env_parse {
($var:expr, $field:expr) => {
if let Ok(v) = std::env::var($var) {
$field = v
.parse()
.map_err(|e| AppError::Config(format!("invalid {}: {e}", $var)))?;
}
};
}
macro_rules! env_bool {
($var:expr, $field:expr) => {
if let Ok(v) = std::env::var($var) {
$field = v == "1" || v.eq_ignore_ascii_case("true");
}
};
}
env_bool!(&format!("{prefix}_FEATURES_DIDCOMM"), features.didcomm);
env_bool!(&format!("{prefix}_FEATURES_TSP"), features.tsp);
env_bool!(&format!("{prefix}_FEATURES_REST_API"), features.rest_api);
env_bool!(
&format!("{prefix}_FEATURES_AGENT_NAMES"),
features.agent_names
);
env_str!(&format!("{prefix}_SERVER_HOST"), server.host);
env_parse!(&format!("{prefix}_SERVER_PORT"), server.port);
env_str!(&format!("{prefix}_LOG_LEVEL"), log.level);
let log_format_var = format!("{prefix}_LOG_FORMAT");
if let Ok(format) = std::env::var(&log_format_var) {
log.format = match format.to_lowercase().as_str() {
"json" => LogFormat::Json,
"text" => LogFormat::Text,
other => {
return Err(AppError::Config(format!(
"invalid {log_format_var} '{other}', expected 'text' or 'json'"
)));
}
};
}
let store_data_dir_var = format!("{prefix}_STORE_DATA_DIR");
if let Ok(data_dir) = std::env::var(&store_data_dir_var) {
store.data_dir = PathBuf::from(data_dir);
}
env_opt!(&format!("{prefix}_STORE_REDIS_URL"), store.redis_url);
env_opt!(
&format!("{prefix}_STORE_DYNAMODB_TABLE_PREFIX"),
store.dynamodb_table_prefix
);
env_opt!(
&format!("{prefix}_STORE_DYNAMODB_REGION"),
store.dynamodb_region
);
env_opt!(
&format!("{prefix}_STORE_FIRESTORE_PROJECT"),
store.firestore_project
);
env_opt!(
&format!("{prefix}_STORE_FIRESTORE_DATABASE"),
store.firestore_database
);
env_opt!(
&format!("{prefix}_STORE_COSMOSDB_CONNECTION_STRING"),
store.cosmosdb_connection_string
);
env_opt!(
&format!("{prefix}_STORE_COSMOSDB_DATABASE"),
store.cosmosdb_database
);
env_opt!(
&format!("{prefix}_STORE_COSMOSDB_REGION"),
store.cosmosdb_region
);
env_parse!(
&format!("{prefix}_AUTH_ACCESS_EXPIRY"),
auth.access_token_expiry
);
env_parse!(
&format!("{prefix}_AUTH_REFRESH_EXPIRY"),
auth.refresh_token_expiry
);
env_parse!(&format!("{prefix}_AUTH_CHALLENGE_TTL"), auth.challenge_ttl);
env_parse!(
&format!("{prefix}_AUTH_SESSION_CLEANUP_INTERVAL"),
auth.session_cleanup_interval
);
env_parse!(
&format!("{prefix}_AUTH_ADMIN_IDLE_TIMEOUT"),
auth.admin_idle_timeout
);
env_parse!(
&format!("{prefix}_AUTH_PASSKEY_ENROLLMENT_TTL"),
auth.passkey_enrollment_ttl
);
env_parse!(
&format!("{prefix}_CLEANUP_TTL_MINUTES"),
auth.cleanup_ttl_minutes
);
env_parse!(
&format!("{prefix}_AUTH_MAX_GLOBAL_PENDING_CHALLENGES"),
auth.max_global_pending_challenges
);
env_parse!(
&format!("{prefix}_AUTH_MAX_PENDING_CHALLENGES_PER_DID"),
auth.max_pending_challenges_per_did
);
env_opt!(
&format!("{prefix}_SECRETS_AWS_SECRET_NAME"),
secrets.aws_secret_name
);
env_opt!(&format!("{prefix}_SECRETS_AWS_REGION"), secrets.aws_region);
env_opt!(
&format!("{prefix}_SECRETS_GCP_PROJECT"),
secrets.gcp_project
);
env_opt!(
&format!("{prefix}_SECRETS_GCP_SECRET_NAME"),
secrets.gcp_secret_name
);
env_opt!(
&format!("{prefix}_SECRETS_AZURE_VAULT_URL"),
secrets.azure_vault_url
);
env_opt!(
&format!("{prefix}_SECRETS_AZURE_SECRET_NAME"),
secrets.azure_secret_name
);
env_str!(
&format!("{prefix}_SECRETS_KEYRING_SERVICE"),
secrets.keyring_service
);
env_opt!(&format!("{prefix}_SECRETS_VAULT_ADDR"), secrets.vault_addr);
env_str!(
&format!("{prefix}_SECRETS_VAULT_KV_MOUNT"),
secrets.vault_kv_mount
);
env_opt!(
&format!("{prefix}_SECRETS_VAULT_SECRET_PATH"),
secrets.vault_secret_path
);
env_str!(
&format!("{prefix}_SECRETS_VAULT_SECRET_KEY"),
secrets.vault_secret_key
);
env_opt!(
&format!("{prefix}_SECRETS_VAULT_NAMESPACE"),
secrets.vault_namespace
);
env_str!(
&format!("{prefix}_SECRETS_VAULT_AUTH_METHOD"),
secrets.vault_auth_method
);
env_opt!(
&format!("{prefix}_SECRETS_VAULT_K8S_ROLE"),
secrets.vault_k8s_role
);
env_str!(
&format!("{prefix}_SECRETS_VAULT_K8S_MOUNT"),
secrets.vault_k8s_mount
);
env_str!(
&format!("{prefix}_SECRETS_VAULT_K8S_JWT_PATH"),
secrets.vault_k8s_jwt_path
);
env_opt!(
&format!("{prefix}_SECRETS_VAULT_TOKEN"),
secrets.vault_token
);
env_opt!(
&format!("{prefix}_SECRETS_VAULT_APPROLE_ROLE_ID"),
secrets.vault_approle_role_id
);
env_opt!(
&format!("{prefix}_SECRETS_VAULT_APPROLE_SECRET_ID"),
secrets.vault_approle_secret_id
);
env_str!(
&format!("{prefix}_SECRETS_VAULT_APPROLE_MOUNT"),
secrets.vault_approle_mount
);
env_bool!(
&format!("{prefix}_SECRETS_VAULT_SKIP_VERIFY"),
secrets.vault_skip_verify
);
env_opt!(
&format!("{prefix}_SECRETS_K8S_SECRET_NAME"),
secrets.k8s_secret_name
);
env_opt!(
&format!("{prefix}_SECRETS_K8S_NAMESPACE"),
secrets.k8s_namespace
);
env_str!(
&format!("{prefix}_SECRETS_K8S_SECRET_KEY"),
secrets.k8s_secret_key
);
Ok(())
}
pub fn init_tracing(log: &LogConfig) {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&log.level));
let subscriber = tracing_subscriber::fmt().with_env_filter(filter);
let result = match log.format {
LogFormat::Json => subscriber.json().try_init(),
LogFormat::Text => subscriber.try_init(),
};
if let Err(e) = result {
eprintln!("tracing subscriber already initialised; continuing ({e})");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_names_default_on_for_absent_and_empty_config() {
assert!(FeaturesConfig::default().agent_names);
let cfg: FeaturesConfig = toml::from_str("").unwrap();
assert!(cfg.agent_names);
assert!(!cfg.didcomm);
assert!(!cfg.tsp);
assert!(!cfg.rest_api);
}
#[test]
fn agent_names_can_still_be_switched_off() {
let cfg: FeaturesConfig = toml::from_str("agent_names = false").unwrap();
assert!(!cfg.agent_names);
}
#[test]
fn features_default_matches_toml_round_trip() {
let from_toml: FeaturesConfig = toml::from_str("").unwrap();
let from_default = FeaturesConfig::default();
assert_eq!(from_toml.agent_names, from_default.agent_names);
assert_eq!(from_toml.deployment_mode, from_default.deployment_mode);
assert_eq!(from_default.deployment_mode, "standalone");
}
#[test]
fn identity_mode_default_is_vta() {
assert_eq!(IdentityMode::default(), IdentityMode::Vta);
assert_eq!(IdentityConfig::default().mode, IdentityMode::Vta);
}
#[test]
fn identity_mode_serializes_kebab_case() {
let toml_str = toml::to_string(&IdentityConfig {
mode: IdentityMode::SelfManaged,
..Default::default()
})
.unwrap();
assert!(
toml_str.contains(r#"mode = "self-managed""#),
"expected kebab-case `self-managed`, got: {toml_str}"
);
let toml_str = toml::to_string(&IdentityConfig {
mode: IdentityMode::Vta,
..Default::default()
})
.unwrap();
assert!(
toml_str.contains(r#"mode = "vta""#),
"expected `vta`, got: {toml_str}"
);
}
#[test]
fn identity_config_deserializes_self_managed() {
let cfg: IdentityConfig = toml::from_str(r#"mode = "self-managed""#).unwrap();
assert_eq!(cfg.mode, IdentityMode::SelfManaged);
}
#[test]
fn identity_config_deserializes_vta() {
let cfg: IdentityConfig = toml::from_str(r#"mode = "vta""#).unwrap();
assert_eq!(cfg.mode, IdentityMode::Vta);
}
#[test]
fn identity_config_round_trips() {
let original = IdentityConfig {
mode: IdentityMode::SelfManaged,
..Default::default()
};
let serialized = toml::to_string(&original).unwrap();
let deserialized: IdentityConfig = toml::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn identity_config_missing_mode_defaults_to_vta() {
let cfg: IdentityConfig = toml::from_str("").unwrap();
assert_eq!(cfg.mode, IdentityMode::Vta);
}
#[test]
fn identity_config_unknown_mode_rejected() {
let result: Result<IdentityConfig, _> = toml::from_str(r#"mode = "bogus""#);
assert!(
result.is_err(),
"expected unknown mode to be rejected, got: {result:?}"
);
}
#[test]
fn transport_selection_flags() {
assert_eq!(TransportSelection::Didcomm.as_flags(), (true, false));
assert_eq!(TransportSelection::Tsp.as_flags(), (false, true));
assert_eq!(TransportSelection::Both.as_flags(), (true, true));
}
#[test]
fn transport_selection_parse_accepts_known_values() {
assert_eq!(
TransportSelection::parse("didcomm").unwrap(),
TransportSelection::Didcomm
);
assert_eq!(
TransportSelection::parse("TSP").unwrap(),
TransportSelection::Tsp
);
assert_eq!(
TransportSelection::parse(" Both ").unwrap(),
TransportSelection::Both
);
assert_eq!(
TransportSelection::parse("didcomm+tsp").unwrap(),
TransportSelection::Both
);
}
#[test]
fn transport_selection_parse_rejects_unknown() {
assert!(TransportSelection::parse("carrier-pigeon").is_err());
}
#[test]
fn transport_selection_from_flags() {
assert_eq!(
TransportSelection::from_flags(true, true),
Some(TransportSelection::Both)
);
assert_eq!(
TransportSelection::from_flags(true, false),
Some(TransportSelection::Didcomm)
);
assert_eq!(
TransportSelection::from_flags(false, true),
Some(TransportSelection::Tsp)
);
assert_eq!(TransportSelection::from_flags(false, false), None);
}
#[test]
fn transport_selection_flags_round_trip() {
for sel in [
TransportSelection::Didcomm,
TransportSelection::Tsp,
TransportSelection::Both,
] {
let (d, t) = sel.as_flags();
assert_eq!(TransportSelection::from_flags(d, t), Some(sel));
}
}
#[test]
fn transport_selection_str_round_trips() {
for sel in [
TransportSelection::Didcomm,
TransportSelection::Tsp,
TransportSelection::Both,
] {
assert_eq!(TransportSelection::parse(sel.as_str()).unwrap(), sel);
}
}
}
#[cfg(test)]
mod fjall_config_tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const FJALL_ENV_VARS: [&str; 3] = [
"STORAGE_FJALL_BLOCK_CACHE",
"STORAGE_FJALL_WRITE_BUFFER",
"STORAGE_FJALL_MAX_JOURNAL",
];
fn clear_fjall_env() {
unsafe {
for var in FJALL_ENV_VARS {
std::env::remove_var(var);
}
}
}
#[test]
fn parse_byte_size_accepts_a_plain_byte_count() {
assert_eq!(parse_byte_size("67108864").unwrap(), 67_108_864);
assert_eq!(parse_byte_size("0").unwrap(), 0);
assert_eq!(
parse_byte_size(" 1024 ").unwrap(),
1024,
"whitespace is trimmed"
);
}
#[test]
fn parse_byte_size_accepts_every_binary_suffix() {
assert_eq!(parse_byte_size("1KiB").unwrap(), 1024);
assert_eq!(parse_byte_size("64MiB").unwrap(), 64 * 1024 * 1024);
assert_eq!(parse_byte_size("1GiB").unwrap(), 1024 * 1024 * 1024);
assert_eq!(
parse_byte_size("2TiB").unwrap(),
2 * 1024 * 1024 * 1024 * 1024
);
}
#[test]
fn parse_byte_size_accepts_every_decimal_suffix() {
assert_eq!(parse_byte_size("512B").unwrap(), 512);
assert_eq!(parse_byte_size("1KB").unwrap(), 1_000);
assert_eq!(parse_byte_size("512MB").unwrap(), 512_000_000);
assert_eq!(parse_byte_size("1GB").unwrap(), 1_000_000_000);
assert_eq!(parse_byte_size("1TB").unwrap(), 1_000_000_000_000);
}
#[test]
fn parse_byte_size_is_case_insensitive_and_accepts_fractions() {
assert_eq!(parse_byte_size("64mib").unwrap(), 64 * 1024 * 1024);
assert_eq!(
parse_byte_size("1.5GiB").unwrap(),
(1.5 * 1024.0 * 1024.0 * 1024.0) as u64
);
}
#[test]
fn parse_byte_size_rejects_garbage_and_unknown_suffixes() {
assert!(parse_byte_size("").is_err(), "empty");
assert!(parse_byte_size(" ").is_err(), "whitespace only");
assert!(parse_byte_size("not-a-size").is_err(), "non-numeric");
assert!(parse_byte_size("64XiB").is_err(), "unrecognised suffix");
assert!(parse_byte_size("-64MiB").is_err(), "negative");
assert!(parse_byte_size("-1").is_err(), "negative, no suffix");
assert!(parse_byte_size("MiB").is_err(), "suffix with no number");
}
#[test]
fn parse_and_validate_rejects_zero_naming_the_field() {
let err =
parse_and_validate_fjall_bytes("STORAGE_FJALL_BLOCK_CACHE", "0", MIN_BLOCK_CACHE_BYTES)
.unwrap_err();
assert!(err.contains("STORAGE_FJALL_BLOCK_CACHE"), "got: {err}");
assert!(err.contains("zero"), "got: {err}");
}
#[test]
fn parse_and_validate_rejects_an_absurdly_small_value_naming_the_field() {
let err = parse_and_validate_fjall_bytes(
"STORAGE_FJALL_MAX_JOURNAL",
"1KiB",
MIN_MAX_JOURNAL_BYTES,
)
.unwrap_err();
assert!(err.contains("STORAGE_FJALL_MAX_JOURNAL"), "got: {err}");
assert!(err.contains("too small"), "got: {err}");
}
#[test]
fn parse_and_validate_rejects_an_unparseable_value_naming_the_field() {
let err = parse_and_validate_fjall_bytes(
"STORAGE_FJALL_WRITE_BUFFER",
"garbage",
MIN_WRITE_BUFFER_BYTES,
)
.unwrap_err();
assert!(err.contains("STORAGE_FJALL_WRITE_BUFFER"), "got: {err}");
}
#[test]
fn parse_and_validate_accepts_a_value_at_exactly_the_floor() {
assert_eq!(
parse_and_validate_fjall_bytes(
"STORAGE_FJALL_MAX_JOURNAL",
"64MiB",
MIN_MAX_JOURNAL_BYTES
)
.unwrap(),
MIN_MAX_JOURNAL_BYTES
);
}
#[test]
fn fjall_tuning_validate_passes_when_every_field_is_unset() {
assert!(FjallTuning::default().validate().is_ok());
}
#[test]
fn fjall_tuning_validate_reports_a_below_floor_field() {
let cfg = FjallTuning {
block_cache: None,
write_buffer: Some(1024),
max_journal: None,
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("fjall.write_buffer"), "got: {err}");
}
#[test]
fn an_absent_fjall_table_defaults_every_field_to_unset() {
let cfg: FjallTuning = toml::from_str("").expect("loads");
assert_eq!(cfg, FjallTuning::default());
}
#[test]
fn fjall_table_accepts_suffixed_strings_and_plain_integers() {
let cfg: FjallTuning = toml::from_str(
r#"
block_cache = "64MiB"
write_buffer = "16MiB"
max_journal = 134217728
"#,
)
.expect("loads");
assert_eq!(cfg.block_cache, Some(64 * 1024 * 1024));
assert_eq!(cfg.write_buffer, Some(16 * 1024 * 1024));
assert_eq!(cfg.max_journal, Some(134_217_728));
}
#[test]
fn fjall_table_refuses_a_below_floor_value_at_parse_time() {
let err = toml::from_str::<FjallTuning>(r#"block_cache = "1B""#)
.expect_err("a below-floor block_cache must be refused when the file is parsed");
let msg = err.to_string();
assert!(msg.contains("fjall.block_cache"), "got: {msg}");
}
#[test]
fn fjall_table_refuses_an_unknown_suffix_at_parse_time() {
let err = toml::from_str::<FjallTuning>(r#"max_journal = "64XiB""#)
.expect_err("an unparseable size must be refused when the file is parsed");
assert!(err.to_string().contains("fjall.max_journal"));
}
#[test]
fn env_override_wins_over_the_file_value() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_fjall_env();
let mut cfg = FjallTuning {
block_cache: Some(32 * 1024 * 1024),
write_buffer: None,
max_journal: None,
};
unsafe {
std::env::set_var("STORAGE_FJALL_BLOCK_CACHE", "8MiB");
}
apply_fjall_env_overrides(&mut cfg).expect("valid override applies");
assert_eq!(
cfg.block_cache,
Some(8 * 1024 * 1024),
"the env var must win over whatever the file set"
);
clear_fjall_env();
}
#[test]
fn an_unset_env_var_leaves_the_file_value_untouched() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_fjall_env();
let mut cfg = FjallTuning {
block_cache: Some(32 * 1024 * 1024),
write_buffer: Some(MIN_WRITE_BUFFER_BYTES),
max_journal: None,
};
let before = cfg;
apply_fjall_env_overrides(&mut cfg).expect("no env vars set: nothing to apply");
assert_eq!(
cfg, before,
"with no STORAGE_FJALL_* vars set, nothing changes"
);
}
#[test]
fn env_override_refuses_an_invalid_value_naming_the_var() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
clear_fjall_env();
unsafe {
std::env::set_var("STORAGE_FJALL_MAX_JOURNAL", "1KiB");
}
let err = apply_fjall_env_overrides(&mut FjallTuning::default())
.expect_err("a below-floor journal size must be refused");
assert!(
err.to_string().contains("STORAGE_FJALL_MAX_JOURNAL"),
"got: {err}"
);
clear_fjall_env();
}
#[test]
fn human_bytes_renders_the_natural_unit() {
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(64 * 1024 * 1024), "64.00 MiB");
assert_eq!(human_bytes(1024 * 1024 * 1024), "1.00 GiB");
}
}
#[cfg(test)]
mod resolve_cache_tests {
use super::*;
#[test]
fn default_is_five_minutes_public() {
let c = ResolveCacheConfig::default();
assert_eq!(c.header_value(), "public, max-age=300");
}
#[test]
fn optional_directives_are_appended() {
let c = ResolveCacheConfig {
max_age_secs: 60,
shared_max_age_secs: Some(600),
stale_while_revalidate_secs: Some(30),
};
assert_eq!(
c.header_value(),
"public, max-age=60, s-maxage=600, stale-while-revalidate=30"
);
}
#[test]
fn zero_max_age_means_no_cache() {
let c = ResolveCacheConfig {
max_age_secs: 0,
..Default::default()
};
assert_eq!(c.header_value(), "no-cache");
let c = ResolveCacheConfig {
max_age_secs: 0,
shared_max_age_secs: Some(120),
stale_while_revalidate_secs: None,
};
assert_eq!(c.header_value(), "public, max-age=0, s-maxage=120");
}
#[test]
fn durations_past_a_year_are_refused() {
let c = ResolveCacheConfig {
stale_while_revalidate_secs: Some(RESOLVE_CACHE_MAX_SECS + 1),
..Default::default()
};
assert!(c.validate().is_err());
let c = ResolveCacheConfig {
max_age_secs: RESOLVE_CACHE_MAX_SECS,
..Default::default()
};
assert!(c.validate().is_ok());
}
#[test]
fn toml_table_parses_and_defaults_when_absent() {
let s: ServerConfig = toml::from_str("host = \"0.0.0.0\"").unwrap();
assert_eq!(s.resolve_cache, ResolveCacheConfig::default());
let s: ServerConfig = toml::from_str(
"[resolve_cache]\nmax_age_secs = 30\nstale_while_revalidate_secs = 60\n",
)
.unwrap();
assert_eq!(s.resolve_cache.max_age_secs, 30);
assert_eq!(s.resolve_cache.stale_while_revalidate_secs, Some(60));
assert_eq!(s.resolve_cache.shared_max_age_secs, None);
assert!(toml::from_str::<ServerConfig>("[resolve_cache]\nmax_age = 30\n").is_err());
}
#[test]
fn env_overrides_set_clear_and_validate() {
const MAX: &str = "RCTEST_SERVER_RESOLVE_CACHE_MAX_AGE_SECS";
const SHARED: &str = "RCTEST_SERVER_RESOLVE_CACHE_SHARED_MAX_AGE_SECS";
const SWR: &str = "RCTEST_SERVER_RESOLVE_CACHE_STALE_WHILE_REVALIDATE_SECS";
unsafe {
std::env::set_var(MAX, "45");
std::env::set_var(SHARED, "900");
std::env::set_var(SWR, "");
}
let mut c = ResolveCacheConfig {
stale_while_revalidate_secs: Some(10),
..Default::default()
};
c.apply_env_overrides("RCTEST").unwrap();
assert_eq!(c.max_age_secs, 45);
assert_eq!(c.shared_max_age_secs, Some(900));
assert_eq!(c.stale_while_revalidate_secs, None, "empty clears");
unsafe { std::env::set_var(MAX, "soon") };
assert!(c.apply_env_overrides("RCTEST").is_err());
unsafe { std::env::set_var(MAX, "99999999") };
assert!(c.apply_env_overrides("RCTEST").is_err(), "past a year");
unsafe {
std::env::remove_var(MAX);
std::env::remove_var(SHARED);
std::env::remove_var(SWR);
}
}
}