use crate::error::AppError;
use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub use vti_common::config::{
AuthConfig, FjallTuning, LogConfig, LogFormat, MessagingConfig, StoreConfig,
};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
pub vtc_did: Option<String>,
pub vta_did: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vta_context: Option<String>,
#[serde(alias = "community_name")]
pub vtc_name: Option<String>,
#[serde(alias = "community_description")]
pub vtc_description: Option<String>,
pub public_url: Option<String>,
#[serde(default = "default_server_config")]
pub server: ServerConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default = "default_store_config")]
pub store: StoreConfig,
#[serde(default)]
pub fjall: FjallTuning,
pub messaging: Option<MessagingConfig>,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub audit_checkpoints: crate::audit_checkpoint::AuditCheckpointConfig,
#[serde(default)]
pub routing: RoutingConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub registry: RegistryConfig,
#[serde(default)]
pub hooks: crate::hooks::HooksConfig,
#[serde(default)]
pub git_ns: crate::git_ns::GitNsConfig,
#[serde(default)]
pub renewal: RenewalConfig,
#[serde(default)]
pub join_requests: crate::join::retention::JoinRequestsConfig,
#[serde(default)]
pub website: WebsiteConfig,
#[serde(default)]
pub admin_ui: AdminUiConfig,
#[serde(default)]
pub trust_tasks: TrustTasksConfig,
#[serde(default)]
pub acl: AclConfig,
#[serde(default)]
pub did_cache: vti_common::config::DidCacheConfig,
#[serde(default)]
pub geoip: GeoIpConfig,
#[serde(default)]
pub rooms: RoomsConfig,
#[serde(default)]
pub oid4vp: Oid4vpConfig,
#[serde(
default,
skip_serializing_if = "crate::credentials::trusted_witnesses::WitnessesConfig::is_empty"
)]
pub witnesses: crate::credentials::trusted_witnesses::WitnessesConfig,
#[serde(default, skip_serializing_if = "DirectoryConfig::is_default")]
pub directory: DirectoryConfig,
#[serde(skip)]
pub config_path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct DirectoryConfig {
#[serde(default = "DirectoryConfig::default_listing")]
pub default_listing: String,
#[serde(default = "DirectoryConfig::default_retention_days")]
pub retention_days: u32,
#[serde(default = "DirectoryConfig::default_facet_threshold")]
pub facet_threshold: u32,
#[serde(default = "DirectoryConfig::default_list_rate")]
pub list_rate_per_minute: u32,
}
impl DirectoryConfig {
fn default_listing() -> String {
"listed".into()
}
fn default_retention_days() -> u32 {
30
}
fn default_facet_threshold() -> u32 {
5
}
fn default_list_rate() -> u32 {
60
}
pub fn is_default(&self) -> bool {
self == &Self::default()
}
pub fn settings(&self) -> Result<crate::directory::model::Settings, String> {
let default_listing = crate::directory::model::Listing::parse(&self.default_listing)
.ok_or_else(|| {
format!(
"[directory] default_listing = {:?}: expected listed, findable or hidden",
self.default_listing
)
})?;
if self.facet_threshold < 2 {
return Err("[directory] facet_threshold must be at least 2".into());
}
if self.list_rate_per_minute == 0 {
return Err("[directory] list_rate_per_minute must be at least 1".into());
}
Ok(crate::directory::model::Settings {
default_listing,
retention_days: self.retention_days,
facet_threshold: self.facet_threshold,
list_rate_per_minute: self.list_rate_per_minute,
})
}
}
impl Default for DirectoryConfig {
fn default() -> Self {
Self {
default_listing: Self::default_listing(),
retention_days: Self::default_retention_days(),
facet_threshold: Self::default_facet_threshold(),
list_rate_per_minute: Self::default_list_rate(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct RoomsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub files: Option<RoomFilesConfig>,
#[serde(default, skip_serializing_if = "RoomRateLimitsConfig::is_unset")]
pub rate_limits: RoomRateLimitsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub community: Option<CommunityRoomsConfig>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct CommunityRoomsConfig {
pub context: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webvh_server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mediator_did: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority_valid_days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority_renew_within_days: Option<u32>,
}
impl CommunityRoomsConfig {
pub fn authority_valid_days(&self) -> u32 {
self.authority_valid_days.unwrap_or(365).max(1)
}
pub fn authority_renew_within(&self) -> chrono::Duration {
let configured = chrono::Duration::days(i64::from(
self.authority_renew_within_days
.unwrap_or(crate::rooms::community_renewal::DEFAULT_RENEW_WITHIN_DAYS),
));
let half_life = chrono::Duration::days(i64::from(self.authority_valid_days())) / 2;
configured.min(half_life)
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct RoomRateLimitsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address_burst: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address_interval_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presenter_burst: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub presenter_interval_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transfer_burst: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transfer_interval_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RoomBudget {
pub burst: u32,
pub interval: std::time::Duration,
}
impl RoomBudget {
fn from(burst: Option<u32>, interval_ms: Option<u64>, default: RoomBudget) -> Self {
Self {
burst: burst.map_or(default.burst, |b| b.max(1)),
interval: interval_ms.map_or(default.interval, |ms| {
std::time::Duration::from_millis(ms.max(1))
}),
}
}
}
pub const ROOM_ADDRESS_DEFAULT: RoomBudget = RoomBudget {
burst: 400,
interval: std::time::Duration::from_millis(10),
};
pub const ROOM_PRESENTER_DEFAULT: RoomBudget = RoomBudget {
burst: 120,
interval: std::time::Duration::from_millis(100),
};
pub const ROOM_TRANSFER_DEFAULT: RoomBudget = RoomBudget {
burst: 200,
interval: std::time::Duration::from_millis(20),
};
impl RoomRateLimitsConfig {
fn is_unset(&self) -> bool {
*self == Self::default()
}
pub fn address(&self) -> RoomBudget {
RoomBudget::from(
self.address_burst,
self.address_interval_ms,
ROOM_ADDRESS_DEFAULT,
)
}
pub fn presenter(&self) -> RoomBudget {
RoomBudget::from(
self.presenter_burst,
self.presenter_interval_ms,
ROOM_PRESENTER_DEFAULT,
)
}
pub fn transfer(&self) -> RoomBudget {
RoomBudget::from(
self.transfer_burst,
self.transfer_interval_ms,
ROOM_TRANSFER_DEFAULT,
)
}
}
impl RoomsConfig {
pub fn files_enabled(&self) -> bool {
self.files.as_ref().is_some_and(|f| f.enabled)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct RoomFilesConfig {
#[serde(default = "default_files_enabled")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blob_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub local_storage_bases: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_file_bytes: Option<u64>,
#[serde(default)]
pub room: RoomFileLimits,
#[serde(default)]
pub member: RoomFileLimits,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage_capacity_bytes: Option<u64>,
#[serde(default = "default_orphan_grace_hours")]
pub orphan_grace_hours: u64,
#[serde(default = "default_blob_sweep_interval_secs")]
pub sweep_interval_secs: u64,
#[serde(default, skip_serializing_if = "is_default_direct")]
pub direct_transfer: vti_rooms::blobs::DirectTransfer,
}
fn is_default_direct(d: &vti_rooms::blobs::DirectTransfer) -> bool {
*d == vti_rooms::blobs::DirectTransfer::default()
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct RoomFileLimits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_files: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_file_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<u64>,
}
fn default_files_enabled() -> bool {
true
}
fn default_orphan_grace_hours() -> u64 {
vti_rooms::blobs::DEFAULT_ORPHAN_GRACE_SECS / 3600
}
fn default_blob_sweep_interval_secs() -> u64 {
600
}
impl Default for RoomFilesConfig {
fn default() -> Self {
Self {
enabled: default_files_enabled(),
blob_dir: None,
local_storage_bases: Vec::new(),
max_file_bytes: None,
room: RoomFileLimits::default(),
member: RoomFileLimits::default(),
storage_capacity_bytes: None,
orphan_grace_hours: default_orphan_grace_hours(),
sweep_interval_secs: default_blob_sweep_interval_secs(),
direct_transfer: Default::default(),
}
}
}
impl RoomFilesConfig {
pub fn limits(&self) -> vti_rooms::blobs::HostLimits {
let mut limits = vti_rooms::blobs::HostLimits {
files_enabled: self.enabled,
orphan_grace_secs: self.orphan_grace_hours.saturating_mul(3600),
storage_capacity_bytes: self.storage_capacity_bytes,
..Default::default()
};
if let Some(v) = self.max_file_bytes {
limits.max_file_bytes = v;
}
let set = |slot: &mut Option<u64>, v: Option<u64>| {
if v.is_some() {
*slot = v;
}
};
set(&mut limits.room.max_files, self.room.max_files);
set(&mut limits.room.max_file_bytes, self.room.max_file_bytes);
set(&mut limits.room.max_bytes, self.room.max_bytes);
set(&mut limits.member.max_files, self.member.max_files);
set(
&mut limits.member.max_file_bytes,
self.member.max_file_bytes,
);
set(&mut limits.member.max_bytes, self.member.max_bytes);
limits
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct Oid4vpConfig {
#[serde(default = "default_oid4vp_session_ttl")]
pub session_ttl_secs: u64,
#[serde(default = "default_oid4vp_max_open_sessions")]
pub max_open_sessions: usize,
#[serde(default = "default_oid4vp_evidence_hold")]
pub evidence_hold_secs: u64,
#[serde(default = "default_oid4vp_max_response_bytes")]
pub max_response_bytes: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_identity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wallet_uri_scheme: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub x509_trust_anchors: Vec<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swiyu_trust: Option<SwiyuTrustConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct SwiyuTrustConfig {
pub registry_url: String,
pub trust_anchor_did: String,
}
impl SwiyuTrustConfig {
pub fn check(&self) -> Result<(), String> {
let url = url::Url::parse(&self.registry_url)
.map_err(|e| format!("[oid4vp.swiyu_trust] registry_url: {e}"))?;
if url.scheme() != "https" {
return Err("[oid4vp.swiyu_trust] registry_url must be https".into());
}
if !self.trust_anchor_did.starts_with("did:") || self.trust_anchor_did.contains('#') {
return Err("[oid4vp.swiyu_trust] trust_anchor_did must be a DID".into());
}
Ok(())
}
}
fn default_oid4vp_session_ttl() -> u64 {
600
}
fn default_oid4vp_max_open_sessions() -> usize {
3
}
fn default_oid4vp_evidence_hold() -> u64 {
6 * 3600
}
fn default_oid4vp_max_response_bytes() -> usize {
4 * 1024 * 1024
}
impl Default for Oid4vpConfig {
fn default() -> Self {
Self {
session_ttl_secs: default_oid4vp_session_ttl(),
max_open_sessions: default_oid4vp_max_open_sessions(),
evidence_hold_secs: default_oid4vp_evidence_hold(),
max_response_bytes: default_oid4vp_max_response_bytes(),
x509_trust_anchors: Vec::new(),
swiyu_trust: None,
default_identity: None,
wallet_uri_scheme: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct GeoIpConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct AclConfig {
#[serde(default = "default_unrestricted_admin_consent_threshold")]
pub unrestricted_admin_consent_threshold: u64,
#[serde(default = "default_action_lifetime")]
pub action_lifetime: u64,
#[serde(default = "default_action_max_open_per_requester")]
pub action_max_open_per_requester: u64,
#[serde(default = "default_action_max_open")]
pub action_max_open: u64,
#[serde(default = "default_action_decline_cooldown")]
pub action_decline_cooldown: u64,
#[serde(default = "default_removal_cooling_off")]
pub removal_cooling_off: u64,
#[serde(default)]
pub consent_request_push: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub single_admin_mode: bool,
}
impl Default for AclConfig {
fn default() -> Self {
Self {
unrestricted_admin_consent_threshold: default_unrestricted_admin_consent_threshold(),
action_lifetime: default_action_lifetime(),
action_max_open_per_requester: default_action_max_open_per_requester(),
action_max_open: default_action_max_open(),
action_decline_cooldown: default_action_decline_cooldown(),
removal_cooling_off: default_removal_cooling_off(),
consent_request_push: false,
single_admin_mode: false,
}
}
}
pub(crate) fn default_unrestricted_admin_consent_threshold() -> u64 {
1
}
pub(crate) fn default_action_lifetime() -> u64 {
72 * 3600
}
pub(crate) fn default_action_max_open_per_requester() -> u64 {
5
}
pub(crate) fn default_action_max_open() -> u64 {
50
}
pub(crate) fn default_action_decline_cooldown() -> u64 {
3600
}
pub(crate) fn default_removal_cooling_off() -> u64 {
24 * 3600
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct TrustTasksConfig {
#[serde(
default,
deserialize_with = "refuse_disabling_declared_proof",
skip_serializing
)]
pub require_declared_proof: Option<bool>,
}
fn refuse_disabling_declared_proof<'de, D>(d: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
if bool::deserialize(d)? {
return Ok(Some(true));
}
Err(serde::de::Error::custom(
"`[trust_tasks] require_declared_proof = false` has been retired and \
cannot be honoured. A document carrying no `proof` for a task whose \
specification declares one REQUIRED is now always refused \
(VTI-OPS-020/021/093, Trust Tasks SPEC §7.2 item 7); the switch \
existed only while `openvtc-core` sent those documents unsigned, and \
openvtc#371 signs them. Delete the key. A community still serving an \
`openvtc` build older than that change must upgrade the client — \
there is no longer a setting that will accept its unsigned joins, \
status polls, self-removals, VMC collection or personhood assertions.",
))
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct AdminUiConfig {
#[serde(default = "default_admin_ui_mode")]
pub mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub members_dir: Option<std::path::PathBuf>,
#[serde(
default,
deserialize_with = "refuse_retired_external_origin",
skip_serializing
)]
pub external_origin: (),
#[serde(default)]
pub rp_id: Option<String>,
#[serde(default)]
pub plugin_dir: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sign_in_link_host: Option<String>,
}
impl Default for AdminUiConfig {
fn default() -> Self {
Self {
mode: default_admin_ui_mode(),
dir: None,
members_dir: None,
external_origin: (),
rp_id: None,
plugin_dir: None,
sign_in_link_host: None,
}
}
}
fn refuse_retired_external_origin<'de, D>(_: D) -> Result<(), D::Error>
where
D: serde::Deserializer<'de>,
{
Err(serde::de::Error::custom(RETIRED_EXTERNAL_MODE))
}
const RETIRED_EXTERNAL_MODE: &str = "admin_ui `mode = \"external\"` and `external_origin` have been \
retired — the console cannot run from another origin. To customise it, serve it from a \
directory instead: `vtc admin-ui export <dir>`, then `mode = \"directory\"` and \
`dir = \"<dir>\"`, and remove `external_origin`.";
fn default_admin_ui_mode() -> String {
"embedded".into()
}
impl AdminUiConfig {
pub fn sign_in_link_host(&self) -> &str {
self.sign_in_link_host
.as_deref()
.unwrap_or(crate::member_portal::oob::DEFAULT_LINK_HOST)
}
pub fn serve_dir(&self) -> Option<&std::path::Path> {
match self.mode.as_str() {
"directory" => self.dir.as_deref(),
_ => None,
}
}
fn validate(&self) -> Result<(), AppError> {
match (self.mode.as_str(), self.dir.is_some()) {
("directory", true) | ("embedded", false) => Ok(()),
("external", _) => Err(AppError::Config(RETIRED_EXTERNAL_MODE.into())),
("directory", false) => Err(AppError::Config(
"admin_ui.mode = \"directory\" needs admin_ui.dir — the directory \
holding the console's index.html (`vtc admin-ui export <dir>` writes \
the built-in console there to start from)"
.into(),
)),
("embedded", true) => Err(AppError::Config(format!(
"admin_ui.dir is set but admin_ui.mode = \"{}\", so it would be \
ignored — set mode = \"directory\" to serve the console from it, or \
remove dir",
self.mode
))),
(other, _) => Err(AppError::Config(format!(
"admin_ui.mode = \"{other}\" is not one of \"embedded\", \"directory\""
))),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct WebsiteConfig {
#[serde(default)]
pub root_dir: Option<PathBuf>,
#[serde(default = "default_deploy_mode")]
pub deploy_mode: String,
#[serde(default = "default_live_cache_ttl_seconds")]
pub live_cache_ttl_seconds: u64,
#[serde(default = "default_managed_generations_keep")]
pub managed_generations_keep: u32,
#[serde(default = "default_cache_control")]
pub cache_control: String,
#[serde(default = "default_executable_blocklist")]
pub executable_blocklist: Vec<String>,
#[serde(default = "default_max_bundle_size_mb")]
pub max_bundle_size_mb: u64,
#[serde(default = "default_max_file_size_mb")]
pub max_file_size_mb: u64,
#[serde(default = "default_csp_override_file")]
pub csp_override_file: String,
}
impl Default for WebsiteConfig {
fn default() -> Self {
Self {
root_dir: None,
deploy_mode: default_deploy_mode(),
live_cache_ttl_seconds: default_live_cache_ttl_seconds(),
managed_generations_keep: default_managed_generations_keep(),
cache_control: default_cache_control(),
executable_blocklist: default_executable_blocklist(),
max_bundle_size_mb: default_max_bundle_size_mb(),
max_file_size_mb: default_max_file_size_mb(),
csp_override_file: default_csp_override_file(),
}
}
}
fn default_deploy_mode() -> String {
"live".into()
}
fn default_live_cache_ttl_seconds() -> u64 {
5
}
fn default_managed_generations_keep() -> u32 {
5
}
fn default_cache_control() -> String {
"public, max-age=300".into()
}
fn default_executable_blocklist() -> Vec<String> {
vec![".cgi".into(), ".php".into(), ".exe".into()]
}
fn default_max_bundle_size_mb() -> u64 {
50
}
fn default_max_file_size_mb() -> u64 {
10
}
fn default_csp_override_file() -> String {
".vtc-website.toml".into()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub struct RenewalConfig {
#[serde(default)]
pub on_personhood_fail: PersonhoodFailMode,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum PersonhoodFailMode {
#[default]
Downgrade,
Refuse,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RegistryConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub did: Option<String>,
#[serde(default = "default_health_probe_interval")]
pub health_probe_interval_seconds: u64,
#[serde(default = "default_drift_check_interval")]
pub drift_check_interval_seconds: u64,
#[serde(default = "default_registry_http_timeout")]
pub http_timeout_seconds: u64,
#[serde(default = "default_rtbf_batch_window_hours")]
pub rtbf_batch_window_hours: u64,
}
fn default_health_probe_interval() -> u64 {
60
}
fn default_drift_check_interval() -> u64 {
900
}
fn default_registry_http_timeout() -> u64 {
5
}
fn default_rtbf_batch_window_hours() -> u64 {
24
}
impl Default for RegistryConfig {
fn default() -> Self {
Self {
url: None,
did: None,
health_probe_interval_seconds: default_health_probe_interval(),
drift_check_interval_seconds: default_drift_check_interval(),
http_timeout_seconds: default_registry_http_timeout(),
rtbf_batch_window_hours: default_rtbf_batch_window_hours(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RoutingConfig {
#[serde(default = "default_api_mount")]
pub api: MountConfig,
#[serde(default = "default_admin_ui_mount")]
pub admin_ui: MountConfig,
#[serde(default = "default_website_mount")]
pub website: MountConfig,
#[serde(default = "default_subdomain_mode_strict")]
pub subdomain_mode_strict: bool,
}
impl Default for RoutingConfig {
fn default() -> Self {
Self {
api: default_api_mount(),
admin_ui: default_admin_ui_mount(),
website: default_website_mount(),
subdomain_mode_strict: default_subdomain_mode_strict(),
}
}
}
fn default_subdomain_mode_strict() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct MountConfig {
pub mount: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
}
fn default_api_mount() -> MountConfig {
MountConfig {
mount: "/v1".into(),
host: None,
}
}
fn default_admin_ui_mount() -> MountConfig {
MountConfig {
mount: "/admin".into(),
host: None,
}
}
fn default_website_mount() -> MountConfig {
MountConfig {
mount: "/".into(),
host: None,
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct CorsConfig {
#[serde(default)]
pub allowed_origins: Vec<String>,
}
#[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 trust_xff_cidrs: Vec<IpNetwork>,
#[serde(
default,
deserialize_with = "refuse_retired_trust_xff",
skip_serializing
)]
pub trust_xff: (),
}
fn refuse_retired_trust_xff<'de, D>(_: D) -> Result<(), D::Error>
where
D: serde::Deserializer<'de>,
{
Err(serde::de::Error::custom(
"`trust_xff` has been retired. Replace it with `trust_xff_cidrs = [\"<cidr>\", ...]` \
— the addresses of the reverse proxies actually in front of this VTC. A `true` \
config silently keying the rate limiter on the socket peer after this change \
collapses every client behind that proxy into one shared bucket.",
))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SecretBackend {
Keyring,
Vault,
K8s,
Aws,
Gcp,
Azure,
Config,
Plaintext,
EncryptedFile,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SecretsConfig {
#[serde(default)]
pub backend: Option<SecretBackend>,
pub secret: Option<String>,
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)]
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 = "Option::is_none")]
pub encrypted_file: Option<vti_secrets::EncryptedFileConfig>,
}
fn default_keyring_service() -> String {
"vtc".to_string()
}
fn default_k8s_secret_key() -> String {
"secret".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()
}
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("secret", &self.secret.as_ref().map(|_| "<redacted>"))
.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("encrypted_file", &self.encrypted_file)
.finish()
}
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
backend: None,
secret: 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(),
encrypted_file: None,
}
}
}
fn default_host() -> String {
default_host_value()
}
fn default_port() -> u16 {
default_port_value()
}
pub(crate) fn default_host_value() -> String {
"0.0.0.0".to_string()
}
pub(crate) fn default_port_value() -> u16 {
8200
}
fn default_server_config() -> ServerConfig {
ServerConfig::default()
}
fn validate_routing(routing: &RoutingConfig) -> Result<(), AppError> {
for (name, m) in [
("api", &routing.api),
("admin_ui", &routing.admin_ui),
("website", &routing.website),
] {
if !m.mount.starts_with('/') {
return Err(AppError::Config(format!(
"routing.{name}.mount must start with '/': got '{}'",
m.mount
)));
}
}
if routing.admin_ui.host.is_none() && routing.admin_ui.mount == "/" {
return Err(AppError::Config(
"routing.admin_ui.mount = '/' would collapse the admin cookie scope; \
pick a non-root prefix (default: '/admin') or enable subdomain mode via \
routing.admin_ui.host"
.into(),
));
}
let path_mounts: Vec<(&str, &str)> = [
(
"api",
routing.api.host.as_deref(),
routing.api.mount.as_str(),
),
(
"admin_ui",
routing.admin_ui.host.as_deref(),
routing.admin_ui.mount.as_str(),
),
(
"website",
routing.website.host.as_deref(),
routing.website.mount.as_str(),
),
]
.into_iter()
.filter_map(|(name, host, mount)| host.is_none().then_some((name, mount)))
.collect();
for i in 0..path_mounts.len() {
for j in (i + 1)..path_mounts.len() {
let (a, am) = path_mounts[i];
let (b, bm) = path_mounts[j];
if am == bm {
return Err(AppError::Config(format!(
"routing.{a}.mount and routing.{b}.mount both = '{am}'; \
path-mode mounts must be unique",
)));
}
}
}
Ok(())
}
fn validate_website_isolation(
routing: &RoutingConfig,
website_on_filesystem: bool,
) -> Result<(), AppError> {
if !website_on_filesystem {
return Ok(());
}
let website_host = routing.website.host.as_deref().map(str::to_ascii_lowercase);
let Some(website_host) = website_host else {
return Err(AppError::Config(
"a filesystem website (website.root_dir) would share an origin with the \
admin/API surface, letting deployed website content ride the admin session \
cookie; set routing.website.host to a dedicated host, or remove \
website.root_dir to serve the trusted built-in landing page"
.into(),
));
};
for (name, surface) in [("api", &routing.api), ("admin_ui", &routing.admin_ui)] {
if surface.host.as_deref().map(str::to_ascii_lowercase) == Some(website_host.clone()) {
return Err(AppError::Config(format!(
"routing.website.host = '{website_host}' collides with routing.{name}.host; \
a filesystem website must be on a host distinct from the admin/API surface \
so deployed content can't ride the admin session cookie"
)));
}
}
Ok(())
}
fn validate_cors(cors: &CorsConfig) -> Result<(), AppError> {
for origin in &cors.allowed_origins {
let trimmed = origin.trim();
if trimmed.is_empty() {
return Err(AppError::Config(
"cors.allowed_origins contains an empty / whitespace entry".into(),
));
}
if trimmed == "*" || trimmed.contains('*') {
return Err(AppError::Config(format!(
"cors.allowed_origins entry '{trimmed}' uses a wildcard; \
spec §9.3 demands an exact-match allowlist"
)));
}
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
return Err(AppError::Config(format!(
"cors.allowed_origins entry '{trimmed}' must be a full origin \
(e.g., 'https://admin.example.com')"
)));
}
}
Ok(())
}
fn default_store_config() -> StoreConfig {
StoreConfig {
data_dir: PathBuf::from("data/vtc"),
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
trust_xff_cidrs: Vec::new(),
trust_xff: (),
}
}
}
impl AppConfig {
pub fn load(config_path: Option<PathBuf>) -> Result<Self, AppError> {
Self::load_with_unknown_keys(config_path).map(|(config, _)| config)
}
pub fn load_with_unknown_keys(
config_path: Option<PathBuf>,
) -> Result<(Self, Vec<String>), AppError> {
let path = config_path
.or_else(|| std::env::var("VTC_CONFIG_PATH").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("config.toml"));
if !path.exists() {
return Err(AppError::Config(format!(
"configuration file not found: {}",
path.display()
)));
}
let contents = std::fs::read_to_string(&path).map_err(AppError::Io)?;
let parse_err = |e: toml::de::Error| {
AppError::Config(format!("failed to parse {}: {e}", path.display()))
};
let de = toml::Deserializer::parse(&contents).map_err(parse_err)?;
let mut unknown_keys: Vec<String> = Vec::new();
let mut config: AppConfig =
serde_ignored::deserialize(de, |key_path| unknown_keys.push(key_path.to_string()))
.map_err(parse_err)?;
config.config_path = path.clone();
if config.trust_tasks.require_declared_proof == Some(true) {
eprintln!(
"warning: {}: `[trust_tasks] require_declared_proof = true` has been retired. \
It is now the only behaviour — a document carrying no `proof` for a task whose \
specification declares one REQUIRED is always refused (VTI-OPS-020/021/093) — \
so this deployment's behaviour is unchanged. Delete the key.",
path.display()
);
}
if let Ok(vtc_did) = std::env::var("VTC_DID") {
config.vtc_did = Some(vtc_did);
}
if let Ok(vta_did) = std::env::var("VTC_VTA_DID") {
config.vta_did = Some(vta_did);
}
if let Ok(host) = std::env::var("VTC_SERVER_HOST") {
config.server.host = host;
}
if let Ok(port) = std::env::var("VTC_SERVER_PORT") {
config.server.port = port
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_SERVER_PORT: {e}")))?;
}
if let Ok(level) = std::env::var("VTC_LOG_LEVEL") {
config.log.level = level;
}
if let Ok(format) = std::env::var("VTC_LOG_FORMAT") {
config.log.format = match format.to_lowercase().as_str() {
"json" => LogFormat::Json,
"text" => LogFormat::Text,
other => {
return Err(AppError::Config(format!(
"invalid VTC_LOG_FORMAT '{other}', expected 'text' or 'json'"
)));
}
};
}
if let Ok(public_url) = std::env::var("VTC_PUBLIC_URL") {
config.public_url = Some(public_url);
}
if let Ok(data_dir) = std::env::var("VTC_STORE_DATA_DIR") {
config.store.data_dir = PathBuf::from(data_dir);
}
vti_common::config::apply_fjall_env_overrides(&mut config.fjall)
.map_err(AppError::Config)?;
match (
std::env::var("VTC_MESSAGING_MEDIATOR_URL"),
std::env::var("VTC_MESSAGING_MEDIATOR_DID"),
) {
(Ok(url), Ok(did)) => {
config.messaging = Some(MessagingConfig {
mediator_url: url,
mediator_did: did,
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
}
(Ok(url), Err(_)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
messaging.mediator_url = url;
}
(Err(_), Ok(did)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
setup_acl: false,
drain_inbox_on_start: false,
});
messaging.mediator_did = did;
}
(Err(_), Err(_)) => {}
}
if let Ok(secret) = std::env::var("VTC_SECRETS_SECRET") {
config.secrets.secret = Some(secret);
}
if let Ok(name) = std::env::var("VTC_SECRETS_AWS_SECRET_NAME") {
config.secrets.aws_secret_name = Some(name);
}
if let Ok(region) = std::env::var("VTC_SECRETS_AWS_REGION") {
config.secrets.aws_region = Some(region);
}
if let Ok(project) = std::env::var("VTC_SECRETS_GCP_PROJECT") {
config.secrets.gcp_project = Some(project);
}
if let Ok(name) = std::env::var("VTC_SECRETS_GCP_SECRET_NAME") {
config.secrets.gcp_secret_name = Some(name);
}
if let Ok(url) = std::env::var("VTC_SECRETS_AZURE_VAULT_URL") {
config.secrets.azure_vault_url = Some(url);
}
if let Ok(name) = std::env::var("VTC_SECRETS_AZURE_SECRET_NAME") {
config.secrets.azure_secret_name = Some(name);
}
if let Ok(service) = std::env::var("VTC_SECRETS_KEYRING_SERVICE") {
config.secrets.keyring_service = service;
}
if let Ok(name) = std::env::var("VTC_SECRETS_K8S_SECRET_NAME") {
config.secrets.k8s_secret_name = Some(name);
}
if let Ok(ns) = std::env::var("VTC_SECRETS_K8S_NAMESPACE") {
config.secrets.k8s_namespace = Some(ns);
}
if let Ok(key) = std::env::var("VTC_SECRETS_K8S_SECRET_KEY") {
config.secrets.k8s_secret_key = key;
}
if let Ok(expiry) = std::env::var("VTC_AUTH_ACCESS_EXPIRY") {
config.auth.access_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_ACCESS_EXPIRY: {e}")))?;
}
if let Ok(expiry) = std::env::var("VTC_AUTH_REFRESH_EXPIRY") {
config.auth.refresh_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_REFRESH_EXPIRY: {e}")))?;
}
if let Ok(ttl) = std::env::var("VTC_AUTH_CHALLENGE_TTL") {
config.auth.challenge_ttl = ttl
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_CHALLENGE_TTL: {e}")))?;
}
if let Ok(interval) = std::env::var("VTC_AUTH_SESSION_CLEANUP_INTERVAL") {
config.auth.session_cleanup_interval = interval.parse().map_err(|e| {
AppError::Config(format!("invalid VTC_AUTH_SESSION_CLEANUP_INTERVAL: {e}"))
})?;
}
if let Ok(key) = std::env::var("VTC_AUTH_JWT_SIGNING_KEY") {
config.auth.jwt_signing_key = Some(key);
}
if let Ok(path) = std::env::var("VTC_GEOIP_DATABASE") {
config.geoip.database = (!path.trim().is_empty()).then(|| PathBuf::from(path));
}
config.validate_routing_and_cors()?;
let did_cache_errors = config.did_cache.validation_errors();
if !did_cache_errors.is_empty() {
return Err(AppError::Config(did_cache_errors.join("; ")));
}
Ok((config, unknown_keys))
}
pub fn validate_routing_and_cors(&self) -> Result<(), AppError> {
validate_routing(&self.routing)?;
validate_website_isolation(&self.routing, self.website.root_dir.is_some())?;
validate_cors(&self.cors)?;
self.admin_ui.validate()?;
self.witnesses.validate(self.vtc_did.as_deref())?;
Ok(())
}
pub fn warn_unknown_keys(&self, unknown_keys: &[String]) {
for key in unknown_keys {
tracing::warn!("{}", unknown_key_message(&self.config_path, key));
}
}
pub fn save(&self) -> Result<(), AppError> {
self.validate_routing_and_cors()?;
let contents = toml::to_string_pretty(self)
.map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
std::fs::write(&self.config_path, contents).map_err(AppError::Io)?;
Ok(())
}
}
fn unknown_key_message(config_path: &std::path::Path, key: &str) -> String {
let path = config_path.display();
match key.rsplit_once('.') {
Some((table, leaf)) => format!(
"unknown configuration key `{key}` in {path} — ignored. There is no `{leaf}` \
setting in [{table}]. Check for a typo or a removed/renamed setting; if \
`{leaf}` belongs to another section, note that a key placed after a [table] \
header belongs to that table, so a key appended at the end of the file lands \
in its last section."
),
None => format!(
"unknown configuration key `{key}` in {path} — ignored. Check for a typo or a \
removed/renamed setting."
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn room_files_are_off_without_the_section_and_on_with_it() {
let empty: AppConfig = toml::from_str("").expect("empty config");
assert!(empty.rooms.files.is_none());
assert!(!empty.rooms.files_enabled());
assert!(!toml::to_string(&empty).unwrap().contains("[rooms.files]"));
let paths: AppConfig = toml::from_str(
"[rooms.files]\nlocal_storage_bases = [\"/srv/vtc/room-storage\"]\n\
blob_dir = \"/srv/vtc/room-blobs\"\n",
)
.expect("the section the console suggests");
assert!(
paths.rooms.files_enabled(),
"writing the section turns files on"
);
let off: AppConfig = toml::from_str("[rooms.files]\nenabled = false\n").unwrap();
assert!(!off.rooms.files_enabled());
let cfg: AppConfig = toml::from_str(
"[rooms.files]\nmax_file_bytes = 1048576\norphan_grace_hours = 24\n\
[rooms.files.room]\nmax_files = 50\n[rooms.files.member]\nmax_bytes = 2048\n",
)
.expect("a [rooms.files] section");
let limits = cfg.rooms.files.as_ref().unwrap().limits();
assert!(limits.files_enabled);
assert_eq!(limits.max_file_bytes, 1_048_576);
assert_eq!(limits.orphan_grace_secs, 24 * 3600);
assert_eq!(limits.room.max_files, Some(50));
assert_eq!(limits.member.max_bytes, Some(2048));
assert_eq!(
limits.room.max_bytes,
vti_rooms::blobs::HostLimits::default().room.max_bytes
);
assert!(
toml::from_str::<AppConfig>("[rooms.files]\nenable = true\n").is_err(),
"a misspelt key is refused, not ignored"
);
}
#[test]
fn geoip_database_is_optional() {
let empty: AppConfig = toml::from_str("").expect("empty config");
assert_eq!(empty.geoip.database, None);
assert!(!toml::to_string(&empty).unwrap().contains("database"));
let set: AppConfig =
toml::from_str("[geoip]\ndatabase = \"/var/lib/vtc/dbip-city-lite.mmdb\"")
.expect("geoip config");
assert_eq!(
set.geoip.database.as_deref(),
Some(std::path::Path::new("/var/lib/vtc/dbip-city-lite.mmdb"))
);
}
#[test]
fn admin_ui_dir_goes_with_directory_mode_only() {
let parse = |s: &str| toml::from_str::<AdminUiConfig>(s).expect("parse admin_ui");
let directory = parse("mode = \"directory\"\ndir = \"/srv/console\"");
directory.validate().unwrap();
assert_eq!(
directory.serve_dir(),
Some(std::path::Path::new("/srv/console"))
);
parse("").validate().unwrap();
assert_eq!(parse("").serve_dir(), None);
for bad in [
"mode = \"directory\"",
"dir = \"/srv/console\"",
"mode = \"custom\"",
] {
assert!(parse(bad).validate().is_err(), "{bad:?} must be refused");
}
}
#[test]
fn retired_external_mode_is_refused_naming_directory_mode() {
let err = parse_admin_ui("mode = \"external\"")
.validate()
.unwrap_err()
.to_string();
assert!(err.contains("mode = \"directory\""), "{err}");
let err = toml::from_str::<AdminUiConfig>("external_origin = \"https://a.example\"")
.unwrap_err()
.to_string();
assert!(err.contains("vtc admin-ui export"), "{err}");
}
fn parse_admin_ui(s: &str) -> AdminUiConfig {
toml::from_str(s).expect("parse admin_ui")
}
#[test]
fn secret_backend_selector_parses_each_variant() {
for (wire, expected) in [
("keyring", SecretBackend::Keyring),
("vault", SecretBackend::Vault),
("k8s", SecretBackend::K8s),
("aws", SecretBackend::Aws),
("gcp", SecretBackend::Gcp),
("azure", SecretBackend::Azure),
("config", SecretBackend::Config),
("plaintext", SecretBackend::Plaintext),
("encrypted-file", SecretBackend::EncryptedFile),
] {
let cfg: SecretsConfig =
toml::from_str(&format!("backend = \"{wire}\"")).expect("parse backend");
assert_eq!(cfg.backend, Some(expected), "wire form {wire:?}");
}
}
#[test]
fn secret_backend_omitted_is_none() {
let cfg: SecretsConfig = toml::from_str("keyring_service = \"vtc\"").expect("parse");
assert_eq!(cfg.backend, None);
}
#[test]
fn retired_trust_xff_key_is_refused() {
let err = toml::from_str::<ServerConfig>("trust_xff = true")
.expect_err("retired trust_xff key must be rejected");
assert!(format!("{err}").contains("trust_xff_cidrs"), "{err}");
}
#[test]
fn trust_xff_cidrs_still_parses() {
let cfg: ServerConfig =
toml::from_str("trust_xff_cidrs = [\"127.0.0.1/32\"]").expect("parse");
assert_eq!(cfg.trust_xff_cidrs.len(), 1);
}
#[test]
fn secret_backend_unknown_is_rejected() {
assert!(
toml::from_str::<SecretsConfig>("backend = \"hashicorp\"").is_err(),
"unknown backend spelling must be a parse error"
);
}
#[test]
fn secrets_config_debug_redacts_secret() {
let cfg = SecretsConfig {
secret: Some("deadbeefdeadbeef".into()),
keyring_service: "vtc".into(),
vault_token: Some("hvs.SUPERSECRETTOKEN".into()),
vault_approle_secret_id: Some("APPROLE_SECRET_ID_XYZ".into()),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(dbg.contains("<redacted>"), "got {dbg}");
assert!(!dbg.contains("deadbeef"), "secret leaked: {dbg}");
assert!(
!dbg.contains("SUPERSECRETTOKEN"),
"vault_token leaked: {dbg}"
);
assert!(
!dbg.contains("APPROLE_SECRET_ID_XYZ"),
"vault_approle_secret_id leaked: {dbg}"
);
assert!(dbg.contains("vtc"));
}
#[test]
fn empty_toml_parses_with_all_defaults() {
let config: AppConfig = toml::from_str("").expect("empty TOML must parse");
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8200, "VTC default port is 8200");
assert!(config.vtc_did.is_none());
assert!(config.vta_did.is_none());
assert!(config.messaging.is_none());
}
#[test]
fn minimal_toml_parses() {
let toml_src = r#"
vtc_did = "did:key:zVTC"
vta_did = "did:key:zVTA"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("minimal TOML must parse");
assert_eq!(config.vtc_did.as_deref(), Some("did:key:zVTC"));
assert_eq!(config.vta_did.as_deref(), Some("did:key:zVTA"));
}
#[test]
fn community_name_alias_is_accepted() {
let toml_src = r#"
community_name = "Alpha Community"
community_description = "First VTC"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("alias must parse");
assert_eq!(config.vtc_name.as_deref(), Some("Alpha Community"));
assert_eq!(config.vtc_description.as_deref(), Some("First VTC"));
}
#[test]
fn vtc_name_canonical_field_is_accepted() {
let toml_src = r#"
vtc_name = "Alpha"
vtc_description = "Canonical"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("canonical name parses");
assert_eq!(config.vtc_name.as_deref(), Some("Alpha"));
}
#[test]
fn invalid_toml_produces_config_error() {
let err = toml::from_str::<AppConfig>("server.port = \"not-a-number\"")
.expect_err("invalid port type must fail parse");
let msg = format!("{err}");
assert!(msg.contains("port"), "error must name the field: {msg}");
}
#[test]
fn server_port_bounds() {
let toml_src = r#"
[server]
port = 70000
"#;
let err =
toml::from_str::<AppConfig>(toml_src).expect_err("out-of-range port must not parse");
assert!(format!("{err}").contains("port"), "got {err}");
}
#[test]
fn secrets_config_keyring_service_defaults_to_vtc() {
let empty: AppConfig = toml::from_str("").unwrap();
assert_eq!(empty.secrets.keyring_service, "vtc");
}
#[test]
fn secrets_unknown_key_is_a_named_parse_error() {
let toml_src = r#"
[secrets]
aws_secretname = "prod/vtc"
"#;
let err = toml::from_str::<AppConfig>(toml_src)
.expect_err("unknown [secrets] key must not parse");
let msg = format!("{err}");
assert!(
msg.contains("aws_secretname"),
"error must name the offending key: {msg}"
);
}
#[test]
fn secrets_known_keys_still_parse() {
let toml_src = r#"
[secrets]
keyring_service = "vtc-test"
aws_secret_name = "prod/vtc"
aws_region = "us-east-1"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("known keys parse");
assert_eq!(config.secrets.keyring_service, "vtc-test");
assert_eq!(config.secrets.aws_secret_name.as_deref(), Some("prod/vtc"));
}
#[test]
fn config_round_trip_preserves_fields() {
let original: AppConfig = toml::from_str(
r#"
vtc_did = "did:key:zVTC"
vta_did = "did:key:zVTA"
vtc_name = "Round Trip"
public_url = "https://vtc.example.com"
"#,
)
.unwrap();
let serialized = toml::to_string_pretty(&original).expect("serialize ok");
let parsed: AppConfig = toml::from_str(&serialized).expect("re-parse ok");
assert_eq!(parsed.vtc_did, original.vtc_did);
assert_eq!(parsed.vta_did, original.vta_did);
assert_eq!(parsed.vtc_name, original.vtc_name);
assert_eq!(parsed.public_url, original.public_url);
}
#[test]
fn a_config_asking_to_disable_the_declared_proof_check_fails_to_load() {
let err = toml::from_str::<AppConfig>("[trust_tasks]\nrequire_declared_proof = false\n")
.expect_err("the retired leniency must not be honoured");
let rendered = err.to_string();
for expected in ["retired", "openvtc#371", "VTI-OPS-020/021/093"] {
assert!(
rendered.contains(expected),
"the refusal must name {expected}, got: {rendered}"
);
}
}
#[test]
fn a_config_that_already_enabled_it_still_loads() {
let config: AppConfig = toml::from_str("[trust_tasks]\nrequire_declared_proof = true\n")
.expect("`= true` was already the behaviour; it must not become a startup failure");
assert_eq!(config.trust_tasks.require_declared_proof, Some(true));
}
#[test]
fn a_config_without_the_retired_key_says_nothing() {
let config: AppConfig = toml::from_str("").expect("empty TOML must parse");
assert_eq!(config.trust_tasks.require_declared_proof, None);
}
#[test]
fn the_retired_key_is_never_written_back() {
let config: AppConfig =
toml::from_str("[trust_tasks]\nrequire_declared_proof = true\n").expect("parses");
let serialized = toml::to_string_pretty(&config).expect("serialize ok");
assert!(
!serialized.contains("require_declared_proof"),
"a retired key must not be re-emitted: {serialized}"
);
}
fn load(contents: &str) -> ((AppConfig, Vec<String>), tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
std::fs::write(&path, contents).expect("write config");
let config = AppConfig::load_with_unknown_keys(Some(path)).expect("load config");
(config, dir)
}
#[test]
fn the_did_cache_ttl_is_bounded() {
let ((config, _), _dir) = load("");
assert_eq!(
config.did_cache.ttl_secs,
vti_common::config::DID_CACHE_TTL_DEFAULT_SECS
);
let ((config, _), _dir) = load("[did_cache]\nttl_secs = 30\n");
assert_eq!(config.did_cache.ttl_secs, 30);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "[did_cache]\nttl_secs = 3600\n").expect("write config");
let err = AppConfig::load_with_unknown_keys(Some(path)).expect_err("3600 s is refused");
assert!(format!("{err}").contains("did_cache.ttl_secs"), "{err}");
}
#[test]
fn a_key_appended_after_the_last_table_is_reported_with_its_dotted_path() {
let ((config, unknown_keys), _dir) = load(
"vtc_name = \"acme\"\n\
[cors]\nallowed_origins = [\"https://admin.example.com\"]\n\
[join_requests]\n\
allowed_origins = [\"https://app.example.com\"]\n",
);
assert_eq!(
unknown_keys,
vec!["join_requests.allowed_origins".to_string()]
);
let msg = unknown_key_message(&config.config_path, &unknown_keys[0]);
assert!(msg.contains("`join_requests.allowed_origins`"), "{msg}");
assert!(
msg.contains("a key placed after a [table] header belongs to that table"),
"{msg}"
);
}
#[test]
fn a_top_level_typo_is_reported_plainly() {
let ((config, unknown_keys), _dir) = load("vtc_naem = \"acme\"\n");
assert_eq!(unknown_keys, vec!["vtc_naem".to_string()]);
assert!(!unknown_key_message(&config.config_path, "vtc_naem").contains("[table]"));
}
#[test]
fn a_valid_config_reports_no_unknown_keys() {
let ((config, unknown_keys), _dir) = load(
"community_name = \"acme\"\n\
[server]\nport = 9000\n\
[cors]\nallowed_origins = [\"https://admin.example.com\"]\n",
);
assert!(unknown_keys.is_empty(), "{:?}", unknown_keys);
assert_eq!(config.vtc_name.as_deref(), Some("acme"));
}
#[test]
fn a_saved_config_reloads_with_no_unknown_keys() {
let config: AppConfig = toml::from_str("").expect("empty TOML parses");
let written = toml::to_string_pretty(&config).expect("serialize ok");
let ((_, unknown_keys), _dir) = load(&written);
assert!(unknown_keys.is_empty(), "{:?}", unknown_keys);
}
}
#[cfg(test)]
mod room_rate_limit_tests {
use super::*;
#[test]
fn an_absent_section_is_the_defaults() {
let rooms: RoomsConfig = toml::from_str("").unwrap();
assert_eq!(rooms.rate_limits, RoomRateLimitsConfig::default());
assert_eq!(rooms.rate_limits.address(), ROOM_ADDRESS_DEFAULT);
assert_eq!(rooms.rate_limits.presenter(), ROOM_PRESENTER_DEFAULT);
assert_eq!(rooms.rate_limits.transfer(), ROOM_TRANSFER_DEFAULT);
assert!(!toml::to_string(&rooms).unwrap().contains("rate_limits"));
}
#[test]
fn a_partial_section_keeps_the_rest_and_zero_is_one() {
let rooms: RoomsConfig =
toml::from_str("[rate_limits]\npresenter_burst = 30\ntransfer_interval_ms = 0\n")
.unwrap();
let l = &rooms.rate_limits;
assert_eq!(l.presenter().burst, 30);
assert_eq!(l.presenter().interval, ROOM_PRESENTER_DEFAULT.interval);
assert_eq!(l.transfer().interval, std::time::Duration::from_millis(1));
assert_eq!(l.address(), ROOM_ADDRESS_DEFAULT);
assert!(toml::from_str::<RoomsConfig>("[rate_limits]\nburst = 1\n").is_err());
}
}